Ctrl K
ComunidadGuías…

scripts/lib/event-subscriptions.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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<string, unknown>;
			const source = (row.source ?? {}) as Record<string, unknown>;
			const destination = (row.destination ?? {}) as Record<string, unknown>;
			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(),
	};
}