import type { ValidateCredentialsInput, ValidateCredentialsResult, } from '@open-mercato/core/modules/communication_channels/lib/adapter' import { parseCredentials } from './credentials' const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4' /** Bounded so a hung Cloudflare API call cannot stall the connect form. */ const PROBE_TIMEOUT_MS = 8_000 /** * Cloudflare has no "list sending domains for this account" endpoint — Email * Sending's onboarding state is exposed per ZONE, as `GET * /zones/{zone_id}/email/sending/subdomains` (confirmed against * developers.cloudflare.com/api/resources/email_sending/ — the only resources * under `email_sending` are `send`, `send_raw`, and zone-scoped `subdomains`; * an earlier version of this file guessed an account-scoped * `/accounts/{account_id}/email/sending/domains` path that does not exist and * would 404 on every single validation, permanently blocking the connect form). * * A "zone" is usually the registrable domain, and `sendingDomain` may be a * subdomain delegated within a larger zone (e.g. `ingest.example.com` while * the zone is `example.com`), so the exact hostname is not always itself a * zone name. Resolve it the same way Wrangler does: try the full host, then * peel off labels one at a time until `/zones?name=...` matches or nothing is * left. This is a direct port of `getZoneIdFromHost` in * `packages/wrangler/src/zones.ts` (cloudflare/workers-sdk) — not invented, * because it's the same resolution Cloudflare's own tooling relies on. */ const MAX_ZONE_LOOKUP_LEVELS = 6 /** * Validate credentials at connect time, before anything is persisted. * * Errors are returned FIELD-KEYED rather than as a single message, because the * connect form renders them inline against the offending input. "Validation * failed" against a seven-field form is close to useless — the operator has no * way to tell a bad token from a domain that was never onboarded, and those have * completely different fixes. * * Two classes of problem are worth separating here, and both are common: * * - the token is wrong, revoked, or missing the required permissions * - the token is fine but the sending domain was never onboarded * * The second is the one that silently produces `E_SENDER_NOT_VERIFIED` on the * first real send, long after setup, so catching it here is worth the round trip. * * PERMISSION NOTE: this probe reads `GET /zones` (to resolve a zone id from * the sending domain) in addition to the zone-scoped Email Sending endpoint. * That means the token needs "Zone: Zone: Read" on top of "Email Sending: Edit". * `integration.ts`'s `apiToken` help text now says so; keep the two in sync. * * The failure mode when it is missing is genuinely misleading and worth * understanding before touching `resolveZoneId`: a token without zone read gets * `200` with an EMPTY result list, not a 403. That is indistinguishable from the * domain not existing, so this function reports `sendingDomain` as unknown when * the real fault is the token. Both branches are kept because there is no way to * tell them apart from the response alone. * * SCOPE: this validates the OUTBOUND half only — credentials, zone, and Email * Sending onboarding. Inbound routing and archive readability are probed * separately by `inbound-health.ts` and surfaced through the health check, not * here: they must not block the connect form, since a channel is legitimately * connected before its Email Routing rule exists. */ export async function validateCloudflareEmailCredentials( input: ValidateCredentialsInput, ): Promise { const parsed = parseCredentials(input.credentials) if (!parsed.ok) { return { ok: false, errors: parsed.errors } } const { accountId, apiToken, sendingDomain, fromAddress } = parsed.value // Cheap local check before spending a network round trip: a From address on a // different domain than the one being validated always fails at send time. const fromDomain = fromAddress.split('@')[1]?.toLowerCase() if (fromDomain && fromDomain !== sendingDomain.toLowerCase()) { return { ok: false, errors: { fromAddress: `From address must be on the sending domain (${sendingDomain}), not ${fromDomain}.`, }, } } const zoneResult = await resolveZoneId(sendingDomain, accountId, apiToken) if (zoneResult.kind === 'unauthorized') { return { ok: false, errors: { apiToken: 'Cloudflare rejected this token while resolving the sending domain\'s zone. Check that it is valid and carries "Zone: Zone: Read" permission (in addition to "Email Sending: Edit").', }, } } if (zoneResult.kind === 'error') { return { ok: false, errors: { apiToken: `Could not reach the Cloudflare API to resolve the sending domain's zone (${zoneResult.detail}). Check connectivity and try again.`, }, } } if (zoneResult.kind === 'not_found') { return { ok: false, errors: { sendingDomain: `No Cloudflare zone for "${sendingDomain}" (or a parent domain) was found under this account. Add the domain to Cloudflare first, then onboard it for Email Sending.`, }, } } const subdomainsResult = await cloudflareApiGet( `/zones/${encodeURIComponent(zoneResult.zoneId)}/email/sending/subdomains`, apiToken, ) if (subdomainsResult.kind === 'unauthorized') { return { ok: false, errors: { apiToken: 'Cloudflare rejected this token while reading Email Sending status for the zone. Check that it carries the "Email Sending: Edit" permission.', }, } } if (subdomainsResult.kind === 'network_error' || subdomainsResult.kind === 'http_error') { return { ok: false, errors: { apiToken: 'Could not reach the Cloudflare API to confirm Email Sending status. Check connectivity and try again.', }, } } const entries = subdomainsResult.body.result ?? [] const match = entries.find((entry) => entry.name?.toLowerCase() === sendingDomain.toLowerCase()) if (!match) { return { ok: false, errors: { sendingDomain: `"${sendingDomain}" has not been onboarded for Email Sending yet. Onboard it under Email Service → Email Sending, or run: wrangler email sending enable ${sendingDomain}`, }, } } if (!match.enabled) { return { ok: false, errors: { sendingDomain: `"${sendingDomain}" was onboarded for Email Sending but is currently disabled. Re-enable it under Email Service → Email Sending.`, }, } } return { ok: true } } export type CloudflareApiEnvelope = { success: boolean errors?: Array<{ code: number; message: string }> result: T | null } type ZoneSummary = { id: string; name: string } type SendingSubdomain = { name: string; enabled: boolean } export type CfFetchOutcome = | { kind: 'ok'; body: CloudflareApiEnvelope } | { kind: 'unauthorized' } | { kind: 'http_error'; status: number } | { kind: 'network_error'; detail: string } /** * Bare GET against the Cloudflare API, classifying the outcome without throwing. * * Exported for `inbound-health.ts`, which probes the Email Routing rule through * the same token. Sharing this rather than re-rolling a fetch keeps one timeout * and one 401/403-vs-HTTP-error classification for every Cloudflare call in the * package — the distinction matters to callers that must tell "this is broken" * apart from "this token cannot see it". */ export async function cloudflareApiGet(path: string, apiToken: string): Promise> { let response: Response try { response = await fetch(`${CLOUDFLARE_API_BASE}${path}`, { headers: { Authorization: `Bearer ${apiToken}` }, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }) } catch (error) { return { kind: 'network_error', detail: error instanceof Error ? error.message : 'unknown error' } } if (response.status === 401 || response.status === 403) return { kind: 'unauthorized' } if (!response.ok) return { kind: 'http_error', status: response.status } try { const body = (await response.json()) as CloudflareApiEnvelope return { kind: 'ok', body } } catch { return { kind: 'network_error', detail: 'response was not valid JSON' } } } export type ZoneLookupResult = | { kind: 'found'; zoneId: string } | { kind: 'not_found' } | { kind: 'unauthorized' } | { kind: 'error'; detail: string } /** * Resolve `hostname` (or the nearest parent label that is itself a zone) to a * Cloudflare zone id, scoped to `accountId` via the `account.id` query param * so a domain owned by a different Cloudflare account never matches. See the * module-level comment for why this walks labels instead of a single lookup. */ export async function resolveZoneId( hostname: string, accountId: string, apiToken: string, ): Promise { const labels = hostname.toLowerCase().split('.').filter(Boolean) let attempts = 0 while (labels.length > 1 && attempts < MAX_ZONE_LOOKUP_LEVELS) { const candidate = labels.join('.') const query = new URLSearchParams({ name: candidate, 'account.id': accountId }) const outcome = await cloudflareApiGet(`/zones?${query.toString()}`, apiToken) if (outcome.kind === 'unauthorized') return { kind: 'unauthorized' } if (outcome.kind === 'network_error') return { kind: 'error', detail: outcome.detail } if (outcome.kind === 'http_error') return { kind: 'error', detail: `HTTP ${outcome.status}` } const zone = outcome.body.result?.[0] if (zone?.id) return { kind: 'found', zoneId: zone.id } labels.shift() attempts += 1 } return { kind: 'not_found' } }