workers/setup.ts
Rama main · réplica de lectura del repositorio original.
// 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<typeof getBackendT> | undefined;
return ((key: string, options?: Record<string, unknown>) => {
t ??= getBackendT(requestLocale(c), "apiSetup");
return t(key, options);
}) as ReturnType<typeof getBackendT>;
}
// 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.<account>.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<string, string | number> = {
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<string, string | number> = {
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;