import PostalMime from "postal-mime"; import { abuseForwardAddress, guessAbuseCategory, recordAbuseReport, siteIdForUrl } from "./abuse"; import type { ReporterKind } from "./abuse"; const MAX_RAW_BYTES = 2 * 1024 * 1024; const MAX_TARGETS = 10; const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'()]+/gi; const TRAILING_PUNCTUATION = /[.,;:!?\]]+$/; /** URLs mentioned in the mail, hosted sites first so a long signature can't push the reported page out. */ export async function extractReportTargets(text: string): Promise<{ hosted: string[]; other: string[] }> { const seen = new Set(); const hosted: string[] = []; const other: string[] = []; for (const match of text.matchAll(URL_PATTERN)) { const url = match[0].replace(TRAILING_PUNCTUATION, ""); if (seen.has(url) || seen.size >= MAX_TARGETS * 4) continue; seen.add(url); if (hosted.length < MAX_TARGETS && (await siteIdForUrl(url))) hosted.push(url); else if (other.length < MAX_TARGETS) other.push(url); } return { hosted, other }; } /** * Whether the receiving MTA (Cloudflare Email Routing, which prepends its own Authentication-Results) saw a DMARC * pass aligned with the From domain. Only the topmost header counts: anything below it came from the sender and * can be forged. */ export function dmarcPassed(headers: { key: string; value: string }[], fromDomain: string): boolean { const results = headers.find((header) => header.key.toLowerCase() === "authentication-results")?.value; if (!results || !fromDomain) return false; const dmarc = results .split(";") .map((part) => part.trim().toLowerCase()) .find((part) => part.startsWith("dmarc=")); if (!dmarc || !/^dmarc=pass\b/.test(dmarc)) return false; const from = /header\.from=([^\s;]+)/.exec(dmarc)?.[1]; return from === fromDomain.toLowerCase(); } async function readRaw(stream: ReadableStream): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (size < MAX_RAW_BYTES) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); size += value.byteLength; } await reader.cancel().catch(() => undefined); const out = new Uint8Array(Math.min(size, MAX_RAW_BYTES)); let offset = 0; for (const chunk of chunks) { const slice = chunk.subarray(0, out.byteLength - offset); out.set(slice, offset); offset += slice.byteLength; } return out; } /** * Inbound handler for abuse@: forwards the original to the operator, then files one report per hosted URL it * mentions (or one unlinked report). Reporters count per sender domain, since anyone can mint sender addresses, * and only DMARC-authenticated domains count toward auto-pause: an unauthenticated From is trivially forged. */ export async function handleAbuseEmail(message: ForwardableEmailMessage): Promise { const forwardTo = abuseForwardAddress(); if (forwardTo) { await message.forward(forwardTo).catch((error: unknown) => console.error("abuse@ forward failed", error)); } const parsed = await PostalMime.parse(await readRaw(message.raw)); const subject = parsed.subject ?? message.headers.get("subject") ?? ""; const body = parsed.text ?? parsed.html?.replace(/<[^>]+>/g, " ") ?? ""; const details = `${subject}\n\n${body}`.trim(); const sender = parsed.from?.address ?? message.from; const { hosted, other } = await extractReportTargets(`${subject}\n${parsed.text ?? ""}\n${parsed.html ?? ""}`); const targets = hosted.length > 0 ? hosted : [other[0] ?? `mailto:${message.to}`]; const category = guessAbuseCategory(details); const senderDomain = sender.split("@").pop()?.toLowerCase() ?? sender; const reporterKind: ReporterKind = dmarcPassed(parsed.headers ?? [], senderDomain) ? "email-verified" : "email-unverified"; for (const targetUrl of targets) { await recordAbuseReport({ source: "email", targetUrl, category, details, reporterEmail: sender, reporterKey: `email-domain:${senderDomain}`, reporterKind, }); } }