/** * The pure half of `setup:sending`: which name gets Email Sending, which * records it needs, and what its DMARC says. The script owns the side effects; * this owns the decisions, so the decisions can be tested without an account. */ import { buildDmarcValue, type DmarcAlignment, type DmarcPolicy } from "#/lib/dns-gate"; export type SendingTarget = { /** The Cloudflare zone, e.g. `example.com`. */ zone: string; /** The label under the zone (`send`), or null when the target is the zone apex. */ label: string | null; /** The name Email Sending is enabled on: `send.example.com`, or `example.com` for the apex. */ sendingDomain: string; isApex: boolean; /** Where the provider's bounce MX + SPF live: `cf-bounce.`. */ bounceDomain: string; /** Where this domain's DMARC lives: `_dmarc.`. */ dmarcDomain: string; }; export type Result = { ok: true; value: T } | { ok: false; error: string }; export const SPF_VALUE = "v=spf1 include:_spf.mx.cloudflare.net ~all"; export function normalizeZone(raw: string | undefined): Result { const normalized = (raw ?? "").trim().toLowerCase().replace(/\.$/, ""); if (!normalized) return { ok: false, error: "--domain is required." }; if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(normalized)) { return { ok: false, error: `invalid --domain "${raw}".` }; } return { ok: true, value: normalized }; } /** * `--subdomain send` (default), `--subdomain send.example.com`, or the apex via * `--apex`, `--subdomain @` or `--subdomain example.com`. * * The apex used to be rejected outright, which is why onboarding a support * mailbox that replies as itself (`support@example.com`) had to be done by * hand. It is allowed now, but only ever by an explicit spelling: a missing or * empty `--subdomain` still means `send`, never the apex. */ export function resolveSendingTarget(opts: { zone: string; subdomain?: string; apex?: boolean; }): Result { const zone = opts.zone; const raw = opts.subdomain?.trim(); const lowered = raw?.toLowerCase().replace(/\.$/, ""); const namesApex = lowered === "@" || lowered === zone; if (opts.apex && raw && !namesApex) { return { ok: false, error: `--apex conflicts with --subdomain "${raw}"; pass one or the other.`, }; } if (opts.apex || namesApex) { return { ok: true, value: build(zone, null) }; } const fallback = lowered || "send"; const relative = fallback.endsWith(`.${zone}`) ? fallback.slice(0, -`.${zone}`.length) : fallback; if (!relative || relative.includes("@") || !/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/.test(relative)) { return { ok: false, error: `invalid --subdomain "${raw ?? ""}" (use a label like "send", or --apex for ${zone} itself).`, }; } return { ok: true, value: build(zone, relative) }; } function build(zone: string, label: string | null): SendingTarget { const sendingDomain = label ? `${label}.${zone}` : zone; return { zone, label, sendingDomain, isApex: label === null, bounceDomain: `cf-bounce.${sendingDomain}`, dmarcDomain: `_dmarc.${sendingDomain}`, }; } export type DmarcPlan = { policy: DmarcPolicy; alignment: DmarcAlignment; value: string; /** Operator-facing warnings to print before anything is touched. */ warnings: string[]; }; /** * The DMARC record `setup:sending` will write. * * A dedicated subdomain defaults to `p=none`: nothing else sends from it, so * monitor mode costs nothing and a mistake cannot bounce anyone's mail. * * The apex is different in kind. `_dmarc.` governs every message that * claims the organizational domain — the operator's personal mail, a * newsletter tool, an invoicing SaaS — and, through `sp=` or its absence, every * subdomain that has no record of its own. No default is safe there: `none` * silently relaxes a domain that may be enforcing today, and anything stronger * can start rejecting senders this script cannot see. So the apex requires the * policy to be named, and says why. */ export function resolveDmarcPlan(opts: { target: SendingTarget; policy?: string; alignment?: string; rua?: string; }): Result { const warnings: string[] = []; const rawPolicy = opts.policy?.trim().toLowerCase(); if (opts.target.isApex && !rawPolicy) { return { ok: false, error: `the apex ${opts.target.dmarcDomain} record governs ALL mail from ${opts.target.zone} ` + "(every other service sending as it, and every subdomain without its own DMARC), so " + "there is no safe default. Check what it says now (dig +short TXT " + `${opts.target.dmarcDomain}) and pass --dmarc-policy none|quarantine|reject explicitly ` + "(plus --dmarc-rua to keep receiving reports).", }; } const policy = rawPolicy || "none"; if (policy !== "none" && policy !== "quarantine" && policy !== "reject") { return { ok: false, error: `invalid --dmarc-policy "${opts.policy}" (use none|quarantine|reject).`, }; } const alignment = opts.alignment?.trim().toLowerCase() || "relaxed"; if (alignment !== "relaxed" && alignment !== "strict") { return { ok: false, error: `invalid --dmarc-alignment "${opts.alignment}" (use relaxed|strict).`, }; } const rua = opts.rua?.trim() || undefined; if (policy === "none" && !rua) { warnings.push( '--dmarc-policy is "none" (monitor mode) and no --dmarc-rua was provided, so you will ' + "receive no DMARC aggregate reports and won't be able to observe DKIM/SPF alignment " + "before ramping to quarantine/reject. Pass --dmarc-rua you@example.com to fix this.", ); } if (opts.target.isApex) { warnings.push( `APEX DMARC: this run will replace ${opts.target.dmarcDomain} — the policy for EVERY sender ` + `using @${opts.target.zone}, not just Reccado — with p=${policy}. Any other v=DMARC1 ` + "record there is collapsed into this one.", ); } return { ok: true, value: { policy, alignment, // Built by the gate so the CLI and the Worker spell a policy byte-identically. value: buildDmarcValue(policy, alignment, rua), warnings, }, }; } // A record parsed out of `wrangler email sending dns get `'s plain-text output (there is // no --json mode for this open-beta command). `priority` is only present on MX records. export type ParsedDnsRecord = { type: "MX" | "TXT"; name: string; content: string; priority?: number; }; function stripSurroundingQuotes(value: string): string { const trimmed = value.trim(); if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { return trimmed.slice(1, -1); } return trimmed; } /** * Parses the plain-text output of `wrangler email sending dns get `. This is an * open-beta Wrangler command with no `--json` mode (passing `--json` errors with * "Unknown argument: json"), so this is the only way to get structured data out of it. The output * looks like: * * MX record: * Name: cf-bounce.send.example.com * Content: route1.mx.cloudflare.net. * Priority: 71 * TTL: 1 * * We key off the stable "MX record:" / "TXT record:" block headers and the indented * "Name:"/"Content:"/"Priority:"/"TTL:" field labels. That naturally skips the Wrangler version * banner and open-beta notice printed above the first block. Blocks missing a Name/Content (or, * for MX, a parseable Priority) are skipped rather than throwing, since this is scraping a * human-readable CLI format that could change shape without notice. */ export function parseWranglerDnsGetOutput(output: string): ParsedDnsRecord[] { const records: ParsedDnsRecord[] = []; let current: { type: "MX" | "TXT"; fields: Record } | null = null; const flush = () => { const block = current; current = null; if (!block) return; const name = block.fields.name?.trim(); const rawContent = block.fields.content?.trim(); if (!name || !rawContent) return; if (block.type === "MX") { const priority = Number.parseInt(block.fields.priority ?? "", 10); if (Number.isNaN(priority)) return; records.push({ type: "MX", name, content: rawContent, priority }); return; } records.push({ type: "TXT", name, content: stripSurroundingQuotes(rawContent) }); }; for (const line of output.split(/\r?\n/)) { const header = line.trim().match(/^(MX|TXT) record:$/i); if (header) { flush(); const label = header[1]?.toUpperCase(); current = label === "MX" || label === "TXT" ? { type: label, fields: {} } : null; continue; } if (!current) continue; const field = line.match(/^\s{2,}(Name|Content|Priority|TTL):\s*(.*)$/); if (field?.[1]) { current.fields[field[1].toLowerCase()] = field[2] ?? ""; } } flush(); return records; } /** * Selects only the provider-generated records this script does not already own: the DKIM TXT * (`*._domainkey.`, content `v=DKIM1...`) and the MX records on * `cf-bounce.`. This is an allowlist, not a denylist, so the SPF TXT * (`cf-bounce.`, `v=spf1...`, upserted separately) and the DMARC TXT * (`_dmarc.`) are excluded by construction — never by matching against the * provider's suggested policy value — which is what guarantees the provider's `p=reject` DMARC * suggestion can never overwrite this script's own DMARC ramp. For the apex this is what keeps * `v=DMARC1; p=reject;` off the organizational domain. */ export function selectProviderRecords( records: ParsedDnsRecord[], sendingDomain: string, ): { dkim?: ParsedDnsRecord; mx: ParsedDnsRecord[] } { const bounceDomain = `cf-bounce.${sendingDomain}`.toLowerCase(); const dkimSuffix = `._domainkey.${sendingDomain}`.toLowerCase(); const dkim = records.find( (record) => record.type === "TXT" && record.name.toLowerCase().replace(/\.$/, "").endsWith(dkimSuffix) && record.content.trim().toLowerCase().startsWith("v=dkim1"), ); const mx = records.filter( (record) => record.type === "MX" && record.name.toLowerCase().replace(/\.$/, "") === bounceDomain, ); return { dkim, mx }; }