import PostalMime from "postal-mime"; import { env } from "cloudflare:workers"; import { loadActiveUserById, toViewer } from "./auth"; import { hmacSha256Hex, timingSafeEqualStrings } from "./crypto"; import { repoDb } from "./scoped-db"; import { issueRepoId } from "./issue-scope"; import { HttpError } from "./errors"; import { createIssueComment } from "./issue-comments"; import type { IssueRow } from "./issues"; import { limitWrite } from "./limits"; import { loadRepoById, requireRepoMutation } from "./repos"; import { listAccountEmails } from "./user-emails"; import { extractReplyText } from "~/lib/email-reply-text"; export const REPLY_PREFIX = "reply+"; const MAC_LENGTH = 12; const EXPIRY_LENGTH = 3; const DAY_MS = 86_400_000; const REPLY_TTL_DAYS = 90; /** Cloudflare Email Routing's authserv-id; an Authentication-Results header from anyone else is ignored. */ const AUTHSERV_ID = "mx.cloudflare.net"; const MAX_LOCAL_PART = 64; const MAX_RAW_BYTES = 1024 * 1024; const MAX_BODY_LENGTH = 65_536; const ID = /^[a-z0-9]+$/; const AUTOMATED_PRECEDENCE = /^(bulk|junk|list|auto_reply)$/i; /** EMAIL_REPLY_DOMAIN, else the sending domain of EMAIL_FROM; empty (replies off) for localhost senders. */ export function replyDomain(): string { const configured = (env.EMAIL_REPLY_DOMAIN ?? "").trim().toLowerCase(); if (configured) return configured; const sender = /@([^>\s]+)/.exec(env.EMAIL_FROM ?? "")?.[1]?.toLowerCase() ?? ""; return sender.includes(".") && sender !== "localhost" ? sender : ""; } async function replyMac(userId: string, issueId: string, expiry: string): Promise { return (await hmacSha256Hex(env.SECRETS_KEY, `email-reply:v2:${issueId}:${userId}:${expiry}`)).slice(0, MAC_LENGTH); } function expiryToken(now: number): string { return (Math.floor(now / DAY_MS) + REPLY_TTL_DAYS).toString(36).padStart(EXPIRY_LENGTH, "0"); } /** * A per-recipient, per-thread reply address: `reply+..@`. The MAC binds the * address to one user, one thread and an expiry day, so only recent mail that user received can be * answered; null when replies are not configured. */ export async function replyAddressFor(userId: string, issueId: string, now = Date.now()): Promise { const domain = replyDomain(); if (!domain || !ID.test(userId) || !ID.test(issueId)) return null; const expiry = expiryToken(now); const local = `${REPLY_PREFIX}${issueId}.${userId}.${expiry}${await replyMac(userId, issueId, expiry)}`; return local.length <= MAX_LOCAL_PART ? `${local}@${domain}` : null; } export function isReplyAddress(address: string): boolean { return address.toLowerCase().startsWith(REPLY_PREFIX); } type ReplyTarget = { issueId: string; userId: string }; async function parseReplyAddress(address: string): Promise { const domain = replyDomain(); const match = /^reply\+([a-z0-9]+)\.([a-z0-9]+)\.([0-9a-z]{3})([0-9a-f]{12})@(.+)$/.exec( address.trim().toLowerCase(), ); if (!domain || !match || match[5] !== domain) return null; const [, issueId, userId, expiry, mac] = match; if (!timingSafeEqualStrings(mac, await replyMac(userId, issueId, expiry))) return null; return Number.parseInt(expiry, 36) * DAY_MS > Date.now() ? { issueId, userId } : null; } function domainOf(address: string): string { return address.slice(address.lastIndexOf("@") + 1).toLowerCase(); } function aligned(domain: string, senderDomain: string): boolean { if (!domain.includes(".")) return false; return domain === senderDomain || senderDomain.endsWith(`.${domain}`) || domain.endsWith(`.${senderDomain}`); } /** * Trusts only the topmost Authentication-Results header (the one our MX prepends; anything below it * came from the sender) and requires DMARC pass, or a DKIM pass aligned with the sender's domain. */ function senderAuthenticated(authResults: string | undefined, sender: string): boolean { if (!authResults) return false; const results = authResults.trim().toLowerCase(); if (!results.startsWith(`${AUTHSERV_ID};`)) return false; const senderDomain = domainOf(sender); for (const match of results.matchAll(/dmarc=pass\b[^;]*?header\.from=([a-z0-9.-]+)/g)) if (aligned(match[1], senderDomain)) return true; for (const match of results.matchAll(/dkim=pass\b[^;]*?header\.(?:d|i)=@?([a-z0-9.-]+)/g)) if (aligned(match[1], senderDomain)) return true; return false; } /** Out-of-office replies, bounces and list traffic must never become comments (or mail loops). */ function isAutomated(headers: Headers): boolean { const autoSubmitted = headers.get("auto-submitted"); if (autoSubmitted && autoSubmitted.toLowerCase() !== "no") return true; if (headers.has("x-autoreply") || headers.has("x-autorespond")) return true; return AUTOMATED_PRECEDENCE.test(headers.get("precedence") ?? ""); } async function senderIsVerified(userId: string, sender: string): Promise { const address = sender.trim().toLowerCase(); return (await listAccountEmails(userId)).some((email) => email.verified && email.email.toLowerCase() === address); } /** Rejecting tells the sender why their reply did not post; Email Routing returns it as a bounce. */ class ReplyRejected extends Error {} async function postReply(message: ForwardableEmailMessage): Promise { if (message.rawSize > MAX_RAW_BYTES) throw new ReplyRejected("Message too large"); const target = await parseReplyAddress(message.to); if (!target) throw new ReplyRejected("Unknown reply address"); const parsed = await PostalMime.parse(await new Response(message.raw).arrayBuffer()); const sender = message.from.trim().toLowerCase(); if ((parsed.from?.address ?? "").trim().toLowerCase() !== sender) throw new ReplyRejected("The From header does not match the envelope sender"); const authResults = parsed.headers.find((header) => header.key === "authentication-results")?.value; if (!senderAuthenticated(authResults, sender)) throw new ReplyRejected("The sender could not be authenticated"); if (!(await senderIsVerified(target.userId, sender))) throw new ReplyRejected("Reply from an address that is not verified on this account"); const user = await loadActiveUserById(target.userId); if (!user) throw new ReplyRejected("Unknown reply address"); const viewer = toViewer(user); const issueRepo = await issueRepoId(target.issueId); const issue = issueRepo ? await repoDb(issueRepo).first("SELECT * FROM issues WHERE id = ? AND hidden_at IS NULL", target.issueId) : null; const repo = issue ? await loadRepoById(issue.repo_id) : null; if (!issue || !repo) throw new ReplyRejected("This conversation no longer exists"); const access = await requireRepoMutation(repo.owner_name, repo.name, viewer, "read"); if (issue.is_pull === 0 && repo.has_issues !== 1) throw new ReplyRejected("Issues are disabled for this repository"); const body = extractReplyText(parsed.text ?? null, parsed.html ?? null).slice(0, MAX_BODY_LENGTH); if (!body) throw new ReplyRejected("The reply was empty"); await limitWrite(viewer); await createIssueComment(access, issue, viewer, body); } /** Inbound handler for `reply+…@` addresses: turns an emailed reply into a comment on the thread. */ export async function handleReplyEmail(message: ForwardableEmailMessage): Promise { if (isAutomated(message.headers)) return; try { await postReply(message); } catch (error) { if (error instanceof ReplyRejected) return message.setReject(error.message); if (error instanceof HttpError) return message.setReject(error.message); throw error; } }