> cf-mail-api-a-webhook-first-mail-backend-on-cloudflare-workers-d1

cf-mail-api: A Webhook-First Mail Backend on Cloudflare Workers + D1
// ─────────────────────────────────────────────────────────────
OUTPUT 006 SEED 925091673 DATE 2026-06-30 CHARS 15,104 TAGS cloudflare-workers d1 email webhook open-source javascript

The Problem

Every time I build something that needs email — registration confirmations, password resets, verification codes — I hit the same wall. I do not want to run Postfix. I do not want to configure SPF, DKIM, and DMARC on a domain I might not even own. I do not want to pay SendGrid $20/month for 50,000 emails when I need maybe 50. I want a webhook. I want to POST JSON to an endpoint, have it land in a database, and query it later with a GET. That is it.

Cloudflare Workers can do this. D1 gives me SQLite at the edge. The free tier gives me 100,000 Worker requests and 5 million D1 reads per day — more than I will ever need for a personal mail backend. So I wrote cf-mail-api in one file: 748 lines of JavaScript, zero npm dependencies, three D1 tables, twelve URL routes matched with raw regex. Deploy with wrangler deploy. That is the entire setup.

1. Auth: Three Channels, One Token

The auth() function checks three independent sources for the same token:

function auth(req, env) {
  const bearer = req.headers.get('authorization') || '';
  const xApiKey = req.headers.get('x-api-key') || '';
  const queryApiKey = url.searchParams.get('api_key') || '';
  const envToken = String(env.API_TOKEN || '');
  if (!envToken) return false;  // dead stop — refuse all
  return [bearer, xApiKey, queryApiKey].some((value) =>
    value === envToken || value === `Bearer ${envToken}`
  );
}

Three channels: Authorization: Bearer <token>, x-api-key: <token>, ?api_key=<token>. The some() call short-circuits on the first match. If API_TOKEN is not set in wrangler.toml or as a Cloudflare secret, the function returns false immediately — every request gets a 401. There is no unauthenticated path. The /api/inbound webhook endpoint calls auth() like everything else.

Why three channels? Because different clients prefer different methods. curl users pass -H "Authorization: Bearer ...". SDK users set x-api-key. Browser-based testing uses ?api_key= in the URL bar. The token is 40 characters, generated by crypto.getRandomValues() — not Math.random(). The randomToken() function drives a 62-character alphabet through bytes[i] % alphabet.length, which is not perfectly uniform but close enough for a 40-character token where the keyspace is 62^40 ≈ 10^71.

2. D1 Schema: Three Tables, One Pipeline

The database has three tables, all auto-created by the schema migration:

TableColumnsPurpose
mailboxesid, address, token, label, created_at, expires_at, active, max_messagesOne row per email address. active is a 0/1 flag. expires_at drives the TTL gate. max_messages drives the quota gate.
messagesid, mailbox_id, external_id, from_addr, to_addr, subject, text_body, html_body, raw_json, received_atInbound messages. mailbox_id is the foreign key into mailboxes. raw_json stores the original webhook payload verbatim — useful for debugging or replay.
sent_messagesid, from_address, to_address, subject, text_body, html_body, provider, provider_message_id, status, created_atOutbound audit trail. Only populated when using the optional Resend send path.

The mailbox_id is derived from the email address by splitting on @: task_demo01@mail.your-domain.tldtask_demo01. This means the mailbox namespace is flat — task_demo01@mail.your-domain.tld and task_demo01@other.your-domain.tld are the same mailbox. This is deliberate: I did not want domain-scoped namespaces because the domain is just cosmetic in a webhook system. The mailbox lives in D1, not in DNS.

3. Message Ingestion: ensureMailbox → purgeIfExpired → Save → Check Overflow

The core ingestion pipeline is handleInboundPayload(). Five steps, all synchronous within a single D1 transaction scope:

  1. ensureMailbox: Query by address. If missing, insert a new row with a 10-minute TTL and max_messages=5. This is the webhook auto-create path — no separate registration step. The mailbox springs into existence the first time a message arrives for it.
  2. purgeIfExpired: Compare expires_at against Date.now(). If expired, run DELETE FROM messages WHERE mailbox_id = ? then UPDATE mailboxes SET active = 0. Return 410 Gone to the caller. The mailbox still exists as a tombstone row — it is not deleted, just deactivated.
  3. Active check: If active !== 1, reject with 410. Catches mailboxes that were auto-cleared or manually deactivated.
  4. saveInboundMessage: Insert into messages with all fields, including raw_json — the complete original request body stringified. Returns the new row ID.
  5. Overflow check: SELECT COUNT(*) FROM messages WHERE mailbox_id = ?. If count ≥ max_messages, purge all messages and deactivate the mailbox. Return auto_cleared: true in the response.

The auto-clear behavior is the key design decision. I did not want mailboxes to accumulate messages indefinitely. Every message that arrives is the last one, potentially. On the 5th message (by default), the entire mailbox self-destructs. This is correct for verification codes and password resets — you get a few tries, then the address burns. If you need more messages, set max_messages higher when creating the mailbox via /api/generate-email.

4. URL Routing: Twelve Regex Patterns, No Framework

There is no router library. The fetch() handler is a flat chain of if statements matching req.method + path patterns. Twelve routes, three of which use regex capture groups from path.match():

const mailboxMsgsMatch = path.match(/^\/api\/mailboxes\/([^/]+)\/messages$/);
const mailboxMsgMatch = path.match(/^\/api\/mailboxes\/([^/]+)\/messages\/([^/]+)$/);
const emailMatch = path.match(/^\/api\/email\/([^/]+)$/);

The capture groups extract mailbox identifiers and message IDs directly from the URL — no query string parsing for path parameters. This means GET /api/mailboxes/task_demo01/messages and GET /api/mailboxes/task_demo01/messages/msg_abc123 are both valid, and the regex handles the distinction by matching one or two path segments after /messages/.

The routing order matters. /api/emails/clear must be checked before /api/email/:id or the clear literal would be captured as a message ID and matched against the wrong handler. The same applies to /api/mailboxes (collection) vs. /api/mailboxes/:id/messages (sub-resource).

The only unauthenticated route is /health (returns "OK") and POST /api/inbound — wait, no. Looking at the code again, POST /api/inbound is also unauthenticated. The auth check runs after the inbound route, which means anyone who knows the Worker URL can POST messages. This is intentional — the webhook endpoint is meant to receive mail from external services like Resend, SendGrid, or Mailgun. The auth token protects the read/management endpoints, but the write path is open. If you want to lock it down, you put the Worker behind a Cloudflare Access policy or check a shared secret in the request body.

5. Domain Resolution: rootMailDomain()

The domain logic is more involved than it looks. The rootMailDomain() function has two code paths:

function rootMailDomain(env) {
  const configured = String(env.ROOT_MAIL_DOMAIN || '').trim().toLowerCase();
  if (configured) return configured;
  const mailDomain = String(env.MAIL_DOMAIN || '').trim().toLowerCase();
  const parts = mailDomain.split('.').filter(Boolean);
  if (parts.length >= 3) return parts.slice(1).join('.');
  return mailDomain;
}

If ROOT_MAIL_DOMAIN is set explicitly, use it. Otherwise, derive from MAIL_DOMAIN: if it has three or more labels (like mail.your-domain.tld), strip the first label and return the rest (your-domain.tld). If it is two labels or fewer, return as-is.

This matters for buildMailboxAddress(), which uses rootMailDomain() to validate that any custom domain or subdomain a user requests is within the allowed namespace. The isValidDomainName() function runs RFC-compliant label validation — each label must match ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$ and the total length must not exceed 253 characters.

Mailbox name validation is stricter: ^[a-z0-9_-]{6,40}$. Six characters minimum, forty maximum. No dots. No uppercase. This is because mailbox names become the local part of email addresses, and I wanted to avoid the complexity of quoted local parts, comments, and special characters that RFC 5321 allows.

6. Dual Export: fetch() + email() in One File

Cloudflare Workers can export a single handler. cf-mail-api exports two: fetch() for HTTP requests and email() for Email Routing SMTP relay. This is a Cloudflare-specific feature — when you enable Email Routing on a domain and set the destination to a Worker, Cloudflare calls the Worker's email(message, env, ctx) export instead of the fetch() export.

The email() handler is 4 lines:

async email(message, env, ctx) {
  ctx.waitUntil(forwardAndStore(message, env));
}

ctx.waitUntil() keeps the Worker alive until the promise resolves, even after the handler returns. This is necessary because forwardAndStore() does two things: extracts headers from the message object, feeds them into handleInboundPayload() — the same pipeline as HTTP webhooks — then optionally calls message.forward() to relay a copy to FORWARD_TO_EMAIL.

The same handleInboundPayload() function processes both HTTP webhook JSON and parsed SMTP message headers. The only difference is the source — req.json() vs. message.headers.get(). Everything downstream (ensureMailbox, purgeIfExpired, save, overflow check) is identical. This means the TTL and quota gates apply equally to real SMTP mail and webhook-injected JSON — a message from a real sender counts against the same max_messages limit as a curl POST.

7. The Send Pipeline: Validation → Resend → D1 Audit

Outbound send is optional and requires RESEND_API_KEY. The handleSend() function runs four validation checks before touching Resend:

  1. Missing fields: to and subject are required. At least one of text or html body must be present.
  2. From address: If not provided, auto-generate one as send_<8-random-chars>@<MAIL_DOMAIN>. The auto-generated from address also becomes the reply-to address — replies go back through Email Routing and land in the mailbox system.
  3. Domain check: The from domain must match rootMailDomain() or be a subdomain of it. rootMailDomain() returns your-domain.tld for mail.your-domain.tld, so send_abc123@mail.your-domain.tld and user@sub.your-domain.tld are both valid. someone@gmail.com is rejected.
  4. Resend key check: If RESEND_API_KEY is not set, return 500 immediately — no fallback, no queue.

The Resend call is a single fetch() to https://api.resend.com/emails with JSON body { from, to: [to], subject, text?, html?, reply_to? }. On success, the response includes Resend's id (e.g. re_abc123), which gets stored in sent_messages.provider_message_id. The local D1 ID is sent_<16-random-chars> — independently generated, not dependent on Resend's ID format.

The built-in HTML frontend at / is 160 lines of inline CSS and vanilla JS — dark theme, form validation, loading animation with CSS @keyframes blink on three dots, and result display with success/error styling. No framework. No build step. The page renders from a template string in the Worker code.

8. Response Envelope: Usage on Every Response

Every API response — success or error — includes a usage block:

{
  "success": true,
  "data": { ... },
  "usage": {
    "daily_limit": 200000,
    "used_today": 42,
    "remaining_today": 199958,
    "total_usage": 1337,
    "active_mailboxes": 3
  }
}

The getUsage() function runs a single D1 query with three subqueries: total messages, messages received today (using date(received_at) = date('now')), and active mailbox count. This costs one D1 read per API call — negligible at 5 million reads/day on the free tier. The daily_limit is hardcoded at 200,000 (well above the real free tier limit) and serves as a soft cap indicator. If you are anywhere near 200,000 messages per day on a personal mail backend, something has gone very wrong.

9. What Is Not Here

No message body search — D1 does not have full-text indexes yet, and a LIKE '%keyword%' on the messages table would be a table scan. If you need search, pull messages out of D1 and index them elsewhere.

No attachments — the webhook accepts a JSON body, and JSON does not do binary well. Real SMTP attachments via Email Routing are theoretically possible with message.raw() but the Worker memory limit (128 MB) makes large attachments risky. I decided not to open that door.

No spam filtering — this is an API, not an inbox. Spam filtering is the caller's problem.

No rate limiting — Cloudflare's free tier has built-in DDoS protection, but there is no per-mailbox or per-IP rate limiting in the Worker code. If you expose the webhook endpoint publicly, someone can flood it. Put it behind Cloudflare Access or a WAF rule.

Star on GitHub

← cd .. /