I run my own webmail at /mail on this site, and it doesn't involve running a mail server. The hard, adversarial parts of email (accepting SMTP from the open internet, dealing with abuse, keeping a sending IP's reputation clean) are handled by services that already do them well. What's left is small enough to own: a Cloudflare Email Worker receives incoming mail and posts it to my Next.js app, Brevo delivers what I send, messages live as JSON files on disk, and the whole thing runs in one Docker container on a Hetzner VPS behind Cloudflare. It has exactly one user: me.
In this post I go through how those pieces fit together and where the simple choices started to show their limits. The snippets are trimmed to show the shape of each part; they're not the full implementation. Secrets and server details are left out on purpose.
Why only one user?
The honest answer is control and curiosity, not cost. A managed inbox is cheaper in hours than anything you build yourself. What building gives me is a client that works the way I want, an archive I can see on disk, and no features I didn't ask for.
The decision that keeps the project manageable is scoping it to one user. As soon as a mail system serves more than one person, it needs an account model, per-account authentication, tenant isolation so one user can't read another's mail, quotas, and an admin surface for all of that. In a multi-tenant product that's most of the work and most of the risk. With "one user, forever", none of it exists. I can connect external Gmail or Outlook accounts, but those are mailboxes I own, not other users of the app. The security question becomes "is this the owner or not?"
It's the same instinct behind the feature, core and data module boundaries I wrote about for Android: the cheapest code to maintain is the code you never had to write.
The architecture at a glance
Mail flows in two directions, and I treat them as two separate pipelines that share a store.
Inbound: a message to any address on my domains reaches Cloudflare Email Routing. A catch-all rule hands it to an Email Worker, and the Worker posts the raw message to an endpoint in my Next.js app, which parses it and writes it to the store. Because of the catch-all, a new address works the moment someone writes to it; the app discovers it and gives it its own inbox.
Outbound: when I send from the UI, the app builds the MIME message and submits it to Brevo over authenticated SMTP. Brevo does the actual delivery and carries the sender reputation.
The store, the UI and the endpoints all live in one Next.js application in one container. There's no database server, no message queue and no mail daemon. Keeping that surface small is the point.
Inbound mail: a Cloudflare Email Worker
Email Routing can send a domain's mail to a Worker instead of a mailbox. The Worker exports an email() handler that receives each message with its envelope from and to, plus a raw property: a ReadableStream of the full MIME content. Mine does two things. It forwards a copy to a regular mailbox I already had, then posts the raw bytes to the app:
export default {
async email(message, env, ctx) {
// Safety copy: even if my app is down, the mail lands in a normal mailbox.
let forward = "ok";
try {
await message.forward(env.FALLBACK_TO);
} catch (e) {
forward = `err:${e.message}`;
}
// raw is a single-use ReadableStream of the full MIME message.
const mime = await new Response(message.raw).arrayBuffer();
const res = await fetch(env.INBOUND_URL, {
method: "POST",
headers: {
authorization: `Bearer ${env.INBOUND_TOKEN}`,
"content-type": "message/rfc822",
"x-mak-to": message.to,
"x-mak-forward": forward,
},
body: mime,
});
if (!res.ok) console.log("inbound POST failed", res.status);
},
};
The forward is a safety net: if the app is down or a deploy goes wrong, the mail still lands somewhere. forward() only works for destination addresses you've verified in Email Routing. The Worker reports the result in a header, so a failed forward shows up in my app's logs.
The raw stream can only be read once, so I buffer it before posting. The endpoint is reachable from the internet, so it has to know the request really came from my Worker. Here that's a shared bearer token, stored as a Worker secret on one side and in the server's environment on the other, and compared in constant time. Knowing the URL gives you nothing.
The app side is an App Router route handler. Route handlers use the Web Request/Response APIs, and for a raw MIME body I read bytes instead of calling request.json() or request.text():
// app/api/mail/inbound/route.ts (simplified)
import { simpleParser } from "mailparser";
export async function POST(req: Request) {
if (!isTrustedWorker(req)) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
if (!rateOk()) {
return Response.json({ error: "rate limited" }, { status: 429 });
}
if (!diskOk()) {
return Response.json({ error: "insufficient storage" }, { status: 507 });
}
// Bytes, not text(): a MIME message isn't guaranteed to be valid UTF-8.
const raw = Buffer.from(await req.arrayBuffer());
if (raw.length > MAX_BODY) {
return Response.json({ error: "too large" }, { status: 413 });
}
const parsed = await simpleParser(raw);
const address = addressOf(req.headers.get("x-mak-to") ?? "");
saveMessage(toMailMessage(address, parsed), attachmentsOf(parsed));
return Response.json({ ok: true });
}
Even with a valid token, the endpoint rate-limits deliveries, caps message and attachment sizes, and refuses new mail with a 507 when free disk space drops below a threshold. A leaked token or a forwarding loop shouldn't be able to fill the disk.
Parsing is where email shows how messy it really is: nested multipart bodies, charset declarations that don't match the content, attachments encoded in different ways. I use mailparser instead of writing my own parser. This is one place where rolling your own is a trap.
Outbound mail: Brevo
Sending is the half people underestimate. Whether a message reaches a stranger's inbox instead of their spam folder depends on SPF, DKIM and DMARC alignment and on the sending IP's history. A fresh VPS IP has no history, and Hetzner blocks outbound port 25 on its cloud servers by default anyway.
So the app doesn't deliver mail itself. It builds the message with nodemailer and submits it to Brevo's SMTP relay on port 587 with STARTTLS. Brevo signs it with DKIM for my domain (after domain authentication, the DKIM records live in Cloudflare DNS) and delivers it from IPs that already have a reputation. One Brevo account sends for all my domains.
The exception is mail sent from a connected Gmail or Microsoft account. That goes out through the Gmail API or Microsoft Graph from that account, so it ends up in that account's Sent folder as usual. If the account has been disconnected, a scheduled message fails instead of quietly going out through Brevo under a different identity.
The message store: JSON files on disk
One person's mail isn't big data. My whole store is still under a gigabyte, and most of that is attachments. Each message is one JSON file in a directory per address, and its attachments are separate files in a directory of their own. Custom folders and labels are fields on the message and rules live in settings; only trash, drafts and scheduled mail get directories of their own. Every write goes to a temp file that is then renamed into place, so a crash never leaves a half-written message. There's no database server to run, patch or back up separately.
That simplicity had a cost that showed up as the store grew. To render a list, the app needs the metadata of every message in a mailbox. The first version parsed every file on a cold start, and again after any write, because it keyed its cache on the directory's modification time. Once synced external accounts pushed the store to a few thousand messages (a couple of hundred megabytes of JSON), a cold list took about five seconds on the VPS.
The fix stayed file-based. A per-file cache keyed on modification time, size and inode means an unchanged file is never parsed twice. On top of that, a derived index is persisted in a .cache directory; every entry is checked against its file's signature, and a stale or corrupt index is simply ignored. The message files remain the source of truth. A cold load now takes around 200 ms, and Next.js's instrumentation.ts hook warms the cache in the background after a restart.
Search is a plain scan over metadata and decoded body text, with a few operators like from:, subject:, has:attachment and date ranges. For one person's mail that's fast enough without a separate search index. Concurrent writers would need coordination, and none of this would survive a thousand mailboxes. It doesn't have to.
The UI: Next.js App Router
The mail client is an interactive client component, MailApp. Lists, reading, composing, selection and keyboard navigation run in the browser, while authenticated route handlers own storage and provider calls. List summaries are fetched separately from message bodies, which load on demand. Provider secrets and filesystem access never reach the client bundle.
Incoming HTML is untrusted. The server cleans it with an allowlist sanitizer (sanitize-html) before it reaches the browser, and remote images are blocked by default so tracking pixels don't fire until I choose to load them. On /mail pages, a Content Security Policy with a per-request nonce is a second line of defense in case something gets past the sanitizer.
The app is also an installable PWA: a service worker caches the shell, an open tab gets live updates over server-sent events, and new inbound mail can trigger a Web Push notification.
Login
One user still needs a lock on the door. Login is a rate-limited password step plus optional two-factor authentication: TOTP or a passkey (WebAuthn), with one-time recovery codes as a fallback. Sessions are HMAC-signed, HttpOnly cookies. The signature includes a server-side session epoch, so "sign out everywhere" just increments that number and every existing session stops validating. In Next.js 16, proxy.ts (the renamed middleware.ts) checks the session and CSRF on /api/mail requests before they reach a handler. The only exceptions are login, the OAuth callbacks and the inbound endpoint, which has its own token check.
Running it: Docker on a VPS behind Cloudflare
Deployment is deliberately boring. Next.js builds with output: "standalone", and a deploy script runs a dependency audit gate, builds locally, syncs the standalone folder to the VPS with rsync and recreates the container. The container uses the stock node:22-alpine image with the app mounted read-only and the data directory mounted separately. A simplified version of the compose file:
services:
web:
image: node:22-alpine
working_dir: /app
command: node server.js
user: "1000:1000"
env_file: .env
volumes:
- ./app:/app:ro # Next.js standalone output, read-only
- ./data:/data # mail store and other state
tmpfs:
- /app/.next/cache # writable cache on top of the read-only app
ports:
- "127.0.0.1:3000:3000" # loopback only; the host's reverse proxy reaches it
mem_limit: 256m
security_opt:
- no-new-privileges:true
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
Binding the published port to 127.0.0.1 keeps the container off the public internet. A reverse proxy on the host forwards to it, and Cloudflare sits in front of that. Since the data has its own mount, a deploy replaces the app folder and never touches the mail. After the container comes up, the script checks /api/health and smoke-tests the public routes. It's the same trust-boundary thinking I apply on Android in SecureCheck: decide where the boundary is, then make sure nothing crosses it by accident.
Backups
With one user there's no second admin and no support desk. If I lock myself out or lose the store, nobody restores it for me, so backups got more care than any visible feature.
Because the store is just files, a backup is a tar archive. Each archive is encrypted with age on the server using only a public recipient key; the private key isn't on the server. The server keeps 7 daily, 4 weekly, 6 monthly and 3 yearly snapshots, and my Mac regularly pulls an off-site copy and holds the private key. A restore drill decrypts an archive into a temporary directory and checks file counts and hashes. What's still on my list is running that drill from a different device, because a restore tested on the same machine that holds the key proves less than it seems.
The trade-offs of one user
What I gain: no account system, no tenant isolation, no per-user configuration, no admin console, a small data model and a security model I can keep in my head. What I give up: anything that assumes more than one person. No sharing, no delegation, no "add a colleague". Scaling means a bigger machine, and for one mailbox that limit is far away. The real risk is the one from the backups section: a single owner is also the single person responsible for recovery.
What I learned
Most of email's difficulty is social and adversarial rather than technical. Parsing MIME is fiddly but finite. Earning the right to deliver mail to strangers is a long reputation game, and the sensible move is not to play it: rent delivery and inbound routing, and own the client and the archive.
The second lesson is that constraints pay off. "One user, forever" removed more code and risk than any abstraction could have. It's the same reason I pull cross-cutting concerns out of business logic with Aspectify: the less there is to maintain, the easier it is to maintain. Simple choices still have limits, though. The file store needed an index once real data piled up, and adding it was a small, local change.