GitHub - hieunc229/mailflare: Email client with custom domain based on Cloudflare

9 min read Original article ↗

A self-hosted, AI-powered email inbox with custom domains, powered by Cloudflare

Deploy to Cloudflare

Roadmap

  • Domain onboarding through Cloudflare, including inbound Email Routing DNS and sending DNS setup.
  • Domain removal cleanup for linked Cloudflare routing rules and sending subdomain resources.
  • Mailbox creation with automatic Cloudflare Email Routing rules.
  • Mailbox management with a grid view, mailbox detail page, and editable display name.
  • Inbox, sent, drafts, spam, and trash folders backed by a shared mail list component.
  • Popup composer with autosaved drafts and draft resume from the drafts folder.
  • Outbound send API, API keys, message read status, spam/trash moves, and seeded demo data.
  • Inbound and outbound attachments stored in R2 with authenticated downloads.
  • Real-time new-email updates and in-app notifications over WebSockets.
  • Search, filtering, and richer mailbox/folder counts.
  • Attachment support and richer compose formatting.
  • Advanced routing rules for catch-all addresses, forwarding, reject/block rules, and priorities.
  • Webhook management UI and delivery retry visibility.

Email agent

  • Message intelligence with summaries, intent classification, urgency scoring, and extracted entities.
  • Agent task queue for proposed replies, follow-ups, triage actions, and missing-information requests.
  • Human-approved actions for draft replies, folder moves, forwarding, contact creation, and webhook calls.
  • Agent rules for learned post-receipt policies such as prioritization, auto-triage, and reply templates.
  • Agent inbox view organized by action state, including needs reply, waiting on me, waiting on them, FYI, auto-handled, and needs approval.
  • Thread and contact memory for prior summaries, user preferences, relationship notes, commitments, and open loops.
  • Tool execution for trusted actions such as sending email, creating drafts, updating message status, calling webhooks, and creating contacts.

Domain API

Domains are not dashboard-only. This app calls Cloudflare when you add/remove a domain:

Action Cloudflare API
List DNS / status GET /zones/{zone_id}/email/routing/dns
Enable inbound routing + MX/SPF/DKIM POST /zones/{zone_id}/email/routing/dns
Disable routing DELETE /zones/{zone_id}/email/routing/dns
Enable subdomain sending + DNS POST /zones/{zone_id}/email/sending/subdomains
Remove subdomain sending DELETE /zones/{zone_id}/email/sending/subdomains/{tag}
Subdomain sending DNS records GET .../subdomains/{tag}/dns

Requirements: Prefer CF_TOKEN with Zone Read + Email Routing Edit + Email Sending Edit + Email Routing Rules Write (or broader). If you use a legacy Global API Key instead, set CF_API_KEY and CF_EMAIL. The hostname must be the account's Cloudflare zone apex or a subdomain under that zone. Root-domain sending uses the Cloudflare Email Service binding, while subdomain sending can also provision the sending-subdomain DNS records. Mailbox creation creates a Cloudflare Email Routing rule that sends that address to CF_EMAIL_WORKER_NAME.

App routes:

  • GET/POST /api/domains — list / add (calls Cloudflare)
  • GET/DELETE /api/domains/[id] — get / remove (disables routing & sending on CF)
  • GET /api/domains/[id]/dns — routing + sending DNS snapshot

Setup

cp .dev.vars.example .dev.vars
# Add CF_TOKEN and optionally CF_AID.
# For a legacy Global API Key, use CF_API_KEY + CF_EMAIL instead.

npm install
npm run db:migrate:local
npm run dev

Attachments

The dashboard composer accepts up to 10 attachments, with a 10 MB per-file limit and a 20 MB combined limit. Attachment metadata is stored in D1 and file content is stored in the configured BUCKET R2 binding. Apply migration 0008_add_message_attachments.sql before using attachments.

POST /api/v1/send accepts optional JSON attachments:

{
  "from": "support@example.com",
  "to": "user@example.net",
  "subject": "Report",
  "text": "Attached.",
  "attachments": [
    {
      "filename": "report.pdf",
      "type": "application/pdf",
      "contentBase64": "<base64 data>"
    }
  ]
}

Received MIME attachments are extracted automatically. Downloads require access to the mailbox containing the message.

Real-time email notifications

Mailflare uses a Cloudflare Durable Object WebSocket hub to notify connected users immediately after an inbound message is stored. The inbox and unread counts refresh without waiting for polling, and an in-app popup links directly to the new message. Mailbox owners, the domain admin, and delegated users receive events for mailboxes they can access.

The REALTIME Durable Object binding and its initial migration are declared in wrangler.jsonc. Deploy with Wrangler or the Cloudflare Git integration so the Durable Object class is provisioned. When the socket is temporarily unavailable, the client retries automatically and uses a slower fallback refresh until the connection recovers.

Register at /register, complete /onboarding, or seed dev data:

curl -X POST http://localhost:3000/api/seed

Deploy

One-click Cloudflare deploy

Deploy to Cloudflare

Publish this repository to GitHub, then replace hieunc229/mailflare in the button at the top of this README with the public repository path.

The deploy flow reads wrangler.jsonc, provisions the Worker bindings, prompts for values from .dev.vars.example, runs D1 migrations, builds the OpenNext Worker, and deploys it.

Keep wrangler.jsonc committed. Cloudflare's deploy button uses it to detect the Worker entrypoint and required bindings. Do not commit .dev.vars; deploy-time secrets should be entered through Cloudflare's setup flow or set locally in .dev.vars.

Required setup values:

  • CF_TOKEN — runtime scoped Cloudflare API token with Zone Read, Email Routing Edit, Email Sending Edit, and Email Routing Rules Write. This is separate from Cloudflare's deploy/build token; Cloudflare does not automatically expose the deploy token to this app.
  • CF_AID — optional unless your token can access multiple accounts.
  • CF_EMAIL_WORKER_NAME — must match the deployed Worker name. If the one-click deploy flow asks for a different Worker name, enter that same value here.

Dashboard updates

The admin overview includes an Update Mailflare button. POST /api/admin/update dispatches .github/workflows/update.yml in the installation repository. The workflow fetches the latest changes from the default branch of hieunc229/mailflare, merges them into the installation branch, applies pending D1 migrations, and pushes the updated source. It does not build or deploy the Worker.

The dashboard first calls GET /api/admin/update and compares the running app's package.json version with the target repository's version. The update button is enabled only when the target version is newer. Checking never starts a workflow; deployment occurs only after an admin clicks the button.

Configure these Worker runtime secrets/variables once:

  • GITHUB_UPDATE_TOKEN — fine-grained GitHub token scoped to the installation repository with Actions: write permission.
  • GITHUB_UPDATE_REF — optional update branch; the repository default branch is used when omitted.

Configure these GitHub Actions repository secrets:

  • CLOUDFLARE_API_TOKEN — Cloudflare token allowed to read and migrate D1.
  • CLOUDFLARE_ACCOUNT_ID — Cloudflare account ID.
  • MAILFLARE_UPSTREAM_TOKEN — required only when the upstream source repository is private. Store this under the installation repository's Settings → Secrets and variables → Actions → Repository secrets. For a classic PAT, enable the repo scope and ensure the token owner has accepted collaborator access to the upstream repository.

If an installation already contains an older failing updater, manually copy the latest .github/workflows/update.yml into that installation repository once. The updater cannot update its own workflow until it can successfully read the upstream repository.

Optional GitHub Actions repository variables:

  • MAILFLARE_UPSTREAM_REPOSITORY — upstream source in owner/repository format. Defaults to hieunc229/mailflare.
  • MAILFLARE_UPSTREAM_BRANCH — upstream branch to merge. Defaults to main.

The workflow resolves the mailflare D1 database ID at runtime and applies migrations before pushing the updated source. If migration fails, the source is not pushed. A connected Cloudflare Git integration may build and deploy after the successful push.

If you rename the Worker, also update related literal resource names in wrangler.jsonc: name, services[].service for WORKER_SELF_REFERENCE, CF_EMAIL_WORKER_NAME, and any D1/R2/Queue names you want renamed. Cloudflare service bindings require the target Worker name to exist exactly; they cannot currently reference name dynamically.

Worker name and Email Routing

CF_EMAIL_WORKER_NAME is the Worker script name that Mailflare sends to Cloudflare's Email Routing API when creating mailbox routing rules. It must match the deployed Worker name exactly. The app now fails with a clear setup error if this variable is missing instead of silently defaulting to mailflare.

Cloudflare service bindings also require a literal Worker name in wrangler.jsonc (services[].service). Cloudflare's service binding configuration uses service: "<WORKER_NAME>", so WORKER_SELF_REFERENCE cannot automatically inherit the top-level name field. If you rename the Worker during one-click deployment, make sure the deployed Worker name, CF_EMAIL_WORKER_NAME, and services[].service agree.

After deployment, route inbound mail to the Worker in Cloudflare Email Routing.

Troubleshooting

Cloudflare API 403 ... code 9109: Invalid access token

Resolution: The Deploy to Cloudflare flow can authenticate and deploy the Worker, but it does not create a runtime CF_TOKEN for Mailflare's onboarding API calls. Create CF_TOKEN manually from Cloudflare dashboard user API tokens and enter it as a deploy secret/variable.

Verify the token:

curl "https://api.cloudflare.com/client/v4/user/tokens/verify" \
  -H "Authorization: Bearer <CF_TOKEN>"

The response should include "success": true and "status": "active". In .dev.vars or deploy settings, set CF_TOKEN to the token secret value only. Do not include the word Bearer, do not use the token ID, and do not put a Global API Key in CF_TOKEN. For a Global API Key, set both CF_EMAIL and CF_API_KEY instead.

Also check whether the token has an expiration, a not_before time, or client IP restrictions. If you changed deploy variables in Cloudflare, redeploy so the Worker receives the new values.

Cloudflare API 403 on /zones/{zone_id}/email/routing/dns: code 10000

This error indicate API Key is missing some permissions. Update CF_TOKEN minimum permissiongs

For Account: Email Sending:Edit, DNS Settings:Edit, Email Routing Addresses:Edit For Zone: DNS Settings:Edit, Email Routing Rules:Edit, Zone Settings:Edit, DNS:Edit

Manual deploy

npm run deploy builds and deploys the Worker. Cloudflare's deploy button can auto-provision the D1 database, R2 bucket, and queues declared in wrangler.jsonc; for manual deployments, create or update those bindings in Cloudflare if they do not already exist.

The deploy script intentionally builds with OpenNext and uploads with Wrangler. Do not replace the Wrangler step with opennextjs-cloudflare deploy: Mailflare's worker.ts wrapper exports the RealtimeHub Durable Object and handles email and queue events in addition to the generated Next.js worker.

For an existing remote D1 database with a committed database_id, use:

npm run deploy:with-migrations

Cloudflare D1 7404: database could not be found

If automatic deployment fails with:

The database <uuid> could not be found [code: 7404]

remove any committed database_id from wrangler.jsonc. D1 database IDs are account-specific; committing one from another account makes Wrangler look up a database that does not exist in the deploying account. This project declares the DB binding with database_name only so Wrangler/Cloudflare can provision the D1 database for the target account.

Remote migration commands need a concrete D1 database_id in local config. With one-click deploy, Cloudflare provisions the database during deploy, and that generated ID is available in the Cloudflare dashboard, not automatically written back to the repository. After the first successful one-click deploy:

  1. Go to Cloudflare dashboard → Workers & Pages → your Worker → Settings → Bindings.
  2. Open the DB D1 binding and copy the database ID.
  3. Add that ID to wrangler.jsonc only for your deployment/account if you want to run npm run db:migrate:remote or npm run deploy:with-migrations.

Do not commit an account-specific D1 ID to a reusable public template.