scripts/setup-sending.ts
Rama main · réplica de lectura del repositorio original.
#!/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.<env>.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.<domain>` by default)
* - enables Cloudflare Email Sending for that sending domain
* - upserts generic DNS records we can safely own:
* * SPF on `cf-bounce.<sending-domain>` -> `v=spf1 include:_spf.mx.cloudflare.net ~all`
* * DMARC on `_dmarc.<sending-domain>` with a recommended policy
* - reads the provider-generated DKIM TXT + MX records via
* `wrangler email sending dns get <sending-domain>` (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.<domain>`) 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 <sending-domain>` 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.<zone>` / `cf-bounce._domainkey.<zone>`, the 6-event subscription, the
* MAIL_SENDING_DOMAINS union — with one difference: `_dmarc.<zone>` 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.<env>.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<T> = {
success: boolean;
errors: Array<{ code: number; message: string }>;
result: T;
};
function parseArgs(argv: string[]): Record<string, string> {
const args: Record<string, string> = {};
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<T>(
token: string,
method: string,
path: string,
body?: unknown,
): Promise<T> {
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<T>;
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<string> {
const result = await cloudflareRequest<Array<{ id: string; name: string }>>(
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<MutableRecord[]> {
return cloudflareRequest<MutableRecord[]>(
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<void> {
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<MutableRecord>(
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<MutableRecord>(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<MutableRecord[]> {
return cloudflareRequest<MutableRecord[]>(
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<void> {
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<MutableRecord>(
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<MutableRecord>(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<T>(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<SubscriptionOutcome> {
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 ?? "<zone-id>"} --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 <its-queue> --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);
}