// Copyright (c) 2026 Doforu // Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only). // See the LICENSE file or https://www.gnu.org/licenses/agpl-3.0.txt import { Hono } from "hono"; import type { Context } from "hono"; import dns from "node:dns"; // NOTE: dns.promises.resolveMx is kept for detect-dns-provider; // verify-mx uses DoH directly to avoid Workers polyfill issues. import { listMailboxes } from "./lib/email-helpers"; import type { Env } from "./types"; import { fetchWithTimeout } from "./lib/fetch-with-timeout"; import * as dbService from "./db"; // [i18n apiSetup] Every `[API]` error string returned to the frontend is // localized via the `apiSetup` namespace. The locale middleware lives in // ./index.ts, mounted on the SAME Hono app (`index.ts` does `app.route("/", // setup)`), so `c.get("locale")` is available here. See `setupT` below for the // scoped translator factory and its fallback when the middleware did not run. import { DEFAULT_LOCALE, isLocale, resolveLocale } from "../shared/i18n/config"; import type { Locale } from "../shared/i18n/types"; import { getBackendT } from "../shared/i18n/translate"; /** * Resolve the request locale, preferring the value the API middleware in * ./index.ts put on the shared request context and falling back to resolving * it from the request itself (unit tests, direct `setup.fetch()` calls, or a * future mount on a separate Hono instance). */ function requestLocale(c: Context): Locale { const scoped = c.get("locale" as never) as string | undefined; return isLocale(scoped) ? scoped : resolveLocale(c.req.raw) ?? DEFAULT_LOCALE; } /** * Translator scoped to the `apiSetup` namespace for the request's locale, * built lazily so the success path allocates nothing extra. */ function setupT(c: Context) { let t: ReturnType | undefined; return ((key: string, options?: Record) => { t ??= getBackendT(requestLocale(c), "apiSetup"); return t(key, options); }) as ReturnType; } // Normalize Resend domain status to our standard values. // Resend may return statuses like "not_started", "dns_verification_in_progress", // "temporary_failure", etc. — map them all to "pending". // Returns `string` (not a literal union) so that a stored value read back // from the DB can be passed straight through (see `originalStatus` below). function normalizeDomainStatus(status: string | undefined): string { if (status === "verified") return "verified"; if (status === "failed") return "failed"; if (status === "temporary_failure") return "failed"; return "pending"; } const setup = new Hono<{ Bindings: Env }>(); /** * The Cloudflare API's error envelope, as far as this route cares about it. * `detect-cf-domains` forwards both `message` and `code` to the client so the * settings UI can distinguish "bad credentials" from "Cloudflare rejected this * request" (see the comment at the `!res.ok` branch below). */ type CfApiErrorBody = { errors?: Array<{ code?: number; message?: string }> }; // ── POST /api/v1/setup/detect-cf-domains ─────────────────────────── // Given a CF API Token + Account ID, return all zones in the account. setup.post("/api/v1/setup/detect-cf-domains", async (c) => { const t = setupT(c); try { const body = await c.req.json<{ cfApiToken: string; cfAccountId: string; }>(); let { cfApiToken, cfAccountId } = body; // Fall back to platform settings if CF credentials not provided if (!cfApiToken) { const saved = await dbService.getSetting(c.env.DB, "cf_api_token"); if (saved) cfApiToken = saved; } if (!cfAccountId) { const saved = await dbService.getSetting(c.env.DB, "cf_account_id"); if (saved) cfAccountId = saved; } if (!cfApiToken || !cfAccountId) { return c.json( { error: t("missingCfCredentials") }, 400, ); } // Fetch all zones for the account (paginated, up to 5 pages = 500 zones) const zones: Array<{ id: string; name: string; status: string }> = []; let page = 1; let hasMore = true; let accountName = ""; while (hasMore && page <= 5) { const url = new URL( `https://api.cloudflare.com/client/v4/zones?account.id=${cfAccountId}&page=${page}&per_page=100`, ); const res = await fetchWithTimeout(url.toString(), { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }); if (!res.ok) { const errBody = (await res.json().catch(() => ({}))) as CfApiErrorBody; const cfError = errBody?.errors?.[0]; // Forward Cloudflare's OWN message plus the upstream status and error // code. Without them the client can only guess, and its only guess — // "the token or account ID is invalid" — is wrong for the most common // real failure: a valid token whose Client IP Address Filtering does // not allow the Worker's egress IP, so every request from the app is // rejected while the same token works from the user's own machine. return c.json( { error: cfError?.message || t("cfApiError", { status: res.status }), cfStatus: res.status, cfCode: cfError?.code, }, 400, ); } const data = (await res.json()) as { success: boolean; errors: Array<{ code?: number; message: string }>; result: Array<{ id: string; name: string; status: string; account?: { id: string; name: string } }>; result_info: { total_pages: number }; }; if (!data.success) { const cfError = data.errors?.[0]; return c.json( { error: cfError?.message || t("cfApiGenericError"), cfStatus: res.status, cfCode: cfError?.code, }, 400, ); } for (const zone of data.result) { zones.push({ id: zone.id, name: zone.name, status: zone.status }); } // Extract account name from the first zone's account object if (!accountName) accountName = data.result[0]?.account?.name || ""; hasMore = page < data.result_info.total_pages; page++; } return c.json({ zones, accountName }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("detectCfDomains failed:", msg); return c.json({ error: t("failedToDetectCfDomains") }, 500); } }); // ── Verify MX Records ──────────────────────────────────────────── // // Inbound MX targets this fork accepts as "the domain is wired up correctly". // // Why a SET and not the single literal this used to be: the original handler // compared every domain's MX against the upstream agentic-inbox deployment // host (`mailboxes.pages.dev`). That is the wrong expectation for this fork for // two reasons: // // 1. This fork does not deploy on pages.dev (it runs on // `mailboxes..workers.dev`), so the old literal could never match. // 2. Inbound mail for a real domain here is delivered through Cloudflare // Email Routing, whose MX records are ALWAYS the three cloudflare.net // hosts below (priorities 2 / 68 / 70). Deploying elsewhere does not // change what a correctly-configured zone looks like. // // Consequence of the old literal: every correctly configured domain — e.g. // `doforu.app` and `doforu.ai`, whose MX are exactly route1/2/3.mx.cloudflare.net // — was reported as `verified: false`, i.e. the check failed 100% of the time. // // `mailboxes.pages.dev` is kept for backward compatibility: a zone that still // points at the upstream deployment must not regress from pass to fail. // Add future inbound targets here (a single named list keeps the set easy to // extend and to audit); comparison ignores case and a trailing root dot. const ACCEPTED_INBOUND_MX_TARGETS: readonly string[] = [ // Cloudflare Email Routing (the real inbound path for this deployment). // https://developers.cloudflare.com/email-routing/ "route1.mx.cloudflare.net", "route2.mx.cloudflare.net", "route3.mx.cloudflare.net", // Legacy upstream agentic-inbox deployment host — compatibility only. "mailboxes.pages.dev", ]; /** * Normalize an MX exchange for comparison: lower-case and drop the trailing * root dot the DoH JSON answer carries (`route1.mx.cloudflare.net.`). */ function normalizeMxTarget(value: string): string { return value.toLowerCase().replace(/\.$/, ""); } setup.post("/api/v1/setup/verify-mx", async (c) => { const t = setupT(c); const { domain } = await c.req.json<{ domain: string }>(); if (!domain) { return c.json({ error: t("domainRequired") }, 400); } try { // Use Cloudflare DoH API directly instead of dns.promises.resolveMx // to avoid node:dns polyfill compatibility issues in Workers runtime const dohRes = await fetchWithTimeout( `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=MX`, { headers: { Accept: "application/dns-json" } }, ); const dnsJson = (await dohRes.json()) as { Answer?: Array<{ type: number; data: string; TTL: number }>; Status?: number; }; const mxRecords = (dnsJson.Answer || []) .filter((a) => a.type === 15) // MX record type = 15 .map((a) => { const [priority, ...exchangeParts] = a.data.split(" "); return { priority: parseInt(priority, 10), exchange: exchangeParts.join("").replace(/\.$/, ""), // remove trailing dot }; }); const matched = mxRecords.find((r) => ACCEPTED_INBOUND_MX_TARGETS.includes(normalizeMxTarget(r.exchange)), ); return c.json({ verified: !!matched, records: mxRecords, matched: matched || null, }); } catch (e: unknown) { // DNS query failed — domain may not have MX records yet const msg = e instanceof Error ? e.message : "Unknown error"; console.error("verifyMx failed:", msg); return c.json({ verified: false, error: t("dnsLookupFailed"), records: [] }); } }); // ── DNS Provider Detection ─────────────────────────────────────── // Ordered table: the FIRST pattern that matches any nameserver wins, so put // broader / higher-priority providers first. `detectProviderFromNameservers` // tests each pattern UNANCHORED (`RegExp.test` = substring match), which is // load-bearing for the Cloudflare row: `ns\d*\.cloudflare\.com` has `\d*` // (zero-or-more), so it matches the letter-prefixed hosts Cloudflare actually // hands out — `walt.ns.cloudflare.com` / `ziggy.ns.cloudflare.com` — on the // `ns.cloudflare.com` substring, while still matching `ns1.cloudflare.com`. // Verified against those samples; do NOT “tighten” this to `ns\d+\.` or anchor // it with `^`, which would silently regress the common case to "Other". const DNS_PROVIDERS: Array<{ pattern: RegExp; name: string }> = [ { pattern: /ns\d*\.cloudflare\.com/i, name: "Cloudflare" }, { pattern: /awsdns-\d+\.(com|net|org|co\.uk)$/i, name: "AWS Route 53" }, { pattern: /googledomains\.com/i, name: "Google Cloud DNS" }, { pattern: /ns\d*\.google\.com/i, name: "Google Cloud DNS" }, { pattern: /domaincontrol\.com/i, name: "GoDaddy" }, { pattern: /godaddy\.com/i, name: "GoDaddy" }, { pattern: /registrar-servers\.com/i, name: "Namecheap" }, { pattern: /dnsmadeeasy\.com/i, name: "DNS Made Easy" }, { pattern: /squarespace\.com/i, name: "Squarespace" }, { pattern: /dynadot\.com/i, name: "Dynadot" }, { pattern: /ultradns\.(com|net)/i, name: "UltraDNS" }, { pattern: /easydns\.com/i, name: "easyDNS" }, { pattern: /hover\.com/i, name: "Hover" }, { pattern: /dreamhost\.com/i, name: "DreamHost" }, { pattern: /ionos\.(com|dev)/i, name: "IONOS" }, { pattern: /hostgator\.com/i, name: "HostGator" }, { pattern: /bluehost\.com/i, name: "Bluehost" }, { pattern: /namesilo\.com/i, name: "NameSilo" }, { pattern: /tucows\.com/i, name: "Tucows" }, ]; function detectProviderFromNameservers(nameservers: string[]): string { for (const ns of nameservers) { for (const { pattern, name } of DNS_PROVIDERS) { if (pattern.test(ns)) return name; } } return "Other"; } // ── POST /api/v1/setup/detect-dns-provider ──────────────────────── // Query NS records for a domain and detect the DNS provider. setup.post("/api/v1/setup/detect-dns-provider", async (c) => { const t = setupT(c); try { const body = await c.req.json<{ domain: string }>(); const { domain } = body; if (!domain) { return c.json({ error: t("missingDomainField") }, 400); } let nameservers: string[] = []; try { nameservers = await dns.promises.resolveNs(domain); } catch { // DNS lookup failed (NXDOMAIN, timeout, etc.) — return Other } const provider = detectProviderFromNameservers(nameservers); return c.json({ provider, nameservers }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("detectDnsProvider failed:", msg); return c.json({ error: t("dnsProviderDetectionFailed") }, 500); } }); // ── GET /api/v1/setup/status ─────────────────────────────────────── // Check whether the application has been configured. setup.get("/api/v1/setup/status", async (c) => { const bucket = c.env.BUCKET; const db = c.env.DB; // 1. Check R2 for existing mailboxes const mailboxes = await listMailboxes(bucket); const mailboxCount = mailboxes.length; // 2. Check D1 for email data let hasEmails = false; if (mailboxCount > 0) { try { const row = await db .prepare("SELECT COUNT(*) as cnt FROM emails LIMIT 1") .first<{ cnt: number }>(); hasEmails = (row?.cnt ?? 0) > 0; } catch { // Table might not exist yet hasEmails = false; } } return c.json({ configured: mailboxCount > 0, mailboxCount, hasEmails, }); }); // ── POST /api/v1/setup/verify-domain ─────────────────────────────── // Create a Resend domain, add DNS records via CF API, and verify. setup.post("/api/v1/setup/verify-domain", async (c) => { const t = setupT(c); try { const body = await c.req.json<{ domain: string; resendApiKey: string; cfApiToken: string; cfAccountId: string; }>(); let { domain, resendApiKey, cfApiToken, cfAccountId } = body; // Fall back to platform settings if CF credentials not provided if (!cfApiToken) { const saved = await dbService.getSetting(c.env.DB, "cf_api_token"); if (saved) cfApiToken = saved; } if (!cfAccountId) { const saved = await dbService.getSetting(c.env.DB, "cf_account_id"); if (saved) cfAccountId = saved; } if (!domain || !resendApiKey || !cfApiToken || !cfAccountId) { return c.json( { error: t("missingVerifyDomainCredentials") }, 400, ); } // 1. Create domain via Resend API const resendRes = await fetchWithTimeout("https://api.resend.com/domains", { method: "POST", headers: { Authorization: `Bearer ${resendApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: domain, region: "us-east-1" }), }); if (!resendRes.ok) { const errBody = await resendRes.json().catch(() => ({})); const msg = (errBody as any).message || t("resendApiError", { status: resendRes.status }); return c.json({ error: t("failedToCreateResendDomainReason", { reason: msg }) }, 400); } const resendData = (await resendRes.json()) as { id: string; name: string; status: string; records: Array<{ record: string; name: string; type: string; ttl: string; status: string; value: string; priority?: number; }>; }; const domainId = resendData.id; // 2. Get Cloudflare zone ID — try exact match, then parent zones for subdomains let zoneId: string | undefined; // Try exact match first const zonesUrl = new URL( `https://api.cloudflare.com/client/v4/zones?name=${domain}&status=active`, ); const zonesRes = await fetchWithTimeout(zonesUrl.toString(), { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }); if (zonesRes.ok) { const zonesData = (await zonesRes.json()) as { success: boolean; errors: Array<{ message: string }>; result: Array<{ id: string }>; }; if (zonesData.success && zonesData.result?.length) { zoneId = zonesData.result[0].id; } } // If not found, try parent zones for subdomains (e.g. a.example.com → example.com) if (!zoneId) { const parts = domain.split("."); for (let i = 1; i < parts.length - 1; i++) { const parentZone = parts.slice(i).join("."); const parentRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones?name=${parentZone}&status=active`, { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }, ); if (parentRes.ok) { const parentData = (await parentRes.json()) as { success: boolean; result: Array<{ id: string }>; }; if (parentData.success && parentData.result?.length) { zoneId = parentData.result[0].id; break; } } } } if (!zoneId) { return c.json( { error: t("zoneNotFoundVerify", { domain }), }, 400, ); } // 3. Add DNS records from Resend verification records const dnsResults: Array<{ name: string; type: string; status: string; value: string }> = []; for (const record of resendData.records) { const dnsBody: Record = { type: record.type, name: record.name, content: record.value, ttl: record.ttl ? Number(record.ttl) : 1, }; // MX records require priority if (record.priority !== undefined) { dnsBody.priority = record.priority; } // CAA and TXT records need proxied=false const dnsRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, { method: "POST", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ ...dnsBody, proxied: false }), }, ); if (!dnsRes.ok) { const errBody = await dnsRes.json().catch(() => ({})); const errMsg = (errBody as any)?.errors?.[0]?.message || t("dnsRecordCreationFailed", { status: dnsRes.status }); dnsResults.push({ name: record.name, type: record.type, status: t("dnsRecordStatusError", { message: errMsg }), value: record.value || "", }); } else { dnsResults.push({ name: record.name, type: record.type, status: t("dnsRecordStatusCreated"), value: record.value || "", }); } } // 4. Wait 2s then verify domain via Resend await new Promise((r) => setTimeout(r, 2000)); const verifyRes = await fetchWithTimeout( `https://api.resend.com/domains/${domainId}/verify`, { method: "POST", headers: { Authorization: `Bearer ${resendApiKey}`, "Content-Type": "application/json", }, }, ); const verifyData = (await verifyRes.json()) as { id: string; status: string; }; // 5. Persist domain to the domains table try { const existingDomain = await dbService.getDomainByName(c.env.DB, domain.toLowerCase()); if (existingDomain) { await dbService.updateDomain(c.env.DB, existingDomain.id, { resend_domain_id: domainId, status: normalizeDomainStatus(verifyData.status), resend_api_key: resendApiKey, }); } else { await dbService.createDomain(c.env.DB, { id: crypto.randomUUID(), name: domain.toLowerCase(), resend_domain_id: domainId, status: normalizeDomainStatus(verifyData.status), resend_api_key: resendApiKey, created_at: new Date().toISOString(), }); } } catch (dbErr) { console.error("Failed to persist domain to DB:", dbErr); return c.json({ error: t("failedToSaveDomainConfig") }, 500); } return c.json({ domainId, status: normalizeDomainStatus(verifyData.status), dnsRecords: dnsResults, }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("verifyDomain failed:", msg); return c.json({ error: t("domainVerificationSetupFailed") }, 500); } }); // ── POST /api/v1/setup/email-routing ─────────────────────────────── // Enable Cloudflare Email Routing with a catch-all rule pointing to // the "mailboxes" Worker. setup.post("/api/v1/setup/email-routing", async (c) => { const t = setupT(c); try { const body = await c.req.json<{ domain: string; cfApiToken: string; cfAccountId: string; }>(); let { domain, cfApiToken, cfAccountId } = body; // Fall back to platform settings if CF credentials not provided if (!cfApiToken) { const saved = await dbService.getSetting(c.env.DB, "cf_api_token"); if (saved) cfApiToken = saved; } if (!cfAccountId) { const saved = await dbService.getSetting(c.env.DB, "cf_account_id"); if (saved) cfAccountId = saved; } if (!domain || !cfApiToken) { return c.json( { error: t("missingEmailRoutingFields") }, 400, ); } // 1. Get zone ID — try exact match first, then parent zone for subdomains let zoneId: string | null = null; // Try exact match (accept any status — zone may still be pending after creation) let zonesUrl = new URL( `https://api.cloudflare.com/client/v4/zones?name=${domain}`, ); let zonesRes = await fetchWithTimeout(zonesUrl.toString(), { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }); if (zonesRes.ok) { const zonesData = (await zonesRes.json()) as { success: boolean; errors: Array<{ message: string }>; result: Array<{ id: string }>; }; if (zonesData.success && zonesData.result?.length) { zoneId = zonesData.result[0].id; } } // If not found, try parent zones for subdomains if (!zoneId) { const parts = domain.split("."); for (let i = 1; i < parts.length - 1; i++) { const parentZone = parts.slice(i).join("."); zonesUrl = new URL( `https://api.cloudflare.com/client/v4/zones?name=${parentZone}`, ); zonesRes = await fetchWithTimeout(zonesUrl.toString(), { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }); if (zonesRes.ok) { const zonesData = (await zonesRes.json()) as { success: boolean; errors: Array<{ message: string }>; result: Array<{ id: string }>; }; if (zonesData.success && zonesData.result?.length) { zoneId = zonesData.result[0].id; break; } } } } if (!zoneId) { return c.json( { error: t("zoneNotFoundRouting", { domain }) }, 400, ); } // 2. Enable Email Routing const enableRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones/${zoneId}/email/routing/enable`, { method: "POST", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }, ); if (!enableRes.ok) { const errBody = await enableRes.json().catch(() => ({})); const msg = (errBody as any)?.errors?.[0]?.message || t("failedToEnableEmailRouting", { status: enableRes.status }); return c.json({ error: msg }, 400); } // 3. Set up catch-all rule → forward to Worker "mailboxes" const catchAllRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones/${zoneId}/email/routing/rules/catch_all`, { method: "PUT", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Catch-all to Worker", actions: [ { type: "worker", value: ["mailboxes"], }, ], matchers: [ { type: "all", }, ], enabled: true, }), }, ); if (!catchAllRes.ok) { const errBody = await catchAllRes.json().catch(() => ({})); const msg = (errBody as any)?.errors?.[0]?.message || t("failedToSetCatchAll", { status: catchAllRes.status }); return c.json({ error: msg }, 400); } // 4. Save cf_zone_id and cf_account_id to domains table try { const existingDomain = await dbService.getDomainByName(c.env.DB, domain.toLowerCase()); if (existingDomain) { await dbService.updateDomain(c.env.DB, existingDomain.id, { cf_zone_id: zoneId, cf_account_id: cfAccountId, }); } else { await dbService.createDomain(c.env.DB, { id: crypto.randomUUID(), name: domain.toLowerCase(), cf_zone_id: zoneId, cf_account_id: cfAccountId, status: "pending", created_at: new Date().toISOString(), }); } } catch (dbErr) { console.error("Failed to persist domain routing info to DB:", dbErr); // Non-fatal — email routing is still configured } return c.json({ success: true }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("emailRouting failed:", msg); return c.json({ error: t("failedToSetupEmailRouting") }, 500); } }); // ── Domain management routes ─────────────────────────────────── // GET /api/v1/domains — list all domains setup.get("/api/v1/domains", async (c) => { const t = setupT(c); try { const domains = await dbService.listDomains(c.env.DB); return c.json(domains); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("listDomains failed:", msg); return c.json({ error: t("failedToListDomains") }, 500); } }); // POST /api/v1/domains — add a new domain (triggers Resend + CF DNS setup) setup.post("/api/v1/domains", async (c) => { const t = setupT(c); try { const body = await c.req.json<{ domain: string; cfZoneId?: string; resendApiKey?: string; cfApiToken?: string; cfAccountId?: string; }>(); let { domain, resendApiKey, cfApiToken, cfAccountId, cfZoneId } = body; // Fall back to platform settings if CF credentials not provided if (!cfApiToken) { const saved = await dbService.getSetting(c.env.DB, "cf_api_token"); if (saved) cfApiToken = saved; } if (!cfAccountId) { const saved = await dbService.getSetting(c.env.DB, "cf_account_id"); if (saved) cfAccountId = saved; } if (!domain) { return c.json({ error: t("missingDomainField") }, 400); } // Check if domain already exists const existingDomain = await dbService.getDomainByName(c.env.DB, domain.toLowerCase()); let domainId: string; let domainWasReused = false; let originalStatus: string | undefined; let resendDomainId: string | undefined; const dnsResults: Array<{ name: string; type: string; status: string; value: string }> = []; const warnings: string[] = []; if (existingDomain) { // If domain already has sending configured via Resend, it's a real duplicate if (existingDomain.resend_domain_id) { return c.json({ error: t("domainAlreadyExists"), domain: existingDomain }, 409); } // Domain was created by the receiving step (email-routing) — reuse it // and upgrade with sending configuration domainId = existingDomain.id; domainWasReused = true; originalStatus = existingDomain.status; await dbService.updateDomain(c.env.DB, domainId, { status: "pending", resend_api_key: resendApiKey || null, }); } else { // Create new domain record domainId = crypto.randomUUID(); const now = new Date().toISOString(); await dbService.createDomain(c.env.DB, { id: domainId, name: domain.toLowerCase(), status: "pending", resend_api_key: resendApiKey || null, cf_zone_id: cfZoneId || null, cf_account_id: cfAccountId || null, created_at: now, }); } // If no Resend API key provided (send-only skipped), mark domain as // verified immediately so the UI shows the correct status for receive-only // setups. The full DNS verification path below only runs when all three // credentials (resendApiKey + cfApiToken + cfAccountId) are present. if (!resendApiKey) { await dbService.updateDomain(c.env.DB, domainId, { status: "verified", }); // If we reused an existing record (from receiving), return the updated domain const updatedDomain = await dbService.getDomain(c.env.DB, domainId); return c.json({ domain: updatedDomain, dnsRecords: dnsResults, warnings }, 200); } // If API keys provided, perform DNS setup automatically if (resendApiKey && cfApiToken && cfAccountId) { // 1. Create Resend domain const resendRes = await fetchWithTimeout("https://api.resend.com/domains", { method: "POST", headers: { Authorization: `Bearer ${resendApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: domain, region: "us-east-1" }), }); if (resendRes.ok) { const resendData = (await resendRes.json()) as { id: string; records: Array<{ record: string; name: string; type: string; ttl: string; status: string; value: string; priority?: number; }>; }; resendDomainId = resendData.id; // 2. Get Cloudflare zone ID — try exact match, then parent zones for subdomains let zoneId: string | undefined; // Try exact match first const zonesRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones?name=${domain}&status=active`, { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }, ); if (zonesRes.ok) { const zonesData = (await zonesRes.json()) as { success: boolean; result: Array<{ id: string }>; }; if (zonesData.success && zonesData.result?.length) { zoneId = zonesData.result[0].id; } } // If not found, try parent zones for subdomains (e.g. a.example.com → example.com) if (!zoneId) { const parts = domain.split("."); for (let i = 1; i < parts.length - 1; i++) { const parentZone = parts.slice(i).join("."); const parentRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones?name=${parentZone}&status=active`, { method: "GET", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }, ); if (parentRes.ok) { const parentData = (await parentRes.json()) as { success: boolean; result: Array<{ id: string }>; }; if (parentData.success && parentData.result?.length) { zoneId = parentData.result[0].id; break; } } } } if (!zoneId) { warnings.push(t("warningNoCloudflareZone")); } // 3. Add DNS records if (zoneId) { for (const record of resendData.records) { const dnsBody: Record = { type: record.type, name: record.name, content: record.value, ttl: record.ttl ? Number(record.ttl) : 1, }; if (record.priority !== undefined) { dnsBody.priority = record.priority; } const dnsRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`, { method: "POST", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ ...dnsBody, proxied: false }), }, ); if (!dnsRes.ok) { const errBody = await dnsRes.json().catch(() => ({})); dnsResults.push({ name: record.name, type: record.type, status: t("dnsRecordStatusError", { message: (errBody as any)?.errors?.[0]?.message || t("dnsRecordStatusUnknown"), }), value: record.value || "", }); } else { dnsResults.push({ name: record.name, type: record.type, status: t("dnsRecordStatusCreated"), value: record.value || "" }); } } // 4. Enable Email Routing and set catch-all const emailRoutingRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones/${zoneId}/email/routing/enable`, { method: "POST", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, }, ); if (!emailRoutingRes.ok) { warnings.push(t("warningEmailRoutingSetupFailed")); } const catchAllRes = await fetchWithTimeout( `https://api.cloudflare.com/client/v4/zones/${zoneId}/email/routing/rules/catch_all`, { method: "PUT", headers: { Authorization: `Bearer ${cfApiToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Catch-all to Worker", actions: [{ type: "worker", value: ["mailboxes"] }], matchers: [{ type: "all" }], enabled: true, }), }, ); if (!catchAllRes.ok) { warnings.push(t("warningCatchAllSetupFailed")); } // Update domain with CF info await dbService.updateDomain(c.env.DB, domainId, { cf_zone_id: zoneId, cf_account_id: cfAccountId, }); } // 5. Verify Resend domain await new Promise((r) => setTimeout(r, 2000)); const verifyRes = await fetchWithTimeout( `https://api.resend.com/domains/${resendDomainId}/verify`, { method: "POST", headers: { Authorization: `Bearer ${resendApiKey}`, "Content-Type": "application/json", }, }, ); const verifyData = (await verifyRes.json()) as { status: string }; // Update domain with final status await dbService.updateDomain(c.env.DB, domainId, { resend_domain_id: resendDomainId, status: normalizeDomainStatus(verifyData.status), }); } else { const errBody = await resendRes.json().catch(() => ({})); // Resend API failed — roll back the D1 record or revert to original status if (domainWasReused) { await dbService.updateDomain(c.env.DB, domainId, { status: originalStatus ?? "pending", resend_api_key: null, }); } else { await dbService.deleteDomain(c.env.DB, domainId); } return c.json({ error: t("failedToCreateDomainOnResend", { reason: (errBody as any)?.message || resendRes.statusText || resendRes.status, }), }, 400); } } else if (resendApiKey) { // Resend-only mode: create Resend domain, return DNS records for user to add manually const resendRes = await fetchWithTimeout("https://api.resend.com/domains", { method: "POST", headers: { Authorization: `Bearer ${resendApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: domain, region: "us-east-1" }), }); if (resendRes.ok) { const resendData = (await resendRes.json()) as { id: string; records: Array<{ record: string; name: string; type: string; ttl: string; status: string; value: string; priority?: number; }>; }; resendDomainId = resendData.id; // Collect DNS records for the user to add manually for (const record of resendData.records) { dnsResults.push({ name: record.name, type: record.type, status: record.status || "pending", value: record.value || "", }); } // Update domain with Resend info (no provider auto-setup) await dbService.updateDomain(c.env.DB, domainId, { resend_domain_id: resendDomainId, status: "pending", resend_api_key: resendApiKey, }); } else { const errBody = await resendRes.json().catch(() => ({})); const msg = (errBody as any).message || t("resendApiError", { status: resendRes.status }); // Resend API failed — roll back the D1 record or revert to original status if (domainWasReused) { await dbService.updateDomain(c.env.DB, domainId, { status: originalStatus ?? "pending", resend_api_key: null, }); } else { await dbService.deleteDomain(c.env.DB, domainId); } return c.json({ error: t("failedToCreateResendDomainReason", { reason: msg }) }, 400); } } const updatedDomain = await dbService.getDomain(c.env.DB, domainId); return c.json({ domain: updatedDomain, dnsRecords: dnsResults, warnings }, 201); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("addDomain failed:", msg); return c.json({ error: t("failedToAddDomain") }, 500); } }); // DELETE /api/v1/domains/:id — remove a domain and clean up all resources setup.delete("/api/v1/domains/:id", async (c) => { const t = setupT(c); try { const id = c.req.param("id"); const domain = await dbService.getDomain(c.env.DB, id); if (!domain) { return c.json({ error: t("domainNotFound") }, 404); } const errors: string[] = []; // ── 1. Clean up all mailboxes under this domain (D1 + R2) ── const allMailboxes = await listMailboxes(c.env.BUCKET); const domainMailboxes = allMailboxes.filter((m) => { const atIdx = m.id.indexOf("@"); return atIdx !== -1 && m.id.substring(atIdx + 1) === domain.name; }); for (const mailbox of domainMailboxes) { try { // Delete D1 data (emails, attachments, folders, AI chat) and get attachment list const attachments = await dbService.deleteMailbox(c.env.DB, mailbox.id); // Delete R2 config JSON await c.env.BUCKET.delete(`mailboxes/${mailbox.id}.json`); // Delete R2 attachment blobs if (attachments.length > 0) { try { await c.env.BUCKET.delete( attachments.map((a) => `attachments/${a.email_id}/${a.id}/${a.filename}`), ); } catch { // Attachment deletion failure is non-fatal } } } catch (e) { const mailboxMsg = e instanceof Error ? e.message : "unknown"; console.error(`Failed to delete mailbox ${mailbox.id}:`, mailboxMsg); errors.push(t("failedToDeleteMailbox", { mailbox: mailbox.id })); } } // ── 2. Delete Resend domain ── if (domain.resend_domain_id && domain.resend_api_key) { try { const resendRes = await fetchWithTimeout( `https://api.resend.com/domains/${domain.resend_domain_id}`, { method: "DELETE", headers: { Authorization: `Bearer ${domain.resend_api_key}`, }, }, ); if (!resendRes.ok) { errors.push(t("resendDomainDeleteFailed", { status: resendRes.status })); } } catch (e) { const resendMsg = e instanceof Error ? e.message : "unknown"; console.error("Resend cleanup failed:", resendMsg); errors.push(t("resendCleanupFailed")); } } // ── 3. Disable Cloudflare Email Routing (best-effort) ── // Note: CF API token is not stored server-side; we use the catch-all rule // deletion which requires zone-level access. If we have cf_zone_id and // cf_account_id, we attempt to remove the catch-all rule. if (domain.cf_zone_id && domain.cf_account_id) { // We cannot call CF API without a token. Log for manual cleanup. errors.push(t("cfEmailRoutingManualDisable", { zoneId: domain.cf_zone_id })); } // ── 4. Delete domain record from D1 ── await dbService.deleteDomain(c.env.DB, id); return c.json({ deleted: true, mailboxesRemoved: domainMailboxes.length, warnings: errors.length > 0 ? errors : undefined, }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("deleteDomain failed:", msg); return c.json({ error: t("failedToDeleteDomain") }, 500); } }); // PUT /api/v1/domains/:id/catch-all — set or clear the catch-all mailbox setup.put("/api/v1/domains/:id/catch-all", async (c) => { const t = setupT(c); try { const id = c.req.param("id"); const body = await c.req.json<{ catch_all_mailbox: string | null }>(); const domain = await dbService.getDomain(c.env.DB, id); if (!domain) { return c.json({ error: t("domainNotFound") }, 404); } const { catch_all_mailbox } = body; if (catch_all_mailbox) { let resolvedMailbox: string; // Handle simplified input: *@domain.com -> create catch-all mailbox *@domain.com if (catch_all_mailbox.startsWith("*@")) { const inputDomain = catch_all_mailbox.slice(2); // strip "*" if (inputDomain.toLowerCase() !== domain.name) { return c.json({ error: t("domainMismatch", { inputDomain, domain: domain.name }) }, 400); } resolvedMailbox = `*@${domain.name}`; // Auto-create the catchall mailbox if it does not exist const mailboxKey = `mailboxes/${resolvedMailbox}.json`; if (!(await c.env.BUCKET.head(mailboxKey))) { const defaultSettings = { fromName: "Catch-all", forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, }; await c.env.BUCKET.put(mailboxKey, JSON.stringify(defaultSettings)); await dbService.initMailboxFolders(c.env.DB, resolvedMailbox); } } else if (catch_all_mailbox.startsWith("@")) { // Legacy @domain format -> normalize to *@domain resolvedMailbox = `*${catch_all_mailbox}`; const mailboxKey = `mailboxes/${resolvedMailbox}.json`; if (!(await c.env.BUCKET.head(mailboxKey))) { // Auto-create the catchall mailbox if it does not exist const defaultSettings = { fromName: "Catch-all", forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, }; await c.env.BUCKET.put(mailboxKey, JSON.stringify(defaultSettings)); await dbService.initMailboxFolders(c.env.DB, resolvedMailbox); } } else { // Traditional input: verify the mailbox exists in R2 resolvedMailbox = catch_all_mailbox; const mailboxKey = `mailboxes/${resolvedMailbox}.json`; if (!(await c.env.BUCKET.head(mailboxKey))) { return c.json({ error: t("mailboxDoesNotExist", { mailbox: resolvedMailbox }) }, 400); } // Verify the mailbox belongs to this domain const mailboxDomain = resolvedMailbox.split("@")[1]?.toLowerCase(); if (mailboxDomain !== domain.name) { return c.json({ error: t("mailboxMustBelongToDomain", { domain: domain.name }) }, 400); } } await dbService.updateDomain(c.env.DB, id, { catch_all_mailbox: resolvedMailbox, }); } else { // Clear catch-all await dbService.updateDomain(c.env.DB, id, { catch_all_mailbox: null, }); } const updated = await dbService.getDomain(c.env.DB, id); return c.json(updated); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("updateCatchAll failed:", msg); return c.json({ error: t("failedToUpdateCatchAll") }, 500); } }); // PUT /api/v1/domains/:id/api-key — update domain-level Resend API key setup.put("/api/v1/domains/:id/api-key", async (c) => { const t = setupT(c); try { const id = c.req.param("id"); const body = await c.req.json<{ resend_api_key: string | null }>(); const domain = await dbService.getDomain(c.env.DB, id); if (!domain) { return c.json({ error: t("domainNotFound") }, 404); } const { resend_api_key } = body; await dbService.updateDomain(c.env.DB, id, { resend_api_key: resend_api_key || null, }); const updated = await dbService.getDomain(c.env.DB, id); return c.json(updated); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("updateApiKey failed:", msg); return c.json({ error: t("failedToUpdateApiKey") }, 500); } }); // GET /api/v1/setup/verify-domain/:domainId — Poll domain verification status setup.get("/api/v1/setup/verify-domain/:domainId", async (c) => { const t = setupT(c); try { const domainId = c.req.param("domainId"); const domain = await dbService.getDomain(c.env.DB, domainId); if (!domain) { return c.json({ error: t("domainNotFound") }, 404); } // If already verified or failed, just return current status if (domain.status === "verified" || domain.status === "failed") { return c.json({ domainId: domain.id, name: domain.name, status: domain.status, }); } // If pending and we have a resend_domain_id, trigger re-verification if (domain.status === "pending" && domain.resend_domain_id && domain.resend_api_key) { try { const verifyRes = await fetchWithTimeout( `https://api.resend.com/domains/${domain.resend_domain_id}/verify`, { method: "POST", headers: { Authorization: `Bearer ${domain.resend_api_key}`, "Content-Type": "application/json", }, }, ); if (verifyRes.ok) { const verifyData = (await verifyRes.json()) as { status: string }; const newStatus = normalizeDomainStatus(verifyData.status); await dbService.updateDomain(c.env.DB, domainId, { status: newStatus, }); return c.json({ domainId: domain.id, name: domain.name, status: newStatus, }); } } catch (verifyErr) { console.error("Re-verification request failed:", verifyErr); } } // Return current status if re-verification didn't help return c.json({ domainId: domain.id, name: domain.name, status: domain.status, }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Unknown error"; console.error("verifyDomainPoll failed:", msg); return c.json({ error: t("failedToVerifyDomain") }, 500); } }); export default setup;