import PostalMime from "postal-mime"; import { abuseForwardAddress, guessAbuseCategory, recordAbuseReport, siteIdForUrl, } 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 }; } 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. */ 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; for (const targetUrl of targets) { await recordAbuseReport({ source: "email", targetUrl, category, details, reporterEmail: sender, reporterKey: `email-domain:${senderDomain}`, }); } }