Back to Blog
Engineering

I Built My Own Email

August 30, 2026
13 min read
emaildnsrustcloudflareside-projects
I Built My Own Email

I wanted hello@ on a domain I own. Not a personal mailbox, a shared one, the kind where two or three people can all see what came in and any of them can answer it.

Every provider will sell you this. They will also charge you per human, forever, around $6 a month each. Which is a strange thing to pay for, when you think about it. hello@ is not a person. It is a room. I was being billed by the number of people allowed to stand in the room.

So I built it. It runs on free tiers and costs me nothing per month. The interesting part was not the app. The interesting part was discovering that email, a thing I have used every day for twenty years, does not work even slightly the way I assumed it did.

Here is what I learned, in the order it confused me.

Sending and receiving are not the same problem

This was the first surprise, and everything else follows from it.

If you have ever used SendGrid or Resend or Postmark, you have sent email from code. It is a POST request. It took you ten minutes. So you would reasonably assume receiving is the same thing pointed the other way.

It is not. They are opposites.

Sending is a trust problem. You are a stranger knocking on Gmail's door claiming to be someone. Gmail's entire job is to be suspicious of you. Nothing about the mechanics is hard, the hard part is convincing the recipient you are not a scammer. More on that later, it is the single biggest thing nobody tells you.

Receiving is an infrastructure problem. Nobody has to trust you. But you have to be a building. You need a permanent address, a door that is open 24 hours a day, and someone standing behind it. If your server is down for ten minutes, the mail does not queue up politely in a cloud somewhere. The sending server retries for a while and then gives up and bounces it back with an error.

Two problems, two totally different solutions. I ended up using two different companies for the two halves, which felt wrong until I understood why.

DNS is a phone book, and MX is a second listing

To receive mail at you@yourdomain.com, the world needs to know which computer to hand it to. That lives in DNS.

Here is DNS in one sentence: it is a public phone book that turns names into addresses.

The entry you already know about is the A record. It says "the website for yourdomain.com lives at this IP." When someone types your domain into a browser, that is what gets looked up.

An MX record is a completely separate entry in the same phone book, and it means "but the mail for yourdomain.com goes over there instead." MX stands for Mail eXchange.

These two are unrelated. Your website can be on Vercel and your mail can be at Google, and neither knows the other exists. This is why "my website is up but my email is broken" is a sentence that makes sense.

There is one extra wrinkle: MX records have a priority number, like 10 mail1 and 20 mail2. Lower number wins. The higher numbers are backups, tried in order if the first one is not answering. It is a fallback list, and I had never once looked at one.

You cannot actually run a mail server (and you should not try)

So I need something at the end of that MX record. My first instinct: spin up a small VPS, run Postfix, point the MX at it, done.

Reasons that goes badly:

  1. Port 25 is blocked. Nearly every cloud provider blocks the port email runs on by default, because they got tired of being a spam factory. AWS, GCP, DigitalOcean, all of them. You have to file a request and explain yourself.

  2. Your IP has a reputation you did not earn. Cheap cloud IPs get recycled. The person who had yours last may well have been a spammer, and blocklists remember IPs, not people.

  3. You have to never go down. Not "99.9% is fine" never go down. Downtime here means bounced mail, and a bounce is a customer being told your address does not exist.

  4. The whole box becomes security-critical. An SMTP daemon exposed to the entire internet is a genuinely unpleasant thing to be responsible for at 2am.

So I did what you should do: I let someone else be the building. Cloudflare Email Routing receives mail for a domain, for free, with no cap on how many addresses you create. You turn it on, it writes the MX records for you, and it will accept mail at any address on your domain.

It is designed to forward that mail somewhere else. But it has a second mode that is much more interesting.

Email Workers: getting handed the actual envelope

Cloudflare lets you run code when a message arrives. Not "get a webhook about the message." Get handed the message, the whole raw thing, and decide what happens to it.

Stripped down, the entire worker I hand people is this:

export default {
  async email(message, env, ctx) {
    const raw = await readRaw(message);          // the whole letter, as bytes

    const response = await fetch(`${GHAR_API_URL}/v1/ingest/email`, {
      method: "POST",
      headers: {
        "content-type": "message/rfc822",
        "x-ghar-secret": env.INGEST_SECRET,      // proves it's really from you
        "x-ghar-to": message.to,
        "x-ghar-from": message.from,
      },
      body: raw,
    });

    if (!response.ok) {
      message.setReject("Mailbox temporarily unavailable");   // tell the sender to retry
      return;
    }
  },
};

That is the entire inbound pipeline. Cloudflare is the building and the letterbox, my worker is the clerk who picks up the letter and drives it to my API.

The lovely detail is message.setReject(). If my API is down, I do not silently lose the message. I refuse to accept it, and the sending server holds onto it and tries again later. The retry queue I would otherwise have to build already exists. It is just sitting on somebody else's mail server, and it has been there since 1982.

The letter itself is a set of nesting dolls

message.raw gives you an RFC 5322 message, which is where the naive mental model finally breaks. An email is not a sender, a subject and some text. It is a tree.

A single ordinary email from Gmail with a photo attached is roughly:

multipart/mixed
├── multipart/alternative        "here's the same message, twice"
│   ├── text/plain               for old clients
│   └── text/html                for everyone else
└── image/jpeg                   the attachment, base64'd

The multipart/alternative bit is the one that surprised people I described this to. Most email is sent twice, in the same message: once as plain text and once as HTML. Your client picks one and silently throws away the other. That is why forwarding a nicely formatted email sometimes arrives looking like a ransom note. Something in the chain picked the other twin.

I do not parse this by hand. There is a library. But knowing the shape explains a bug that would otherwise be baffling: my parser was helpfully synthesising an HTML part for messages that only ever had plain text. So my UI thought every message was HTML, and rendered plain-text notes with hardcoded near-black text on my app's dark background. Invisible mail. The fix was to only store HTML when a real text/html part actually exists in the tree.

Why your mail lands in spam: three DNS records with terrible names

Now the other half. Sending is easy and getting delivered is hard, and the difference between the two is three DNS records that everyone finds intimidating because of the acronyms. They are genuinely simple.

Imagine your domain is a company and email is physical mail.

SPF is the list of post offices allowed to mail on your behalf. It is a public note saying "only these servers may send mail claiming to be from me." If a spammer sends from their own machine pretending to be you, the receiver checks your list, does not find them, and gets suspicious.

DKIM is a wax seal on the envelope. Your sending server signs each message with a private key. The matching public key is published in your DNS. The receiver re-checks the seal. If it matches, the message really came from you and nobody edited it in transit. This is genuinely just public-key cryptography, wearing an acronym.

DMARC is the instruction card for when the first two fail. It is the one people skip, and it is the important one, because SPF and DKIM by themselves only produce a result, they do not say what to do about it. DMARC says: p=none (do nothing, just tell me), p=quarantine (spam folder it), or p=reject (refuse it outright).

Almost everyone is sitting on p=none, which means they have technically "set up DMARC" and have in practice instructed the world to ignore every failure. If you do one thing to your own domain after reading this, go look at yours.

The good news: a provider like Resend hands you the exact records to paste. You are not inventing anything. You are copying three strings into DNS and then understanding what you pasted, which is the part I actually enjoyed.

Email has no conversations. Everyone is guessing.

This one genuinely changed how I look at my inbox.

Gmail shows you threads. Every mail client shows you threads. I assumed a thread was a thing, an ID stamped on each message, the way Slack has a thread ID.

There is no such thing. It does not exist anywhere in the protocol.

What actually exists is a chain of breadcrumbs. Every message gets a globally unique Message-ID. When you reply, your client is supposed to add two headers:

  • In-Reply-To: the ID of the exact message I am replying to

  • References: the IDs of every message in the chain so far

Threading is your mail client walking those breadcrumbs backwards and drawing the tree itself. Every client does this independently, which is why the same conversation can be one tidy thread for you and four separate emails for the person you are talking to.

And the breadcrumbs break constantly. Plenty of clients, and most automated senders, drop the headers entirely. So every client also has a fallback, and the fallback is: guess from the subject line. Strip the Re: and Fwd: prefixes, and if the rest matches something recent, call it the same conversation.

My version does the same thing, because there is nothing better to do:

  1. Look for References or In-Reply-To pointing at a message I already have. Use that thread.

  2. Otherwise, match on normalised subject, same mailbox, within the last 30 days.

  3. Otherwise, it is a new conversation.

That 30 day window is a made-up number I chose. Every mail client has its own made-up number. Your inbox is held together by heuristics and vibes.

Once you know this, the classic email annoyance makes sense: someone replies to a months-old newsletter to ask you an unrelated question, and it lands buried inside the old thread. The breadcrumb pointed there. The system worked exactly as designed. The design is just a guess.

A folder is not where a message lives

Small design thing, but it took me two tries to get right.

I first put folders on conversations. Archive a thread, the thread moves to Archive. Obvious.

Then I archived a conversation, replied to it, and the reply vanished. Because the reply is a sent message, and the thread lives in archive, and now those two facts are fighting.

The fix is that folders belong to individual messages, not conversations. A conversation shows up in a folder if any message in it is in that folder. So a thread can be in your inbox and your sent folder at the same time, which is not a bug, it is exactly what you want. That reply appears in Sent, and the conversation stays whole in your inbox.

Gmail has worked this way for years and I never noticed. Labels attach to messages. The conversation view is something drawn on top afterwards.

The logo next to the sender name costs $1,100 a year

A fun one to end on.

Some senders get their logo shown next to their name in your inbox. That is BIMI, and I assumed it was something the sending service did, some header on the message.

It is not in the message at all. It is another DNS record. You host a logo as an SVG, you publish the URL in DNS, and mailbox providers go fetch it themselves.

There are three catches, in ascending order of annoyance:

  1. It must be a specific SVG dialect called SVG Tiny Portable/Secure. No scripts, no embedded images, no external references, square viewbox. Reasonable, given they are fetching an arbitrary file from a stranger and rendering it.

  2. It is ignored entirely unless your DMARC is at quarantine or reject. So the majority of domains, sitting on p=none, are not eligible and have no idea.

  3. Gmail wants a certificate. A Verified Mark Certificate runs $1,100 to $1,500 a year and requires a registered trademark. Apple wants one too. Without it your logo shows in Yahoo, AOL and Fastmail, and does not show for most people you actually email.

So I built the whole BIMI flow, validator and all, and then put a paragraph in the UI saying "this will probably not show up in Gmail, here is why." The feature that actually earns its keep is the boring one next to it: an HTML signature appended to every outgoing message. It works in every client, it costs nothing, and nobody needs a trademark.

What it costs

  • Cloudflare Email Routing: free, unlimited inbound

  • Resend: free, 3,000 outbound messages a month

  • Supabase, Render, Vercel: free tiers

  • The domain: I already owned it

Which is the actual punchline. The $6 per user per month was never paying for the hard part. Receiving is free and unlimited from Cloudflare. Sending is free up to a few thousand messages. The expensive part was the assumption that a shared address needs to be priced like a person.

What I would tell myself at the start

Sending and receiving share a name and nothing else. Solve them separately, with different tools. Trying to find one thing that does both is what sends people down the self-hosted mail server path.

Let someone else hold port 25. Being the building is a full-time job with a pager.

The protocol is a pile of conventions. Threading is guesswork. Folders are per-message labels. HTML mail is a second copy of the same message. None of it is as designed as it looks from the inside of Gmail.

Read the RFCs, they are unexpectedly readable. RFC 5322 is email's actual message format, and it is far plainer than you expect. It is the latest revision of RFC 822, from 1982, and you can still feel the shape of the original argument underneath it. Most of what annoys you about email was settled by people who have been retired for a decade.

I got a shared inbox at the end of it, which was the point. But mostly I stopped thinking of email as one thing that works, and started seeing it as about six things that mostly agree with each other, which is a much better model for when it breaks.