/** * Cloudflare Email Sending event subscriptions, as data. * * A sending domain only produces delivery/bounce/complaint events if an event * subscription binds it to the queue this worker consumes. Nothing in Reccado * used to know that, so three features read the *absence* of events as evidence * — and absence is only evidence where presence was possible. This module is the * shared vocabulary for the two scripts that can see the provider's config * plane: `setup:sending` (which creates the subscription) and `doctor --cloud` * (which cross-checks live lists against each other). * * Everything here is pure. The wrangler subprocesses live in the callers, which * is what lets the verdict logic be unit-tested without a Cloudflare account — * and the verdict logic is the part with the interesting failure modes. */ /** * The six event types a sending domain must publish for Reccado's delivery * tracking and suppression mirror to be whole. A subscription with only * `message.delivered` selected passes any existence check while bounces and * complaints stay dark, which is why the event set is checked and not just the * subscription's presence. */ export const EMAIL_SENDING_EVENT_TYPES = [ "message.delivered", "message.deferred", "message.bounced", "message.rejected", "message.complained", "message.failed", ] as const; /** The wrangler version that first exposed `--source email.sending`. */ export const MIN_WRANGLER_FOR_EMAIL_SENDING = "4.127.1"; export type EventSubscription = { id: string; name: string; enabled: boolean; source: { type: string; domain?: string; zone_id?: string }; destination: { type: string; queue_id?: string }; events: string[]; }; export type SendingDomain = { zone: string; name: string; enabled: boolean }; export type AccountQueue = { id: string; name: string; consumers: number }; export type FeedbackSubscriptionVerdict = /** All six event types reach the expected queue. */ | { state: "live"; subscriptionIds: string[] } /** No `email.sending` subscription names this domain at all. */ | { state: "absent" } /** One exists but is switched off, so it publishes nothing. */ | { state: "disabled"; subscriptionIds: string[] } /** One exists and is enabled, but delivers somewhere else. */ | { state: "wrong_queue"; subscriptionIds: string[]; queueIds: string[] } /** Reaches us, but does not carry every event type we act on. */ | { state: "partial_events"; subscriptionIds: string[]; missingEvents: string[] }; /** * Decides whether a sending domain's feedback channel actually reaches us. * * Three separate ways to be dark, deliberately not collapsed into a boolean: a * missing subscription, one pointed at another queue (the live environment here * is `reccado-dev`, so a dev/prod mix-up is one wrong dropdown away), and one * missing event types. They need different fixes, so they get different names. * * Coverage is the union across every usable subscription rather than the best * single one: two subscriptions splitting the six event types between them is a * legitimate configuration, and calling that broken would be a false alarm the * operator learns to ignore. */ export function evaluateFeedbackSubscription(opts: { sendingDomain: string; expectedQueueId: string; subscriptions: EventSubscription[]; }): FeedbackSubscriptionVerdict { const domain = opts.sendingDomain.trim().toLowerCase(); const candidates = opts.subscriptions.filter( (sub) => sub.source?.type === "email.sending" && sub.source?.domain?.toLowerCase() === domain, ); if (candidates.length === 0) return { state: "absent" }; const ids = candidates.map((sub) => sub.id); const enabled = candidates.filter((sub) => sub.enabled); if (enabled.length === 0) return { state: "disabled", subscriptionIds: ids }; const usable = enabled.filter((sub) => sub.destination?.queue_id === opts.expectedQueueId); if (usable.length === 0) { return { state: "wrong_queue", subscriptionIds: enabled.map((sub) => sub.id), queueIds: [ ...new Set(enabled.map((sub) => sub.destination?.queue_id ?? "unknown").filter(Boolean)), ], }; } const covered = new Set(usable.flatMap((sub) => sub.events ?? [])); const missingEvents = EMAIL_SENDING_EVENT_TYPES.filter((type) => !covered.has(type)); const usableIds = usable.map((sub) => sub.id); if (missingEvents.length > 0) { return { state: "partial_events", subscriptionIds: usableIds, missingEvents }; } return { state: "live", subscriptionIds: usableIds }; } /** One line an operator can read, for every verdict including the good one. */ export function describeFeedbackVerdict( sendingDomain: string, queueName: string, verdict: FeedbackSubscriptionVerdict, ): string { switch (verdict.state) { case "live": return `${sendingDomain} publishes all ${EMAIL_SENDING_EVENT_TYPES.length} lifecycle events to ${queueName}.`; case "absent": return `${sendingDomain} has no Email Sending event subscription on ${queueName} — every send from it stays delivery_status=null forever.`; case "disabled": return `${sendingDomain}'s event subscription on ${queueName} is disabled, so it publishes nothing.`; case "wrong_queue": return `${sendingDomain}'s event subscription delivers to queue ${verdict.queueIds.join(", ")}, not ${queueName} — its events never reach this worker.`; case "partial_events": return `${sendingDomain}'s event subscription on ${queueName} omits ${verdict.missingEvents.join(", ")} — those outcomes stay invisible (suppression included).`; } } /** * `queues subscription list --json`. Returns null rather than throwing when the * output is not the JSON array we expect (an older wrangler prints a table and * rejects `--json` outright), so the caller can say "unresolved" instead of * crashing on a CLI shape it does not control. */ export function parseSubscriptionListJson(raw: string): EventSubscription[] | null { const start = raw.indexOf("["); if (start === -1) return null; try { const parsed = JSON.parse(raw.slice(start)) as unknown; if (!Array.isArray(parsed)) return null; return parsed.map((entry) => { const row = entry as Record; const source = (row.source ?? {}) as Record; const destination = (row.destination ?? {}) as Record; return { id: String(row.id ?? ""), name: String(row.name ?? ""), enabled: row.enabled !== false, source: { type: String(source.type ?? ""), domain: typeof source.domain === "string" ? source.domain : undefined, zone_id: typeof source.zone_id === "string" ? source.zone_id : undefined, }, destination: { type: String(destination.type ?? ""), queue_id: typeof destination.queue_id === "string" ? destination.queue_id : undefined, }, events: Array.isArray(row.events) ? row.events.map(String) : [], }; }); } catch { return null; } } /** * `wrangler email sending list`, which has no `--json` mode on any released * version (`--json` errors with "Unknown argument: json"), so its box-drawn * table is the only surface: │ zone │ name │ enabled │ tag │. * * Same scraping tradeoff `parseWranglerDnsGetOutput` already accepts in * `setup-sending.ts`: unrecognisable rows are skipped, never guessed at. */ export function parseSendingDomainsTable(raw: string): SendingDomain[] { const rows: SendingDomain[] = []; for (const line of raw.split("\n")) { if (!line.startsWith("│")) continue; const cells = line .split("│") .slice(1, -1) .map((cell) => cell.trim()); const [zone, name, enabled] = cells; if (cells.length < 3 || !zone || !name || zone === "zone") continue; rows.push({ zone, name: name.toLowerCase(), enabled: enabled?.toLowerCase() === "yes" }); } return rows; } /** * `wrangler queues list`'s table: │ id │ name │ created_on │ modified_on │ producers │ consumers │. * * The id column is what makes this more than a name lookup: a subscription's * destination is a queue *id*, so resolving "is this pointed at our queue" needs * the mapping this table is the only source of. */ export function parseQueueListTable(raw: string): AccountQueue[] { const rows: AccountQueue[] = []; for (const line of raw.split("\n")) { if (!line.startsWith("│")) continue; const cells = line .split("│") .slice(1, -1) .map((cell) => cell.trim()); const id = cells[0]; const name = cells[1]; if (cells.length < 6 || !name || name === "name") continue; rows.push({ id: id ?? "", name, consumers: Number.parseInt(cells[5] ?? "", 10) || 0 }); } return rows; } /** Whether a `queues subscription create --help` dump offers `email.sending`. */ export function helpAdvertisesEmailSendingSource(help: string): boolean { return /"email\.sending"/.test(help); } export type SendingDomainDrift = { /** * Enabled in Email Sending with a subscription on our events queue, but not * declared: a mailbox on it would relay through the fallback From instead of * sending as itself. A warning only — the account may host other projects. */ undeclared: string[]; /** * Declared, but not an enabled Email Sending domain in this account: a * mailbox on it will send as itself and Cloudflare will refuse the From. */ unverified: string[]; }; /** * Crosses the deployed MAIL_SENDING_DOMAINS against the provider's live lists. * * The var is a declaration a human (or setup:sending) wrote once; the lists are * what Cloudflare will actually honour. Onboarding a domain by hand — enable, * subscribe, forget the var — or a var that never reached the Worker produced * the same symptom and neither was visible anywhere. */ export function crossCheckSendingDomains(opts: { declared: string[]; sendingDomains: SendingDomain[]; subscriptions: EventSubscription[]; queueId: string; }): SendingDomainDrift { const declared = new Set(opts.declared.map((domain) => domain.trim().toLowerCase())); const enabled = new Set( opts.sendingDomains.filter((domain) => domain.enabled).map((domain) => domain.name), ); const subscribed = new Set( opts.subscriptions .filter( (sub) => sub.source.type === "email.sending" && sub.destination.queue_id === opts.queueId && sub.source.domain, ) .map((sub) => (sub.source.domain ?? "").toLowerCase()), ); return { undeclared: [...enabled].filter((name) => subscribed.has(name) && !declared.has(name)).sort(), unverified: [...declared].filter((name) => !enabled.has(name)).sort(), }; }