#!/usr/bin/env tsx /** * `pnpm setup:sending` — prepares Cloudflare Email Sending for a dedicated sending subdomain * and updates Reccado's generated Wrangler config to use that sender address. * * SAFETY: dry-run by default. It prints the exact commands and DNS/API mutations it would make. * Pass `--apply` to enable Email Sending for the target subdomain, upsert the DNS records Reccado * can own confidently (SPF + DMARC, and — with a token — the provider-generated DKIM + MX records * too), and write `wrangler.generated..json` with MAIL_FROM_ADDRESS, MAIL_SENDING_DOMAINS * and (only with --restrict-senders, or an existing list) allowed_sender_addresses. * * PREFLIGHT: sending to arbitrary external recipients requires a Workers Paid plan (free-plan * accounts can only send to verified destination addresses). The script prints a loud reminder * before it touches anything, but it does not try to detect or gate on your plan — verify it * yourself in the Cloudflare dashboard. * * What it automates: * - chooses a dedicated sending subdomain (`send.` by default) * - enables Cloudflare Email Sending for that sending domain * - upserts generic DNS records we can safely own: * * SPF on `cf-bounce.` -> `v=spf1 include:_spf.mx.cloudflare.net ~all` * * DMARC on `_dmarc.` with a recommended policy * - reads the provider-generated DKIM TXT + MX records via * `wrangler email sending dns get ` (parsed from its plain-text output — this * open-beta command has no `--json` mode) and, when `CLOUDFLARE_API_TOKEN` is set and * `--apply` is passed, upserts them too, unless `--skip-provider-records` is given * - writes MAIL_FROM_ADDRESS (default `hello@send.`) into a generated config * - narrows the `EMAIL` binding to the chosen sender via `allowed_sender_addresses` * - creates-or-verifies the Email Sending **event subscription** binding the sending domain to * the EMAIL_EVENTS_QUEUE, which is what makes delivered/deferred/bounced/rejected/complained/ * failed events exist for it at all. This phase reports `verified` or `unresolved` and exits * non-zero on an unresolved --apply run: a domain that can send but produces no feedback is a * half-built domain, and reporting success on it is what let three features read "no event" * as evidence of nothing sent. Opt out with `--skip-event-subscription` (an explicit * declaration, not silence). * * DMARC ramp (recommended for a brand-new sending subdomain): * 1. Default is `p=none` (monitor mode) with relaxed alignment (`adkim=r; aspf=r`). Pass * `--dmarc-rua you@example.com` so you actually receive aggregate reports in this phase — * without an rua address, monitor mode produces no visibility into DKIM/SPF alignment. * 2. Once reports show DKIM/SPF are aligned, re-run with `--dmarc-policy quarantine`. * 3. Once quarantine looks clean, re-run with `--dmarc-policy reject` to fully enforce. * Alignment can be tightened with `--dmarc-alignment strict` once you're confident; relaxed is * the safe default and `pct` is omitted entirely while the policy is `none`. * NOTE: the provider's own DKIM/MX output includes a suggested DMARC record too (typically * `p=reject`) — this script never applies it. DMARC stays exclusively owned by the ramp above so * a fresh subdomain is never accidentally hard-enforced before it's been observed. * * What it deliberately does NOT automate: * - `--skip-provider-records` opts back out of the DKIM/MX auto-add above, for anyone who wants * to hand-manage those via `wrangler email sending dns get ` instead. * - any send path changes: request-send -> confirm-send remains the only outbound flow. * * Usage: * pnpm setup:sending --env dev --domain example.com * pnpm setup:sending --env dev --domain example.com --subdomain send --from-local-part hello --apply * pnpm setup:sending --env dev --domain example.com --dmarc-rua dmarc@example.com --apply * pnpm setup:sending --env dev --domain example.com --dmarc-policy quarantine --dmarc-alignment strict --apply * pnpm setup:sending --env dev --domain example.com --skip-provider-records --apply * pnpm setup:sending --env dev --domain example.com --apex --dmarc-policy none --dmarc-rua dmarc@example.com * * Zone apex (`--apex`, or `--subdomain @`): for a mailbox on the organizational domain itself * (support@example.com) that should reply as itself. Same steps — enable, SPF/DKIM/MX on * `cf-bounce.` / `cf-bounce._domainkey.`, the 6-event subscription, the * MAIL_SENDING_DOMAINS union — with one difference: `_dmarc.` governs EVERY sender using the * domain, so there is no default policy. `--dmarc-policy` is required, the current record is * printed (read before Email Sending auto-creates its `p=reject`) next to the planned one, and a * dropped `rua` is called out. This is a human declaration at the CLI; the Worker's own DMARC * ramp (src/lib/dns-gate.ts) still never touches an apex. * * Shipping: nothing here reaches the running Worker until `pnpm run deploy:dev` (or * `pnpm run deploy`) overlays `wrangler.generated..json` onto the build — see * scripts/lib/deploy-config.ts for which fields that file owns. * * Apply mode notes: * - `wrangler email sending enable/get` uses your local Wrangler auth (`pnpm wrangler login`) * - DNS upserts (SPF, DMARC, and — unless --skip-provider-records — DKIM + MX) need * CLOUDFLARE_API_TOKEN in the environment with zone DNS edit access */ import { normalizeTxtContent } from "#/lib/dns-gate"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { stripJsonc, type WranglerBlock, type WranglerConfig } from "./lib/deploy-config"; import { lookupTxt, parseDmarcTags } from "./lib/dmarc"; import { describeFeedbackVerdict, EMAIL_SENDING_EVENT_TYPES, evaluateFeedbackSubscription, helpAdvertisesEmailSendingSource, MIN_WRANGLER_FOR_EMAIL_SENDING, parseQueueListTable, parseSubscriptionListJson, } from "./lib/event-subscriptions"; import { normalizeZone, type ParsedDnsRecord, parseWranglerDnsGetOutput, resolveDmarcPlan, resolveSendingTarget, SPF_VALUE, selectProviderRecords, } from "./lib/sending-plan"; import { planSendingConfigUpdate } from "./lib/sending-config"; import { wrangler, wranglerCapture } from "./lib/wrangler-cli"; type MutableRecord = { id: string; type: string; name: string; content: string; ttl?: number; proxied?: boolean; priority?: number; }; type TxtRecordRequest = { type: "TXT"; name: string; content: string; ttl: number; comment?: string; }; type MxRecordRequest = { type: "MX"; name: string; content: string; priority: number; ttl: number; comment?: string; }; type CloudflareEnvelope = { success: boolean; errors: Array<{ code: number; message: string }>; result: T; }; function parseArgs(argv: string[]): Record { const args: Record = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith("--")) continue; const [rawKey, inlineValue] = arg.slice(2).split("=", 2); if (!rawKey) continue; if (inlineValue !== undefined) { args[rawKey] = inlineValue; continue; } const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[rawKey] = "true"; continue; } args[rawKey] = next; i += 1; } return args; } function normalizeLocalPart(raw: string | undefined): string { const value = (raw?.trim() || "hello").toLowerCase(); if (!/^[a-z0-9._%+-]+$/i.test(value)) { console.error(`setup:sending: invalid --from-local-part "${raw}".`); process.exit(1); } return value; } function runIdempotent(title: string, argv: string[], apply: boolean): void { console.log(`\n▸ ${title}\n $ pnpm wrangler ${argv.join(" ")}`); if (!apply) return; try { wrangler(argv); } catch (error) { const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" ? (error as { stderr: string }).stderr : ""; const haystack = `${error instanceof Error ? error.message : String(error)}\n${stderr}`; if ( /already exists|already created|already enabled|already taken|already in use|duplicate|409|11009/i.test( haystack, ) ) { console.log(" (already in place — skipping)"); return; } if (stderr) console.error(stderr); throw error; } } async function cloudflareRequest( token: string, method: string, path: string, body?: unknown, ): Promise { const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); const json = (await response.json()) as CloudflareEnvelope; if (!response.ok || !json.success) { const details = json.errors?.map((error) => `${error.code}: ${error.message}`).join("; "); throw new Error(`Cloudflare API ${method} ${path} failed${details ? ` (${details})` : ""}.`); } return json.result; } async function resolveZoneId(token: string, domain: string): Promise { const result = await cloudflareRequest>( token, "GET", `/zones?name=${encodeURIComponent(domain)}&status=active&per_page=1`, ); const zone = result[0]; if (!zone) { throw new Error(`Cloudflare API could not find an active zone for ${domain}.`); } return zone.id; } async function listTxtRecords( token: string, zoneId: string, name: string, ): Promise { return cloudflareRequest( token, "GET", `/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}&per_page=100`, ); } async function upsertTxtStyleRecord(opts: { apply: boolean; token?: string; zoneId?: string; name: string; styleLabel: string; desiredContent: string; comment: string; ownsRecord: (record: MutableRecord) => boolean; // Collapse multiple matching records to one instead of refusing. Needed for DMARC: Cloudflare // Email Sending auto-provisions its own DMARC record alongside ours, and two DMARC TXT records // at one name are invalid (RFC 7489 treats the policy as absent). dedupe?: boolean; }): Promise { const { apply, token, zoneId, name, styleLabel, desiredContent, comment, ownsRecord, dedupe } = opts; console.log(`\n▸ Ensure ${styleLabel} TXT`); console.log(` name: ${name}`); console.log(` content: ${desiredContent}`); if (!apply) return; if (!token || !zoneId) { throw new Error(`setup:sending: missing Cloudflare API context for ${styleLabel} upsert.`); } const existing = await listTxtRecords(token, zoneId, name); let managed = existing.filter(ownsRecord); if (managed.length > 1) { if (!dedupe) { throw new Error( `setup:sending: refusing to modify ${styleLabel} at ${name} because multiple matching TXT records already exist.`, ); } // Keep the first, patch it below, delete the rest so exactly one record remains. for (const record of managed.slice(1)) { await cloudflareRequest(token, "DELETE", `/zones/${zoneId}/dns_records/${record.id}`); console.log(` Removed a duplicate ${styleLabel} record.`); } managed = managed.slice(0, 1); } if (managed.length === 1) { const current = managed[0]; if (!current) { throw new Error( `setup:sending: expected exactly one existing ${styleLabel} record at ${name}.`, ); } if (normalizeTxtContent(current.content) === normalizeTxtContent(desiredContent)) { console.log(" (already matches — leaving it untouched)"); return; } await cloudflareRequest( token, "PATCH", `/zones/${zoneId}/dns_records/${current.id}`, { type: "TXT", name, content: desiredContent, ttl: 1, comment, } satisfies TxtRecordRequest, ); console.log(" Updated existing TXT record."); return; } await cloudflareRequest(token, "POST", `/zones/${zoneId}/dns_records`, { type: "TXT", name, content: desiredContent, ttl: 1, comment, } satisfies TxtRecordRequest); console.log(" Created TXT record."); } async function listMxRecords( token: string, zoneId: string, name: string, ): Promise { return cloudflareRequest( token, "GET", `/zones/${zoneId}/dns_records?type=MX&name=${encodeURIComponent(name)}&per_page=100`, ); } async function upsertMxRecord(opts: { apply: boolean; token?: string; zoneId?: string; name: string; content: string; priority: number; comment: string; }): Promise { const { apply, token, zoneId, name, content, priority, comment } = opts; console.log( `\n▸ Ensure MX record\n name: ${name}\n content: ${content}\n priority: ${priority}`, ); if (!apply) return; if (!token || !zoneId) { throw new Error(`setup:sending: missing Cloudflare API context for MX upsert at ${name}.`); } // Compare ignoring a trailing dot (Cloudflare/wrangler are inconsistent about it, but both // accept either form) so we don't create a duplicate MX record that only differs by "." const normalizeMxContent = (value: string) => value.trim().toLowerCase().replace(/\.$/, ""); const existing = await listMxRecords(token, zoneId, name); const match = existing.find( (record) => normalizeMxContent(record.content) === normalizeMxContent(content), ); if (match) { if (match.priority === priority) { console.log(" (already matches — leaving it untouched)"); return; } await cloudflareRequest( token, "PATCH", `/zones/${zoneId}/dns_records/${match.id}`, { type: "MX", name, content, priority, ttl: 1, comment, } satisfies MxRecordRequest, ); console.log(" Updated existing MX record's priority."); return; } // Only ever create/patch records that match this exact content — never delete MX records at // this name that we didn't create, since other MX records may coexist here. await cloudflareRequest(token, "POST", `/zones/${zoneId}/dns_records`, { type: "MX", name, content, priority, ttl: 1, comment, } satisfies MxRecordRequest); console.log(" Created MX record."); } const args = parseArgs(process.argv.slice(2)); const apply = args.apply === "true"; const skipProviderRecords = args["skip-provider-records"] === "true"; const skipEventSubscription = args["skip-event-subscription"] === "true"; const targetEnv = args.env; const envLabel = targetEnv ?? "production"; function orExit(result: { ok: true; value: T } | { ok: false; error: string }): T { if (!result.ok) { console.error(`setup:sending: ${result.error}`); process.exit(1); } return result.value; } const zoneDomain = orExit(normalizeZone(args.domain)); const target = orExit( resolveSendingTarget({ zone: zoneDomain, subdomain: args.subdomain, apex: args.apex === "true", }), ); const fromLocalPart = normalizeLocalPart(args["from-local-part"]); const rua = args["dmarc-rua"]?.trim(); const dmarcPlan = orExit( resolveDmarcPlan({ target, policy: args["dmarc-policy"], alignment: args["dmarc-alignment"], rua, }), ); const dmarcValue = dmarcPlan.value; for (const warning of dmarcPlan.warnings) { console.warn(`\nWARNING: ${warning}\n`); } const sendingDomain = target.sendingDomain; const fromAddress = `${fromLocalPart}@${sendingDomain}`; const bounceDomain = target.bounceDomain; const dmarcDomain = target.dmarcDomain; const spfValue = SPF_VALUE; const token = process.env.CLOUDFLARE_API_TOKEN?.trim(); const trackedConfig = JSON.parse( stripJsonc(readFileSync("wrangler.jsonc", "utf8")), ) as WranglerConfig; const configBlock: WranglerBlock | undefined = targetEnv ? trackedConfig.env?.[targetEnv] : trackedConfig; if (!configBlock) { console.error(`setup:sending: no config block for env "${targetEnv}" in wrangler.jsonc.`); process.exit(1); } const worker = configBlock.name ?? trackedConfig.name; if (!worker) { console.error(`setup:sending: could not resolve the Worker name for env "${envLabel}".`); process.exit(1); } const generatedConfigPath = `wrangler.generated.${envLabel}.json`; const sourceConfigPath = existsSync(generatedConfigPath) ? generatedConfigPath : "wrangler.jsonc"; const generatedConfig = existsSync(generatedConfigPath) ? (JSON.parse(stripJsonc(readFileSync(generatedConfigPath, "utf8"))) as WranglerConfig) : undefined; /** * Adding a sending domain must not reconfigure the ones already there. * * This script used to be run once, by hand, on the first domain. It is now the * engine behind a provisioning flow that runs on the second, third and fourth — * and two of its writes were only ever safe on the first run: * * MAIL_FROM_ADDRESS is a single value: the fallback identity for every mailbox * whose own domain is not verified. Overwriting it while provisioning a new * customer domain silently moves everyone else's outbound mail to that * customer's domain. * * allowed_sender_addresses is ABSENT by default, and absent means "any sender". * Adding one address does not widen an existing list, it replaces an unbounded * permission with a list of exactly one — so the first provisioning run would * cut off sending from every address already in use. * * Both are still reachable, but only when asked for by name. */ const setDefaultFrom = Boolean(args["set-default-from"]); const restrictSenders = Boolean(args["restrict-senders"]); // Read from what a deploy of this env WILL ship (tracked vars with the generated // overlay applied — scripts/lib/deploy-config.ts), not from whichever single file // happens to exist: a value declared only in wrangler.jsonc must survive this // write, or the next deploy would drop it. // // MAIL_SENDING_DOMAINS is the list of domains verified for outbound. A mailbox on // a listed domain sends as itself; anything else falls back to MAIL_FROM_ADDRESS // with a Reply-To. It is a union and never a replacement — forgetting to extend it // is why a freshly provisioned domain would otherwise be verified at Cloudflare but // still send under someone else's name. The rules live in scripts/lib/sending-config.ts, // shared with `pnpm onboard`. const configUpdate = planSendingConfigUpdate({ tracked: trackedConfig, generated: generatedConfig, env: targetEnv, addDomains: [sendingDomain], fromAddress, addSenders: [fromAddress], setDefaultFrom, restrictSenders, }); if (!configUpdate.ok) { console.error(`setup:sending: ${configUpdate.error} in ${sourceConfigPath}.`); process.exit(1); } const mutableConfig = configUpdate.value.config; const nextFrom = configUpdate.value.nextFrom; const fromAddressPreserved = configUpdate.value.fromAddressPreserved; const sendingDomains = configUpdate.value.sendingDomains; console.log( `\nReccado setup:sending — env: ${envLabel} · worker: ${worker}` + `\nzone: ${zoneDomain}` + `\nsending domain: ${sendingDomain}${target.isApex ? " (ZONE APEX)" : ""}` + `\nfrom address: ${fromAddress}` + `\nmode: ${apply ? "APPLY (mutating Cloudflare + generated config)" : "dry run (no changes)"}\n`, ); console.log(`${"═".repeat(72)}`); console.log("PREFLIGHT: Workers Paid plan required for arbitrary-recipient sending"); console.log(`${"═".repeat(72)}`); console.log( "Cloudflare Email Sending on a free plan can only send to VERIFIED destination addresses.\n" + "Sending to arbitrary external recipients requires a Workers Paid plan ($5/mo).\n" + " - Verify your plan: Cloudflare dashboard -> your account -> Plans\n" + " - Upgrade: Cloudflare dashboard -> your account -> Plans -> Workers Paid\n" + "This script cannot reliably detect your plan via the API, so this check is manual and does\n" + "NOT block --apply — everything below will still run even on a free plan, and sends to\n" + "unverified recipients will simply fail silently later if you skip this.", ); console.log(`${"═".repeat(72)}\n`); // The apex DMARC is read BEFORE anything is enabled: enabling Email Sending on a // name is what auto-creates `v=DMARC1; p=reject;` there, so reading afterwards // would show the provider's record and hide what the operator actually had. if (target.isApex) { const current = await lookupTxt(dmarcDomain); const currentDmarc = (current ?? []).filter((txt) => parseDmarcTags(txt) !== null); const currentText = current === null ? `(could not resolve — check it yourself: dig +short TXT ${dmarcDomain})` : currentDmarc.length === 0 ? "(none)" : currentDmarc.join(" | "); console.log(`${"═".repeat(72)}`); console.log(`APEX: ${zoneDomain} — its DMARC governs every sender using @${zoneDomain}`); console.log(`${"═".repeat(72)}`); console.log(` current ${dmarcDomain}: ${currentText}`); console.log(` planned ${dmarcDomain}: ${dmarcValue}`); const droppedRua = currentDmarc .map((txt) => parseDmarcTags(txt)?.rua) .filter((value): value is string => Boolean(value)); if (droppedRua.length > 0 && !rua) { console.warn( ` WARNING: the current record sends aggregate reports to ${droppedRua.join(", ")}; the planned\n` + " one sends them nowhere. Pass --dmarc-rua to keep them (the mailto: prefix is added for you).", ); } console.log(`${"═".repeat(72)}\n`); } runIdempotent( `Enable Cloudflare Email Sending for ${target.isApex ? "the zone apex" : "the sending subdomain"} ${sendingDomain}`, ["email", "sending", "enable", sendingDomain], apply, ); console.log( `\n▸ Render generated Wrangler config with MAIL_FROM_ADDRESS + allowed_sender_addresses`, ); console.log(` source: ${sourceConfigPath}`); console.log(` target: ${generatedConfigPath}`); console.log(` MAIL_FROM_ADDRESS=${nextFrom}`); if (fromAddressPreserved) { console.log( ` (kept the existing default sender; this domain's ${fromAddress} was NOT made the\n` + " global fallback. Pass --set-default-from if that is what you want.)", ); } console.log(` MAIL_SENDING_DOMAINS=${sendingDomains.join(",")}`); if (configUpdate.value.emailBinding === "absent") { console.log(" EMAIL.allowed_sender_addresses not updated (no send_email binding found)."); } else if (configUpdate.value.emailBinding === "unbounded" && !restrictSenders) { console.log( " EMAIL.allowed_sender_addresses left unrestricted (any sender). Narrowing it here would\n" + " cut off every address already sending. Pass --restrict-senders to opt in.", ); } else { console.log(` EMAIL.allowed_sender_addresses=${configUpdate.value.allowedSenders?.join(", ")}`); } if (apply) { writeFileSync(generatedConfigPath, `${JSON.stringify(mutableConfig, null, 2)}\n`); console.log(` Wrote ${generatedConfigPath}.`); } let zoneId: string | undefined; if (apply && token) { zoneId = await resolveZoneId(token, zoneDomain); console.log(`\n▸ Resolved Cloudflare zone\n ${zoneDomain} -> ${zoneId}`); } else if (apply) { console.log( "\n▸ DNS API upserts skipped\n CLOUDFLARE_API_TOKEN is unset, so the script will print the exact manual next steps instead of mutating DNS.", ); } if (apply && token && zoneId) { await upsertTxtStyleRecord({ apply, token, zoneId, name: bounceDomain, styleLabel: "SPF", desiredContent: spfValue, comment: "Reccado setup:sending managed SPF for Cloudflare Email Sending bounce subdomain", ownsRecord: (record) => normalizeTxtContent(record.content).toLowerCase().startsWith("v=spf1"), }); await upsertTxtStyleRecord({ apply, token, zoneId, name: dmarcDomain, styleLabel: "DMARC", desiredContent: dmarcValue, comment: target.isApex ? "Reccado setup:sending managed DMARC for the zone apex (explicit --dmarc-policy)" : "Reccado setup:sending managed DMARC for dedicated sending subdomain", ownsRecord: (record) => normalizeTxtContent(record.content).toLowerCase().startsWith("v=dmarc1"), // Cloudflare Email Sending provisions its own DMARC; collapse to a single record. dedupe: true, }); } else { console.log(`\n▸ Planned SPF TXT\n ${bounceDomain} -> ${spfValue}`); console.log(`\n▸ Planned DMARC TXT\n ${dmarcDomain} -> ${dmarcValue}`); } console.log( `\n▸ Provider-generated DKIM + MX records` + `\n $ pnpm wrangler email sending dns get ${sendingDomain}`, ); let providerRecords: ParsedDnsRecord[] = []; if (skipProviderRecords) { console.log( " Skipped (--skip-provider-records). Run the command above yourself and add the DKIM TXT\n" + " + MX records it prints — this script will not touch them.", ); } else { // This is read-only (no CLOUDFLARE_API_TOKEN required), so we can attempt it even in a pure // dry run to preview what would be added — but the sending domain may not be enabled yet in // that case (Email Sending is only actually enabled above when --apply is passed), so guard // against `dns get` failing and fall back to a plain note instead of crashing. try { const output = wrangler(["email", "sending", "dns", "get", sendingDomain], { capture: true }); console.log(output); providerRecords = parseWranglerDnsGetOutput(output); } catch (error) { const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" ? (error as { stderr: string }).stderr : ""; console.log( ` Could not fetch provider DNS records yet — this is expected if Email Sending isn't\n` + ` enabled on ${sendingDomain} yet (e.g. a dry run before --apply, or immediately after\n` + " enabling it). Re-run this script once enablement has propagated, or check manually:\n" + ` $ pnpm wrangler email sending dns get ${sendingDomain}`, ); if (stderr.trim()) console.log(` (${stderr.trim().split("\n")[0]})`); } } const { dkim, mx } = selectProviderRecords(providerRecords, sendingDomain); if (!skipProviderRecords) { if (apply && token && zoneId) { if (dkim) { await upsertTxtStyleRecord({ apply, token, zoneId, name: dkim.name, styleLabel: "DKIM", desiredContent: dkim.content, comment: "Reccado setup:sending managed DKIM (provider-generated) for dedicated sending subdomain", ownsRecord: (record) => normalizeTxtContent(record.content).toLowerCase().startsWith("v=dkim1"), }); } else { console.log( "\n▸ DKIM TXT not found yet in provider output (Email Sending may still be " + "provisioning) — re-run once `wrangler email sending dns get` shows it.", ); } for (const record of mx) { await upsertMxRecord({ apply, token, zoneId, name: record.name, content: record.content, priority: record.priority ?? 10, comment: "Reccado setup:sending managed MX (provider-generated) for Cloudflare Email Sending", }); } } else if (dkim || mx.length > 0) { console.log("\n▸ Planned DKIM + MX records (provider-generated)"); if (dkim) { console.log(` TXT ${dkim.name} -> ${dkim.content}`); } for (const record of mx) { console.log(` MX ${record.name} -> ${record.content} (priority ${record.priority})`); } console.log( " These are auto-added once CLOUDFLARE_API_TOKEN is set and you pass --apply.\n" + " Pass --skip-provider-records to opt out and manage them by hand instead.", ); } else if (providerRecords.length > 0) { console.log( "\n▸ No DKIM/MX records in the provider output yet — Email Sending may still be " + "provisioning; re-run once `wrangler email sending dns get` shows them.", ); } } // --- Email Sending event subscription ---------------------------------------- // // Enabling Email Sending on a domain does not give it a feedback channel. // Lifecycle events (delivered/deferred/bounced/rejected/complained/failed) only // exist for a domain an event subscription binds to a queue, and until now that // step lived exclusively in `docs/OPERATIONS.md`. So this script could exit 0 // having provisioned a sending domain structurally incapable of the delivery // tracking and suppression mirror the rest of Reccado documents — not a missing // warning, a claim of completion that was false. Three features read the absence // of events as evidence, and absence is only evidence where presence was // possible; this phase is what makes presence possible. // // Terminal state is therefore verified or unresolved, never silent green, on the // same contract as `pnpm doctor`: a fix line exact enough to paste. type SubscriptionOutcome = { state: "verified" | "unresolved" | "skipped"; detail: string; fix?: string; }; const eventsQueue = configBlock.queues?.producers?.find( (producer) => producer.binding === "EMAIL_EVENTS_QUEUE", )?.queue; const subscriptionOutcome = await ensureEventSubscription(); async function ensureEventSubscription(): Promise { console.log(`\n▸ Email Sending event subscription for ${sendingDomain}`); if (skipEventSubscription) { return { state: "skipped", detail: "--skip-event-subscription was passed; this run made no claim about feedback.", }; } if (!eventsQueue) { return { state: "unresolved", detail: `no EMAIL_EVENTS_QUEUE producer is declared for env "${envLabel}", so there is no queue to subscribe to.`, fix: "Add the EMAIL_EVENTS_QUEUE producer to wrangler.jsonc, then re-run.", }; } console.log(` queue: ${eventsQueue}`); console.log(` events: ${EMAIL_SENDING_EVENT_TYPES.join(", ")}`); const listed = wranglerCapture(["queues", "subscription", "list", eventsQueue, "--json"]); const subscriptions = listed.ok ? parseSubscriptionListJson(listed.out) : null; if (!subscriptions) { return { state: "unresolved", detail: `could not read the subscriptions on ${eventsQueue}${listed.err.trim() ? ` (${listed.err.trim().split("\n")[0]})` : ""}.`, fix: `pnpm wrangler queues subscription list ${eventsQueue} --json — check you are logged in (pnpm wrangler login) and on wrangler >= ${MIN_WRANGLER_FOR_EMAIL_SENDING}.`, }; } // The subscription's destination is a queue *id*; only `queues list` maps ids // to names, and without that mapping "it exists" cannot be told from "it // exists and points somewhere else". const queueList = wranglerCapture(["queues", "list"]); const queueId = queueList.ok ? parseQueueListTable(queueList.out).find((queue) => queue.name === eventsQueue)?.id : undefined; if (!queueId) { return { state: "unresolved", detail: `queue ${eventsQueue} was not found in this account, so its subscriptions cannot be verified.`, fix: `pnpm setup:cloud${targetEnv ? ` --env ${targetEnv}` : ""} --domain ${zoneDomain} --address inbox@${zoneDomain} --apply (or pnpm wrangler queues create ${eventsQueue})`, }; } const verdict = evaluateFeedbackSubscription({ sendingDomain, expectedQueueId: queueId, subscriptions, }); if (verdict.state === "live") { return { state: "verified", detail: describeFeedbackVerdict(sendingDomain, eventsQueue, verdict), }; } console.log(` ${describeFeedbackVerdict(sendingDomain, eventsQueue, verdict)}`); // A zone id is the one input neither wrangler nor the sending-domain list will // hand over. An existing subscription in the same zone already carries it, so // the second sending domain of a zone needs no token at all; the first one // falls back to the same REST lookup the DNS upserts already use. const zoneIdForSubscription = zoneId ?? subscriptions.find( (sub) => sub.source.type === "email.sending" && sub.source.zone_id && (sub.source.domain?.toLowerCase() === zoneDomain || sub.source.domain?.toLowerCase().endsWith(`.${zoneDomain}`)), )?.source.zone_id; const createCommand = `pnpm wrangler queues subscription create ${eventsQueue} --source email.sending --zone-id ${zoneIdForSubscription ?? ""} --domain ${sendingDomain} --events ${EMAIL_SENDING_EVENT_TYPES.join(",")}`; if (!apply) { console.log(` Would run:\n $ ${createCommand}`); return { state: "unresolved", detail: "dry run — no subscription was created or verified.", fix: `Re-run with --apply, or run: ${createCommand}`, }; } // An `update` cannot move a subscription between queues, and one pointed at // another queue is not ours to repoint — creating a second alongside it would // leave two owners for one fact. if (verdict.state === "wrong_queue") { return { state: "unresolved", detail: describeFeedbackVerdict(sendingDomain, eventsQueue, verdict), fix: `Delete it (pnpm wrangler queues subscription delete --id ${verdict.subscriptionIds[0]}), then: ${createCommand}`, }; } const help = wranglerCapture(["queues", "subscription", "create", "--help"]); if (!helpAdvertisesEmailSendingSource(`${help.out}${help.err}`)) { return { state: "unresolved", detail: "this wrangler's `queues subscription create` does not offer --source email.sending.", fix: `Upgrade to wrangler >= ${MIN_WRANGLER_FOR_EMAIL_SENDING} (pnpm add -D wrangler@${MIN_WRANGLER_FOR_EMAIL_SENDING}) and re-run, or create it in the dashboard: Queues -> ${eventsQueue} -> Subscriptions -> Subscribe to events -> Email Sending -> ${sendingDomain}, selecting all ${EMAIL_SENDING_EVENT_TYPES.length} message.* events.`, }; } // Converge rather than duplicate: a subscription that exists but is switched // off or missing event types is fixed in place, because a second subscription // for the same domain would make "which one is authoritative" a question // nobody wants to answer at 3am. if (verdict.state === "disabled" || verdict.state === "partial_events") { const id = verdict.subscriptionIds[0] ?? ""; const updated = wranglerCapture([ "queues", "subscription", "update", eventsQueue, "--id", id, "--events", EMAIL_SENDING_EVENT_TYPES.join(","), "--enabled", "true", ]); if (!updated.ok) { return { state: "unresolved", detail: `updating subscription ${id} failed${updated.err.trim() ? ` (${updated.err.trim().split("\n")[0]})` : ""}.`, fix: `pnpm wrangler queues subscription update ${eventsQueue} --id ${id} --events ${EMAIL_SENDING_EVENT_TYPES.join(",")} --enabled true`, }; } } else { if (!zoneIdForSubscription) { return { state: "unresolved", detail: `no subscription exists and the zone id for ${zoneDomain} could not be resolved.`, fix: `Set CLOUDFLARE_API_TOKEN and re-run with --apply, or read the zone id from the Cloudflare dashboard (zone overview, right-hand column) and run: ${createCommand}`, }; } const created = wranglerCapture([ "queues", "subscription", "create", eventsQueue, "--source", "email.sending", "--zone-id", zoneIdForSubscription, "--domain", sendingDomain, "--events", EMAIL_SENDING_EVENT_TYPES.join(","), ]); if (!created.ok) { return { state: "unresolved", detail: `creating the subscription failed${created.err.trim() ? ` (${created.err.trim().split("\n")[0]})` : ""}.`, fix: createCommand, }; } } // Re-read rather than trust the mutation's exit code: "verified" has to mean // the provider agrees, not that a command returned 0. const recheck = wranglerCapture(["queues", "subscription", "list", eventsQueue, "--json"]); const after = recheck.ok ? parseSubscriptionListJson(recheck.out) : null; const afterVerdict = after ? evaluateFeedbackSubscription({ sendingDomain, expectedQueueId: queueId, subscriptions: after, }) : null; if (afterVerdict?.state === "live") { return { state: "verified", detail: describeFeedbackVerdict(sendingDomain, eventsQueue, afterVerdict), }; } return { state: "unresolved", detail: afterVerdict ? describeFeedbackVerdict(sendingDomain, eventsQueue, afterVerdict) : `the subscription list on ${eventsQueue} could not be re-read to confirm the change.`, fix: `pnpm wrangler queues subscription list ${eventsQueue} --json`, }; } console.log(` ${subscriptionOutcome.state.toUpperCase()}: ${subscriptionOutcome.detail}`); if (subscriptionOutcome.fix) console.log(` → ${subscriptionOutcome.fix}`); console.log(`\n${"─".repeat(72)}`); console.log("Still required / intentionally manual:\n"); if (skipProviderRecords) { console.log( `- DKIM + MX: run \`wrangler email sending dns get ${sendingDomain}\` yourself and add the ` + "DKIM TXT + MX records it prints (--skip-provider-records was passed, so this script " + "left them untouched).", ); } else if (apply && token) { console.log( "- DKIM + MX are auto-added above from the provider's own output — nothing left to do here " + "unless the provider changes its record shape in a way this parser doesn't recognize.", ); } else { console.log( "- DKIM + MX: set CLOUDFLARE_API_TOKEN and re-run with --apply to auto-add the planned " + "records printed above (or pass --skip-provider-records to manage them by hand instead).", ); } if (skipEventSubscription) { console.log( `- Event subscription: skipped (--skip-event-subscription). Until ${sendingDomain} is bound to ` + "the events queue, every send from it stays delivery_status=null and no bounce or complaint " + "ever reaches the suppression mirror.", ); } // Nothing above reaches the running Worker: MAIL_SENDING_DOMAINS and friends live in // the generated file until a deploy overlays it onto the build. Say exactly which // command does that — the previous wording pointed at setup:domain while the // command operators actually ran (deploy:dev) ignored the generated file. const deployCommand = targetEnv === "dev" ? "pnpm run deploy:dev" : targetEnv ? `pnpm exec tsx scripts/deploy.ts --env ${targetEnv}` : "pnpm run deploy"; console.log( `- ${apply ? "Ship it" : "After --apply, ship it"}: the Worker only sees MAIL_SENDING_DOMAINS=${[...sendingDomains].sort().join(",")} after a deploy that overlays ${generatedConfigPath} onto the build.`, ); console.log(` ${deployCommand} --dry-run # prints the overlay + bindings, uploads nothing`); console.log(` ${deployCommand}`); console.log( ` Then confirm the deployed config agrees with Email Sending: pnpm doctor${targetEnv ? ` --env ${targetEnv}` : ""} --cloud`, ); console.log( "- Sending to arbitrary recipients still requires a Workers Paid plan; verified-destination-only accounts remain limited by Cloudflare.", ); console.log( "- Reccado's send invariant is unchanged: draft -> request-send -> confirm-send is still the only outbound path.", ); if (!apply) { console.log("\nDry run only. Re-run with --apply to execute against Cloudflare.\n"); } // A dry run never claimed to have finished, so its unresolved subscription is // just the plan. An --apply run did claim it, and exiting 0 on a sending domain // with no feedback channel is the exact lie this phase was added to stop. if (apply && subscriptionOutcome.state === "unresolved") { console.error( `\nsetup:sending: UNRESOLVED — ${sendingDomain} can send, but its delivery/bounce/complaint events do not reach Reccado.` + `\n ${subscriptionOutcome.detail}` + (subscriptionOutcome.fix ? `\n → ${subscriptionOutcome.fix}` : "") + "\n Sending still works; delivery tracking and the suppression mirror do not.\n", ); process.exit(1); }