Self-hosted email on your own domain, running entirely on Cloudflare's edge.
Receive mail through Cloudflare Email Routing, store it in a D1 database (attachments optionally in R2), send through Brevo, and manage everything from a Vue 3 web app served by the same Worker. One deploy, no servers, free-tier friendly.
π€ Installing with an AI agent (Claude Code, etc.)? Point it at
agent-install.mdβ a step-by-step install guide written for agents.
- Catch-all receiving on your domain β every address at your domain lands in one inbox
- Sending via Brevo's transactional API, with delivery tracking (delivered / opened / bounced) reported back by webhook
- Conversation threading via
Message-ID/In-Reply-Toheaders - Drafts, reply-with-quote, forwarding (original attachments included), and scheduled send (cron-delivered)
- Archive, labels, starring, and full-text search (SQLite FTS5 over subjects, bodies, and addresses)
- Attachments up to 5MB/file in D1, or 25MB/file with an optional R2 bucket
- Contacts with autocomplete in Compose β people you write to are captured automatically
- Per-message
.emldownload and whole-mailbox mbox export - Optional forwarding of received mail to a personal inbox as backup
App
- Vue 3 SPA with full light / dark / system theming
- Dashboard: daily send/receive activity chart, recent messages, system status, storage usage
- Installable PWA with payload-free push notifications on new mail
- Remote-image blocking (tracking-pixel protection) with per-email reveal
Security
- Passkey sign-in (FIDO2/WebAuthn) β Touch ID, Windows Hello, security keys β plus a password fallback
- PBKDF2 password hashing (100k iterations), session tokens stored as SHA-256 hashes
- Received HTML sanitized with DOMPurify before rendering
- Strict Content-Security-Policy and security headers on every response
- Shared rate limit for password and passkey login attempts
- Server-side attachment validation, safe download headers, sanitized filenames
Cloudflare Edge
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β Email Routing βββΆ Email Worker βββ¬ββΆ D1 (mail, contacts, β
β (catch-all) β settings, sessions, β
β β FTS index) β
β Cron (*/5) βββΆ scheduled send ββββ€ β
β βββΆ R2 (attachments, β
β HTTPS βββΆ Worker (Hono API) ββββββ optional) β
β β β
β βββΆ Vue 3 SPA (static assets) β
β β
ββββββββββββββββ¬ββββββββββββββββββββββββββββββ¬ββββββββββββββββββββ
β send β delivery webhooks
βΌ β
Brevo βββββββββββββββββββββββββββ
Browser βββ Web Push (VAPID, no payload) ββ Email Worker
Prerequisites: a Cloudflare account with a domain, a Brevo account (free tier is fine), Node.js 18+, and pnpm.
# 1. Clone and install
git clone https://github.com/MrOplus/AvaMail.git
cd AvaMail
pnpm install
# 2. Authenticate wrangler
npx wrangler login
# 3. Create the database
cd packages/worker
npx wrangler d1 create avamail-db # note the database_id in the output
# 4. Configure
cp wrangler.toml.example wrangler.toml # then paste your database_id into it
# 5. Initialize the schema (fresh installs only β see Upgrading otherwise)
npx wrangler d1 execute avamail-db --remote --file=./src/db/schema.sql
# 6. Build and deploy
cd ../..
pnpm -r build
cd packages/worker
npx wrangler deployYour app is now live at https://avamail.<your-subdomain>.workers.dev.
-
Open the URL and create your admin password. Add a passkey under Settings β Security for passwordless sign-in.
-
Settings β Cloudflare β create an API token (dash.cloudflare.com β API Tokens β Create Custom Token) with these permissions, then enter it with your Account ID and domain:
Scope Permission Access Account Email Routing Addresses Edit Zone Zone Read Zone DNS Edit Zone Email Routing Rules Edit Zone Zone Settings Edit Saving enables Email Routing on your zone and points the catch-all rule at the worker. If inbound mail ever stops, the Fix Email Routing button re-applies the rule.
-
Settings β Brevo β paste your Brevo API key (app.brevo.com β SMTP & API). Saving also auto-registers the delivery-status webhook.
-
Verify your sending domain in Brevo (Senders & Domains) β add the SPF / DKIM / DMARC records it gives you, or outbound mail will be rejected or spam-foldered.
-
Settings β Addresses β add at least one From address.
-
Click Complete Setup, then test both directions: mail an external message to
anything@yourdomain.comand send one out via Compose.
wrangler.toml (gitignored; copy from wrangler.toml.example):
name = "avamail" # worker name β also the catch-all rule target
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "avamail-db"
database_id = "YOUR_DATABASE_ID_HERE"
[assets]
directory = "../../frontend/dist"
binding = "ASSETS" # SPA deep links (/inbox, /login, β¦)
run_worker_first = true # security headers on every response
# Optional β see "R2 attachments" below
# [[r2_buckets]]
# binding = "ATTACHMENTS"
# bucket_name = "avamail-attachments"
[triggers]
crons = ["*/5 * * * *"] # scheduled send + session cleanup
[vars]
APP_NAME = "AvaMail"Secrets (Cloudflare token, Brevo key) are never placed in config files β they're entered through the app UI and stored in D1.
Without R2, attachments live base64-inline in D1 (5MB per file). To raise the cap to 25MB and keep the database lean:
- Enable R2 once in the Cloudflare dashboard (the API errors with code
10042until you do). npx wrangler r2 bucket create avamail-attachments- Uncomment the
[[r2_buckets]]block inwrangler.tomland redeploy.
Existing inline attachments keep working β reads fall back to D1 automatically.
Fresh installs use schema.sql only. Existing databases apply what they're
missing, each file exactly once:
cd packages/worker
# pre-passkey installs first bring the base schema up to date:
npx wrangler d1 execute avamail-db --remote --file=./src/db/schema.sql
# then the structural migration (adds threading/drafts/FTS/etc. β run ONCE):
npx wrangler d1 execute avamail-db --remote --file=./src/db/migrations/002-features.sqlUnsure which generation you're on? agent-install.md has a decision table keyed off
which tables exist. After migrating: pnpm -r build and npx wrangler deploy.
Upgrading from a pre-passkey version signs everyone out once (session tokens are now stored hashed) β just log in again.
# Terminal 1 β worker + local D1 on :8787
cd packages/worker
npx wrangler d1 execute avamail-db --local --file=./src/db/schema.sql
npx wrangler dev
# Terminal 2 β Vite dev server with HMR on :5173 (proxies /api to :8787)
cd frontend
pnpm run devTest the cron locally: start with npx wrangler dev --test-scheduled, then
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*".
AvaMail/
βββ frontend/ # Vue 3 SPA (Vite, Tailwind, Pinia)
β βββ public/ # PWA manifest, service worker, theme bootstrap
β βββ src/
β βββ api/ # typed API client
β βββ components/ # EmailList, ActivityChart
β βββ stores/ # auth, emails, theme
β βββ utils/ # avatar helpers
β βββ views/ # Dashboard, Inbox, EmailView, Compose,
β # Drafts, Archive, Contacts, Settings, β¦
βββ packages/
β βββ worker/ # Cloudflare Worker (Hono)
β β βββ src/
β β βββ api/ # routes: auth, webauthn, emails, contacts,
β β β # push, webhooks, settings, setup
β β βββ db/ # schema.sql, migrations/, queries.ts
β β βββ lib/ # mailer, attachments (R2/D1), push (VAPID),
β β β # eml builder, rate limiting, validation
β β βββ email-handler.ts # inbound mail: parse, thread, store, notify
β β βββ index.ts # fetch + email + scheduled entrypoints
β βββ brevo-api/ # Brevo REST wrapper (send, senders, webhooks)
β βββ cloudflare-email-api/ # Cloudflare REST wrapper (zones, routing, DNS)
βββ agent-install.md # agent-oriented install guide
All endpoints are under /api. Everything except login/setup ceremonies and
the Brevo webhook requires Authorization: Bearer <token>.
| Endpoint | Method | Description |
|---|---|---|
/auth/status |
GET | Auth state, needsSetup, hasPasskeys |
/auth/setup |
POST | Create the admin password (first run) |
/auth/login |
POST | Password login (rate limited: 5 / 15 min) |
/auth/logout |
POST | Invalidate the session |
/auth/change-password |
POST | Change password |
/auth/webauthn/register/options Β· /verify |
POST | Passkey registration ceremony |
/auth/webauthn/login/options Β· /verify |
POST | Passkey login ceremony (shares the login rate limit) |
/auth/webauthn/credentials |
GET | List passkeys |
/auth/webauthn/credentials/:id |
DELETE | Remove a passkey |
| Endpoint | Method | Description |
|---|---|---|
/emails |
GET | List (type=received|sent|draft|scheduled, archived, label, unread, starred, limit, offset) |
/emails/search |
GET | Full-text search (?q=) |
/emails/labels |
GET | All labels in use |
/emails/stats |
GET | Counts + storage usage |
/emails/activity |
GET | Daily send/receive counts (?days=14) |
/emails/export/mbox |
GET | Whole-mailbox mbox download |
/emails/drafts |
POST | Create or update a draft |
/emails/send |
POST | Send β supports draftId, scheduleAt, replyToEmailId, forwardOfEmailId, attachments |
/emails/:id |
GET | Single email with attachments + conversation thread |
/emails/:id/eml |
GET | Download as .eml |
/emails/:id/read Β· /unread Β· /star |
POST | Flags |
/emails/:id/archive Β· /unarchive |
POST | Archive state |
/emails/:id/labels |
PUT | Replace labels |
/emails/:id |
DELETE | Delete email + attachments (D1 and R2) |
/emails/:id/attachments/:attachmentId |
GET | Download an attachment |
| Endpoint | Method | Description |
|---|---|---|
/contacts |
GET / POST | List / create (upserts by email) |
/contacts/:id |
PUT / DELETE | Update / delete |
/push/vapid-key |
GET | VAPID public key |
/push/subscribe Β· /unsubscribe |
POST | Manage push subscriptions |
/webhooks/brevo/:secret |
POST | Brevo delivery events (auto-registered; secret-guarded) |
/setup/status |
GET | Configuration state |
/setup/cloudflare |
POST | Connect Cloudflare + enable routing |
/setup/cloudflare/worker-routing |
POST | Re-point the catch-all at the worker |
/setup/brevo |
POST | Connect Brevo + register webhook |
/setup/addresses |
GET / POST / DELETE | Manage From addresses |
/setup/complete |
POST | Finish onboarding |
| Symptom | Fix |
|---|---|
| "Frontend assets not found" | Build before deploying: pnpm -r build, then redeploy |
| Inbound mail not arriving | Settings β Cloudflare β Fix Email Routing; verify MX records and that the zone is active; npx wrangler tail to watch the worker |
| Outbound rejected / in spam | Verify the sending domain (SPF/DKIM/DMARC) in Brevo |
| No delivery badges on sent mail | Re-save the Brevo API key in Settings to (re)register the webhook |
| 429 on login | Rate limit β wait 15 minutes (setup: 3/hour) |
| Signed out after upgrading | Expected once (hashed session tokens) β sign in again |
| Passkey missing on a new domain | Passkeys bind to the hostname; add one on the new host |
| Scheduled email shows "failed" | Fix the Brevo config; the message is preserved under Drafts β edit and resend |
MIT β see LICENSE.
Issues and feature requests: GitHub Issues.