<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabe</title>
    <description>The latest articles on DEV Community by Gabe (@fijiwebdesign).</description>
    <link>https://dev.to/fijiwebdesign</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F407530%2Fb070bb34-65c2-4c05-81fe-902d376f9a6a.jpeg</url>
      <title>DEV Community: Gabe</title>
      <link>https://dev.to/fijiwebdesign</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fijiwebdesign"/>
    <language>en</language>
    <item>
      <title>Receiving email is the part nobody warns you about</title>
      <dc:creator>Gabe</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:47:24 +0000</pubDate>
      <link>https://dev.to/fijiwebdesign/receiving-email-is-the-part-nobody-warns-you-about-jp6</link>
      <guid>https://dev.to/fijiwebdesign/receiving-email-is-the-part-nobody-warns-you-about-jp6</guid>
      <description>&lt;p&gt;If you're wiring email into an app for the first time, often to give an agent its own inbox, you just want the contents of the message and instead you hit MIME. The moment a feature needs the &lt;em&gt;other&lt;/em&gt; direction (a reply that reopens a support ticket, an inbound attachment to process, an "email us your receipt" address, an agent that reads its own mail) you leave the paved road: you inherit arbitrary MIME produced by thirty years of mail clients that agree on nothing, and most providers hand you that swamp as an afterthought bolted onto their send product. Here is the full journey one received email takes before your app can touch it, and every stage in bold is work somebody has to own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Who owns it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sender's MTA&lt;/td&gt;
&lt;td&gt;opens an SMTP :25 connection&lt;/td&gt;
&lt;td&gt;the sender&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;your MX edge&lt;/td&gt;
&lt;td&gt;mail lands on your domain&lt;/td&gt;
&lt;td&gt;you (or your provider)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;parse the MIME tree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;nested multipart, mixed encodings&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the hard part&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;authenticate the sender&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SPF / DKIM / DMARC vs a forged &lt;code&gt;From:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the hard part&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;filter spam&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;score it, or drown in it&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the hard part&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;deliver the webhook&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HMAC-sign, then retry on failure&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the hard part&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;your app&lt;/td&gt;
&lt;td&gt;the contents of the email, at last&lt;/td&gt;
&lt;td&gt;you&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Every bold stage is work the options below make you own, in some combination. MailKite collapses them into one signed JSON webhook.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One inbound email's journey, top to bottom. The four bold stages (parse the MIME tree, authenticate the sender, filter spam, and deliver a signed and retried webhook) are the work every option below makes you own in some combination.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That whole stack collapses into one signed JSON webhook. Here's the entire message your app receives — already parsed, decoded, and authenticated — before any of the plumbing that gets there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_2Hk9…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ada@example.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support@myapp.ai"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Re: invoice #1042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Looks good — approved!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Looks good — approved!&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"threadId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;a1b2c3@mail.example.com&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dkim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dmarc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spam"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ham"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attachments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"po.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"contentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18213&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.mailkite.dev/att/2Hk9…?exp=…&amp;amp;sig=…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No MIME tree, no inline base64, no charset to guess, and the sender's SPF/DKIM/DMARC verdict is already attached. The rest of this post is &lt;em&gt;why&lt;/em&gt; every other option makes you own those stages yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why inbound is the hard direction
&lt;/h2&gt;

&lt;p&gt;When you &lt;em&gt;send&lt;/em&gt;, you control the message. You build clean data, hand it to a library, and it produces well-formed MIME. When you &lt;em&gt;receive&lt;/em&gt;, you inherit whatever twenty years of email clients, marketing tools, and someone's Exchange server from 2009 decided to emit. You don't get to reject it for being ugly. You have to parse it.&lt;/p&gt;

&lt;p&gt;And real-world MIME is genuinely nasty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a tree, not a string.&lt;/strong&gt; A single email is &lt;code&gt;multipart/alternative&lt;/code&gt; (text + HTML) wrapped in &lt;code&gt;multipart/mixed&lt;/code&gt; (body + attachments), sometimes wrapped again for signatures. You walk the tree to find "the body."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encodings everywhere.&lt;/strong&gt; &lt;code&gt;quoted-printable&lt;/code&gt;, &lt;code&gt;base64&lt;/code&gt;, and charset labels that lie. The classic symptom: a &lt;code&gt;£&lt;/code&gt; or an emoji turns into &lt;code&gt;Â£&lt;/code&gt; because something decoded latin-1 bytes as UTF-8. Getting every part &lt;a href="https://mailkite.dev/blog/email-attachments-without-corruption/" rel="noopener noreferrer"&gt;through decoding without corrupting the &lt;code&gt;£&lt;/code&gt;&lt;/a&gt; is fiddlier than it looks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attachments are inline base64&lt;/strong&gt; stuffed into the same payload, so a 10 MB PDF becomes a ~13 MB string you now have to pull apart, decode, and store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headers are trivially forged.&lt;/strong&gt; &lt;code&gt;From:&lt;/code&gt; is just text. Anyone can put anything there. Trust it and you've built a spoofing hole. You need SPF/DKIM/DMARC results to know if the sender is real, and computing those is its own project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That first point is the one people underestimate. Here is what a single "plain" email actually is on the wire: a nested tree you walk to find one leaf.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;MIME part&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;root&lt;/td&gt;
&lt;td&gt;&lt;code&gt;multipart/mixed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the envelope around everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;├─&lt;/td&gt;
&lt;td&gt;&lt;code&gt;multipart/alternative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same body, two formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;│  ├─&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text/plain&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;the body you actually want&lt;/strong&gt; (the leaf you walk the tree to find)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;│  └─&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text/html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same body, as markup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;└─&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/pdf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;base64 attachment, inline in the payload (a ~13 MB string)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;A single "plain" email is a nested MIME tree. The body is one leaf you locate by walking it; the attachment rides inline as base64. Parsing this correctly, for every odd client that ever emitted mail, is the work an inbound API does so you don't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We know precisely how nasty this gets, because we &lt;a href="https://mailkite.dev/blog/byte-identical-mime-failures/" rel="noopener noreferrer"&gt;wrote the same MIME parser three times, in three languages, and the hard part was making them fail identically&lt;/a&gt;. None of this is your app's problem. It's plumbing. But it sits directly between you and the one thing you wanted: &lt;em&gt;the contents of the email.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This isn't a niche gripe, either. The founder of Mailgun once described building the product specifically to &lt;em&gt;eliminate MIME&lt;/em&gt; for developers, because of how much malformed, broken MIME arrives over SMTP. When the people who ran a mail company for a living call the format the enemy, believe them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incumbents each punt in a different way
&lt;/h2&gt;

&lt;p&gt;Every existing option leaves you holding some version of the bag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SendGrid Inbound Parse&lt;/strong&gt; posts you &lt;code&gt;multipart/form-data&lt;/code&gt;, a form upload, not JSON. You run a form parser, pull headers back out of string fields, and handle attachments as file parts. Developers hit the same walls repeatedly: undocumented payload shapes you reverse-engineer with test emails, attachments arriving corrupt or mislabeled, and the &lt;code&gt;£&lt;/code&gt;-becomes-garbage encoding bug as a rite of passage. Worst of all, if your endpoint returns a 4xx or has a DNS hiccup, it drops the email &lt;strong&gt;immediately, with no retry.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mailgun Routes&lt;/strong&gt; exists &lt;em&gt;because&lt;/em&gt; MIME is awful, but now you maintain a rule engine: &lt;code&gt;match_recipient("support@myapp\.ai")&lt;/code&gt;, expression syntax you keep in sync with your app. Inbound also moved behind a paywall, and its convenience fields (like stripped body text) have a habit of quietly eating real message content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Email Routing&lt;/strong&gt; is a great edge and free, but there's no native parse-to-webhook. You write an Email Worker and parse the raw MIME &lt;em&gt;yourself&lt;/em&gt;, inside a CPU budget that a big base64 attachment will blow through, against a 25 MB limit. It's a fine trigger/router; it is not a "parsed message as JSON" API. And it was built to forward, not to let your app reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend and other send-first tools&lt;/strong&gt; added inbound, but the webhook is typically &lt;strong&gt;metadata-only&lt;/strong&gt;: you get an event, then make a &lt;em&gt;second&lt;/em&gt; API call to fetch the body and attachments. Two round-trips to read one email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosting&lt;/strong&gt; Postfix or Haraka lets you parse everything exactly how you want. You'll also inherit deliverability as a full-time job. Fresh VPS IPs start life on blocklists, and even flawless SPF/DKIM/DMARC lands you in spam often enough that the consensus from people who've done it is simply: don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice the shape. Every option makes you own the MIME parsing, own the retry/reliability story, make a second call, or own IP reputation, usually more than one. The "just receive an email" task quietly becomes: pick a lesser-evil vendor, learn its wrapper, re-implement decoding for the parts it botched, compute your own auth results, and figure out attachment storage. For a checkbox on a feature list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it should look like instead: one webhook, the whole message
&lt;/h2&gt;

&lt;p&gt;We built MailKite's inbound as a first-class product, not a bolt-on. When mail arrives at any address on your domain, we parse the entire MIME tree at the edge (the same work you'd do by hand if you were &lt;a href="https://mailkite.dev/blog/parse-inbound-email-to-json-node/" rel="noopener noreferrer"&gt;parsing inbound email to JSON in Node&lt;/a&gt;) and POST you &lt;strong&gt;one webhook with the whole message already extracted as JSON:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg_2Hk9…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email.received"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ada@example.com"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support@myapp.ai"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Re: invoice #1042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Looks good — approved!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Looks good — approved!&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"threadId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;a1b2c3@mail.example.com&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spf"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dkim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dmarc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spam"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ham"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attachments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"filename"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"po.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"contentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18213&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.mailkite.dev/att/2Hk9…?exp=…&amp;amp;sig=…"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; there: no MIME tree, no inline base64 blob, no charset guessing. &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;html&lt;/code&gt; are already decoded, the &lt;code&gt;£&lt;/code&gt; is a &lt;code&gt;£&lt;/code&gt;. Attachments are pulled out and handed to you as a short-lived signed &lt;code&gt;url&lt;/code&gt; you fetch on demand, so a 13 MB PDF never rides along in your webhook body. Threading is resolved. And &lt;code&gt;auth&lt;/code&gt; tells you up front whether SPF, DKIM, and DMARC passed.&lt;/p&gt;

&lt;p&gt;That last field matters more than it looks. The moment your app &lt;em&gt;does something&lt;/em&gt; with an inbound email (files a ticket, sends a reply, hands it to an agent), a forged &lt;code&gt;From:&lt;/code&gt; becomes an authorization decision. Having &lt;code&gt;auth&lt;/code&gt; in the payload means you decide how much to trust the sender without computing it yourself or trusting blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receiving your first email
&lt;/h2&gt;

&lt;p&gt;Point a domain at MailKite (add the MX record, verify), set a webhook URL, and inbound mail to any address on that domain gets parsed and POSTed to you. The whole handler is: verify the signature, then read the fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MailKite&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mailkite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAILKITE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Capture the RAW body — verify the exact bytes, not a re-serialized object.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/hooks/mailkite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/hooks/mailkite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Recomputes the HMAC, compares in constant time, and rejects anything&lt;/span&gt;
  &lt;span class="c1"&gt;// outside the ±5-minute replay window.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-mailkite-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;MailKite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email.received&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;·&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// event.text / event.html are already decoded.&lt;/span&gt;
    &lt;span class="c1"&gt;// …create a ticket, reply, store it, hand it to an agent.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ack fast; do the heavy work out of band&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Two traps that cost real debugging hours.&lt;/strong&gt; Verify the HMAC against the &lt;strong&gt;raw request bytes&lt;/strong&gt;: round-tripping the JSON through &lt;code&gt;parse&lt;/code&gt; and re-serialize changes the bytes and the signature never matches again. And &lt;strong&gt;ack fast&lt;/strong&gt;: return &lt;code&gt;200&lt;/code&gt; first, then do the slow work in a queue, or the sender's retry hands you a duplicate because your handler took nine seconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;a href="https://mailkite.dev/blog/verify-inbound-webhooks-hmac/" rel="noopener noreferrer"&gt;full HMAC verification walkthrough&lt;/a&gt; covers the raw-bytes trap in depth. The same handler exists for Python, Ruby, Go, PHP, and Java; see the &lt;a href="https://mailkite.dev/docs/receiving" rel="noopener noreferrer"&gt;receiving docs&lt;/a&gt; and &lt;a href="https://mailkite.dev/docs/webhook-security" rel="noopener noreferrer"&gt;webhook security&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All of this runs on the free tier across unlimited domains: 5,000 messages a month with inbound and outbound sharing one quota, 200 emails/day, automatic retries, HMAC-signed payloads, and metered overage instead of a hard cutoff. That's enough to give every side project its own inbox before anything is riding on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the hardest part of receiving email programmatically?&lt;/strong&gt;&lt;br&gt;
Parsing real-world MIME: emails are nested multipart trees with mixed encodings (quoted-printable, base64) and inline attachments, plus forgeable headers. Decoding all of that correctly, and computing SPF/DKIM/DMARC to know if the sender is genuine, is the work that surprises people. A good inbound API does it for you and hands you decoded &lt;code&gt;text&lt;/code&gt;/&lt;code&gt;html&lt;/code&gt; and an &lt;code&gt;auth&lt;/code&gt; result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is SendGrid Inbound Parse the same as this?&lt;/strong&gt;&lt;br&gt;
It's the closest incumbent, but it POSTs multipart form data, is known to mangle certain encodings and attachments, and drops the message with no retry if your endpoint returns an error. Here the message arrives already fully parsed as JSON, and failed deliveries retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop someone from spoofing an inbound email?&lt;/strong&gt;&lt;br&gt;
Don't trust the &lt;code&gt;From:&lt;/code&gt; header, it's plain text. Use the SPF/DKIM/DMARC results in the &lt;code&gt;auth&lt;/code&gt; object to decide how much to trust the sender, and always verify the webhook signature so you know the request genuinely came from your provider and not an attacker POSTing to your endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do attachments come inline in the webhook?&lt;/strong&gt;&lt;br&gt;
Not by default: a large base64 attachment inline bloats every webhook and can blow request-size limits. Each attachment arrives as a short-lived signed &lt;code&gt;url&lt;/code&gt; you fetch on demand. (Zero-retention and encrypted domains are the exception: those receive attachment &lt;code&gt;content&lt;/code&gt; inline as base64, since there's nothing stored to link to.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Cloudflare Email Routing do this?&lt;/strong&gt;&lt;br&gt;
It gives you the raw message and leaves parsing to you inside a CPU-limited Worker, exactly where a big attachment will exhaust your budget. It's a fine trigger/router; it is not a "parsed message as JSON" API, and it can't reply from your domain.&lt;/p&gt;




&lt;p&gt;Sending was never the hard part. Receiving is, and it's the direction behind support inboxes, reply-by-email, and agents that read their own mail. &lt;a href="https://app.mailkite.dev" rel="noopener noreferrer"&gt;Point a domain at MailKite&lt;/a&gt; and receive your first parsed email in a few minutes.&lt;/p&gt;

&lt;p&gt;Four deep dives sit under the hard parts above, one per stage of the pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mailkite.dev/blog/parse-inbound-email-to-json-node/" rel="noopener noreferrer"&gt;Parse inbound email to JSON in Node&lt;/a&gt;&lt;/strong&gt; — Walk the MIME tree by hand: decode every part, find the body, and hand your app clean JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mailkite.dev/blog/verify-inbound-webhooks-hmac/" rel="noopener noreferrer"&gt;Verify inbound webhooks with HMAC&lt;/a&gt;&lt;/strong&gt; — Recompute the signature over the raw bytes, compare in constant time, and reject replays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mailkite.dev/blog/email-attachments-without-corruption/" rel="noopener noreferrer"&gt;Attachments without corrupting the £&lt;/a&gt;&lt;/strong&gt; — Why a £ turns into Â£, and how to move every byte through decoding intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mailkite.dev/blog/byte-identical-mime-failures/" rel="noopener noreferrer"&gt;One MIME parser, three languages&lt;/a&gt;&lt;/strong&gt; — We wrote the same parser three times; the hard part was making them fail identically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://mailkite.dev/blog/agent-inbox-security-by-design/" rel="noopener noreferrer"&gt;You can't prompt your way out of prompt injection&lt;/a&gt;: how we designed an agent inbox that's ACL-gated by design, so a fooled agent still can't do damage.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mailkite.dev/blog/receiving-email-nobody-warns-you/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt; on April 1, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>email</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I wrote the same email parser three times, and the hard part was making them fail identically</title>
      <dc:creator>Gabe</dc:creator>
      <pubDate>Fri, 04 Sep 2026 19:17:01 +0000</pubDate>
      <link>https://dev.to/fijiwebdesign/i-wrote-the-same-email-parser-three-times-and-the-hard-part-was-making-them-fail-identically-32cm</link>
      <guid>https://dev.to/fijiwebdesign/i-wrote-the-same-email-parser-three-times-and-the-hard-part-was-making-them-fail-identically-32cm</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/mailkite/mail-parse" rel="noopener noreferrer"&gt;&lt;code&gt;@mailkite/mail-parse&lt;/code&gt;&lt;/a&gt; is MIT-licensed, and its test suite is really two conformance proofs stacked together. One implementation generates the truth; the other two are checked against it, once for the emails that parse and once for the emails that break. Here's both halves at a glance, before any of the code that makes them true:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram. Two conformance proofs, one parser. Top: the TypeScript package generates &lt;code&gt;parse_golden.json&lt;/code&gt; from 15 pathological fixtures; Python and Go assert field-for-field against it. Bottom: the same malformed email produces the byte-identical failure signature &lt;code&gt;f55154fda8f2cadd&lt;/code&gt; in all three runtimes (a real captured hash from the parity tests), so it dedups to one bucket and one issue.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reason any of this matters is that inbound email is a swamp. Not the protocol, the &lt;em&gt;content&lt;/em&gt;. The moment you accept mail from the open internet you stop getting the tidy RFC 5322 messages from the spec and start getting whatever thirty years of mail clients, marketing tools, and misconfigured servers actually emit: base64 that isn't padded, a &lt;code&gt;Content-Type&lt;/code&gt; charset that doesn't exist, boundaries that never close, &lt;code&gt;winmail.dat&lt;/code&gt;, 8-bit bytes in a header that swears it's ASCII. A parser that runs on that input has to degrade well, not throw.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@mailkite/mail-parse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// bytes in: a Buffer/Uint8Array, a string, or any (async) iterable of chunks (a stream)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawMimeBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// { address, name? }&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// RFC 2047 decoded&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// decoded text/plain&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// [{ filename, mimeType, content, size }]&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;diagnostics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// typed, non-fatal degradations: it never throws on bad input&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same call, and the same &lt;code&gt;Message&lt;/code&gt; shape, exists in the Python and Go ports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why three languages at all
&lt;/h2&gt;

&lt;p&gt;The honest answer: the email doesn't get to pick where it lands.&lt;/p&gt;

&lt;p&gt;Our inbound path parses raw MIME at the SMTP edge, a Node process on a plain VPS: no CPU cap, the natural home for streaming a 20 MB message straight to object storage without buffering it. But the same parsed-message shape also has to be producible inside a Cloudflare Worker (buffered, a different runtime), and the SDKs developers actually call live in a spread of languages.&lt;/p&gt;

&lt;p&gt;If those parsers drift, you get the worst class of bug: an email that produces one JSON shape in the Node path and a subtly different one somewhere else. No stack trace. No crash. Just a support ticket that says "the attachment is missing" for one customer and no one else, with no way to reproduce it, because the email that triggered it is gone.&lt;/p&gt;

&lt;p&gt;So "three languages" wasn't a flex. It was a constraint that forced a discipline: there is one parser, expressed three times, and I need a mechanical way to prove they're the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making success identical: one golden, generated from the reference
&lt;/h2&gt;

&lt;p&gt;The first half is conformance testing, and the trick is to have a single source of truth rather than three hand-written expectation sets that rot independently.&lt;/p&gt;

&lt;p&gt;The TypeScript package is the reference implementation. A script runs it over the 15 gold &lt;code&gt;.eml&lt;/code&gt; fixtures (the pathological ones, collected from real breakage) and serializes the full parsed result to a &lt;code&gt;parse_golden.json&lt;/code&gt;: subject, from, recipient count, the text body, the HTML body, every attachment's metadata, and the sorted diagnostic codes.&lt;/p&gt;

&lt;p&gt;Then Python and Go each assert &lt;strong&gt;field-for-field&lt;/strong&gt; against that same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_streaming.py — Python asserts against the TS-generated golden
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_every_fixture_matches_ts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FIXTURES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.eml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))):&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;golden&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                       &lt;span class="c1"&gt;# the TS reference's output
&lt;/span&gt;        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;from_&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;toCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;toCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                         &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filename&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attachments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attachments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;diagnostics&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                         &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diagnostics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diagnostics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the whole shape
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// parse_test.go — the SAME golden JSON, asserted from Go&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestTsParity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;golden&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;loadGolden&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"testdata/golden/parse_golden.json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;golden&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readFixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strOrNil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;strOrNil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s subject: got %q want %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strOrNil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;strOrNil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// ...from, toCount, text, html, attachments, diagnostics — field-for-field over 15 fixtures&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice property: the golden is &lt;em&gt;generated&lt;/em&gt;, not authored. When the reference parser's behavior changes, the golden regenerates, and the Python and Go tests fail until they match. There's no world where the three implementations silently diverge on a fixture and everyone's test suite stays green. The fixtures include TypeScript's exact 8-bit-header quirks, so "close enough" doesn't pass.&lt;/p&gt;

&lt;p&gt;That covers the emails that parse. It's the emails that &lt;em&gt;don't&lt;/em&gt; that taught me the real lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making failure identical: a fingerprint, not a stack trace
&lt;/h2&gt;

&lt;p&gt;When a MIME parser hits something it can't cleanly handle, the naive move is to throw. That's wrong for inbound email for two reasons: one broken part shouldn't sink the whole message, and, more subtly, an exception is a terrible unit of aggregation. Ten thousand deployments hitting the same malformed-boundary bug should be &lt;em&gt;one&lt;/em&gt; signal, not ten thousand log lines with slightly different byte offsets.&lt;/p&gt;

&lt;p&gt;So the parser never throws on bad input. Every degradation emits a typed diagnostic, and the part worth showing you: a &lt;strong&gt;failure signature&lt;/strong&gt;, a deterministic, PII-free hash of the &lt;em&gt;structural&lt;/em&gt; features of what broke.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FailureFeatures&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;libVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;envelope&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;structure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;part&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// header block? assembly? one leaf part?&lt;/span&gt;
  &lt;span class="nl"&gt;diagnosticCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;                 &lt;span class="c1"&gt;// e.g. ["BOUNDARY_NOT_CLOSED"], order-independent&lt;/span&gt;
  &lt;span class="nl"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                      &lt;span class="c1"&gt;// the offending leaf's declared type&lt;/span&gt;
  &lt;span class="nl"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transferEncoding&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;byteSignature&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// hex of the leading bytes: STRUCTURE, never content&lt;/span&gt;
  &lt;span class="nl"&gt;headerNames&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;                    &lt;span class="c1"&gt;// header NAMES present, never their values&lt;/span&gt;
  &lt;span class="nl"&gt;mailerFamily&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                     &lt;span class="c1"&gt;// X-Mailer normalized, e.g. "outlook"&lt;/span&gt;
  &lt;span class="nl"&gt;structurePath&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// "multipart/mixed&amp;gt;multipart/alternative&amp;gt;application/ms-tnef"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FailureSignature&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// fnv1a64(canonicalize(features)): 16 hex chars, the dedup key&lt;/span&gt;
  &lt;span class="nl"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FailureFeatures&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rollup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SignatureRollup&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;    &lt;span class="c1"&gt;// coarser hashes: "a whole scope is failing" vs a precise group&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; in there: no subject, no addresses, no body bytes. The signature describes the &lt;em&gt;shape&lt;/em&gt; of a failure (a base64 attachment that won't decode, an HTML part with a bogus charset, a &lt;code&gt;winmail.dat&lt;/code&gt; at a particular position in the tree) using only structural facts. That's what makes it safe to emit from a library running on other people's mail: the fingerprint can leave the box, the email never does.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Order is a cross-language hash trap.&lt;/strong&gt; The same failure can surface its diagnostic codes in different orders across three runtimes, and casing or a stray &lt;code&gt;;charset=&lt;/code&gt; param can differ too. Canonicalize &lt;em&gt;before&lt;/em&gt; you hash (sort the codes, lowercase the types, strip noisy params) or three identical failures fingerprint as three different bugs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And because it's a pure hash of canonicalized structural features, the same broken email produces the &lt;strong&gt;same hash everywhere&lt;/strong&gt;. That's the second conformance proof, the one that actually matters. The expected hashes are captured from the TypeScript &lt;code&gt;computeSignature()&lt;/code&gt; and asserted verbatim in Python and Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_signature.py — hashes captured from the TS reference, asserted in Python
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_part_tnef_signature_matches_ts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute_signature&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;part&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;diagnosticCodes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BOUNDARY_NOT_CLOSED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contentType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/ms-tnef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# a winmail.dat leaf
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transferEncoding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;f55154fda8f2cadd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# byte-identical FNV-1a-64
&lt;/span&gt;    &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rollup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c3c0a940c0ea88e6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# and the coarser roll-up
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I picked &lt;a href="https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function" rel="noopener noreferrer"&gt;FNV-1a&lt;/a&gt; (the 64-bit variant, a 16-hex-char digest) deliberately: a handful of lines, no dependencies, trivially portable, so "compute this hash" means the same thing in three languages without pulling in a crypto library or hoping two implementations of something fancier agree on edge cases. The canonicalization is the real work (lowercase the types, strip noisy params, bucket byte-signatures to known magic numbers, reduce the mailer to a family, sort the codes so order doesn't matter); the hash is just the cheap, deterministic seal on top.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram. The signature pipeline. Only structural facts enter the hash, never message content, so the fingerprint is safe to emit from a library running on other people's mail. The same features canonicalize and FNV-1a-64 hash to &lt;code&gt;f55154fda8f2cadd&lt;/code&gt; in all three runtimes, with a coarser scope roll-up &lt;code&gt;c3c0a940c0ea88e6&lt;/code&gt;. Both hashes are captured verbatim from the parity tests.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The payoff: a malformed email that breaks in the Node edge and the same email replayed through the Python SDK don't just both fail, they report the &lt;em&gt;same signature&lt;/em&gt;, land in the &lt;em&gt;same dedup bucket&lt;/em&gt;, and (once a threshold is crossed) file &lt;em&gt;one&lt;/em&gt; GitHub issue with a structural description precise enough to write a regression test from. Cross-language observability falls out of cross-language determinism for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the off-the-shelf parsers fit (honest alternatives)
&lt;/h2&gt;

&lt;p&gt;If you only live in one language and don't need the cross-runtime fingerprint, reach for the mature single-language option first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node&lt;/strong&gt;: &lt;a href="https://nodemailer.com/extras/mailparser/" rel="noopener noreferrer"&gt;mailparser&lt;/a&gt; or &lt;a href="https://github.com/postalsys/postal-mime" rel="noopener noreferrer"&gt;postal-mime&lt;/a&gt; (we lean on postal-mime for our own Workers-side build).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt;: the capable &lt;code&gt;email&lt;/code&gt; package in the standard library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go&lt;/strong&gt;: &lt;code&gt;net/mail&lt;/code&gt; plus &lt;code&gt;mime/multipart&lt;/code&gt; for the structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any of them will turn a well-formed message into fields with little or nothing to vendor.&lt;/p&gt;

&lt;p&gt;What none of them give you is the thing this post is about: the &lt;em&gt;same&lt;/em&gt; parsed shape and the &lt;em&gt;same&lt;/em&gt; failure fingerprint across all three runtimes. That guarantee only earns its keep if, like us, you parse the same mail in more than one place and need a bug in one to be provably the same bug in the others. If you don't, a stdlib parser is the right call, and I'd tell you to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I'd tell past-me
&lt;/h2&gt;

&lt;p&gt;Two lessons, both slightly counterintuitive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Generate your conformance oracle; don't hand-write it per language.&lt;/strong&gt; One reference implementation plus a generated golden beats three lovingly-maintained expectation files that drift the day you're not looking. The languages get to have different idioms internally (streams in Node, synchronous middleware in Python and Go) as long as they're forced through the same external truth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design your failures to be aggregatable, and you get portability and privacy as side effects.&lt;/strong&gt; The instinct is to make errors &lt;em&gt;rich&lt;/em&gt;: full context, the offending bytes, a stack trace. For a library that runs on data you're not allowed to see, the opposite is right: make errors &lt;em&gt;structural and hashable&lt;/em&gt;. A PII-free fingerprint is the thing you can compare across languages, dedup across deployments, and safely emit from someone else's process. Determinism is what makes it identical across three parsers; structure-only is what makes it safe to emit at all.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The parser is MIT-licensed and lives here: &lt;a href="https://github.com/mailkite/mail-parse" rel="noopener noreferrer"&gt;Node/TS&lt;/a&gt;, &lt;a href="https://github.com/mailkite/mail-parse-py" rel="noopener noreferrer"&gt;Python&lt;/a&gt;, &lt;a href="https://github.com/mailkite/mail-parse-go" rel="noopener noreferrer"&gt;Go&lt;/a&gt;. It grew out of building &lt;a href="https://mailkite.dev" rel="noopener noreferrer"&gt;MailKite&lt;/a&gt;, which we build (inbound email turned into a webhook), and that's where the appetite for "the same broken email must behave the same everywhere" came from. But the parser stands on its own: if you just need to turn MIME into clean, typed JSON without a service in the loop, take it and ignore the rest of us.&lt;/p&gt;

&lt;p&gt;If you've fought the email-content swamp, I'd genuinely like to hear which message finally made you write your own parser. Mine was a &lt;code&gt;winmail.dat&lt;/code&gt; inside a &lt;code&gt;multipart/mixed&lt;/code&gt; that three off-the-shelf libraries each mangled a different way. In our signature scheme that failure has a name now: &lt;code&gt;f55154fda8f2cadd&lt;/code&gt;. It's the same on all three parsers, and I'll never forget it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://mailkite.dev/blog/receiving-email-nobody-warns-you/" rel="noopener noreferrer"&gt;Receiving email is the part nobody warns you about&lt;/a&gt; on why the inbound content swamp is the hard direction, and &lt;a href="https://mailkite.dev/blog/email-attachments-without-corruption/" rel="noopener noreferrer"&gt;Handling email attachments without losing the £&lt;/a&gt; on the charset-and-encoding half of the same problem.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mailkite.dev/blog/byte-identical-mime-failures/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt; on June 24, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>python</category>
      <category>go</category>
      <category>opensource</category>
    </item>
    <item>
      <title>MailKite SMTP for WordPress is on WordPress.org</title>
      <dc:creator>Gabe</dc:creator>
      <pubDate>Thu, 03 Sep 2026 02:04:34 +0000</pubDate>
      <link>https://dev.to/fijiwebdesign/mailkite-smtp-for-wordpress-is-on-wordpressorg-3m7i</link>
      <guid>https://dev.to/fijiwebdesign/mailkite-smtp-for-wordpress-is-on-wordpressorg-3m7i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://mailkite.dev/blog/mailkite-smtp-wordpress-plugin/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt;. I build MailKite.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MailKite SMTP, the WordPress plugin we build, is now listed in the WordPress.org plugin directory. It replaces PHP mail() with delivery through MailKite, SendGrid, Brevo, Mailgun or any SMTP server, keeps a free email log, fails over automatically when a send fails, and receives email into WordPress. Version 0.4.2, GPL-2.0, free with no Pro tier.&lt;/p&gt;

&lt;p&gt;Install it from Plugins → Add New (search "MailKite SMTP"), or from the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install &lt;/span&gt;mailkite-smtp &lt;span class="nt"&gt;--activate&lt;/span&gt;
wp mailkite &lt;span class="nb"&gt;test &lt;/span&gt;you@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Listing: &lt;a href="https://wordpress.org/plugins/mailkite-smtp/" rel="noopener noreferrer"&gt;wordpress.org/plugins/mailkite-smtp&lt;/a&gt;. Source: &lt;a href="https://github.com/mailkite/mailkite-smtp" rel="noopener noreferrer"&gt;github.com/mailkite/mailkite-smtp&lt;/a&gt;. Requires WordPress 6.2 and PHP 8.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can do now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Send through the provider you already have.&lt;/strong&gt; MailKite, SendGrid, Brevo, Mailgun with your own API key, or any SMTP host. Routing rules send WooCommerce receipts through one mailer and newsletters through another, by subject or recipient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop losing email silently.&lt;/strong&gt; If an API send fails, the same message retries through your SMTP server or PHP mail, and the log entry says it fell back instead of reporting a success you did not get. Failure alerts go to email, Slack, Discord, or any webhook, rate-limited so an outage does not become an alert storm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the log without paying for it.&lt;/strong&gt; Every message, its status, the mailer that sent it, and the error verbatim when there was one. One-click resend of failures, CSV export, configurable retention. Most SMTP plugins put this behind a paid tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receive email in WordPress.&lt;/strong&gt; Turn inbound on in one click and replies, bounces, and out-of-office notices land in the log next to the message they answer. Reply from wp-admin, in-thread, from your own domain. Or forward everything to an address you already read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move over in one click&lt;/strong&gt; from WP Mail SMTP, Easy WP SMTP, FluentSMTP, or Post SMTP. The importer reads their settings; you confirm and send a test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;After activation, the settings screen shows one section per mailer and only the one you pick. Choosing MailKite creates an account from your email address if you do not have one, and the plugin picks up the API key from there. Choosing any other provider asks for its key or SMTP credentials, which are stored encrypted with a key derived from your wp-config salts.&lt;/p&gt;

&lt;p&gt;The Send Test tab reports which mailer handled the message and the provider's error text when one comes back. The same check runs from WP-CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp mailkite status
wp mailkite &lt;span class="nb"&gt;test &lt;/span&gt;you@example.com
wp mailkite log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For automated provisioning, set &lt;code&gt;MAILKITE_API_KEY&lt;/code&gt; and &lt;code&gt;MAILKITE_DEFAULT_MAILER&lt;/code&gt; in wp-config.php and skip the settings screen entirely. Settings export and import (secrets excluded) cover the rest of a multi-site rollout. The &lt;a href="https://mailkite.dev/docs/integrations/wordpress" rel="noopener noreferrer"&gt;WordPress setup guide&lt;/a&gt; walks through each mailer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inbound email fires a WordPress action
&lt;/h2&gt;

&lt;p&gt;Every received message fires one hook. The plugin has already verified the webhook signature and stored the message before your code runs, so a handler is a few lines in functions.php:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// functions.php — runs for every inbound message the plugin receives.&lt;/span&gt;
&lt;span class="c1"&gt;// The plugin has already verified the signature and logged the mail.&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'mailkite_smtp_inbound'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// $message is the event payload: id, threadId, subject, text, html, attachments,&lt;/span&gt;
    &lt;span class="c1"&gt;// and from/to as objects — $message['from']['address'], not a plain string.&lt;/span&gt;
    &lt;span class="nv"&gt;$subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'subject'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$sender&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'from'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'address'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[support]'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_insert_post&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'post_type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'ticket'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'post_title'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'post_content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'text'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'post_status'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'meta_input'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'from'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'thread_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'threadId'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning inbound on registers the webhook and its signing secret on your MailKite domain from inside WordPress. There is no URL to copy and no secret to paste. The REST route rejects an unsigned request in its permission callback, before any handler code runs. Inbound needs a MailKite account with a verified domain; sending does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs that cannot leak a reset link
&lt;/h2&gt;

&lt;p&gt;A stored email log is also a stored copy of every password-reset link your site ever sent. The plugin redacts the body of password-reset, login, and verification emails by default, so a leaked database or an over-shared log page contains the subject and the outcome but never the link. You can turn this off per site if you accept the risk.&lt;/p&gt;

&lt;p&gt;Received mail that belongs to a user's personal mailbox (the companion MailKite Mailboxes plugin) never appears in the site-wide log. Ownership is part of the query in the log store, not a filter in the template, and every reader of the log tables goes through the store. That last part is what the 0.4.2 release fixed: the WordPress.org review found the REST log endpoint selecting the table directly and skipping the restriction, and auditing the other readers turned up the same omission in the WP-CLI listing, Site Health, and the weekly summary. All four now read through the store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three review rounds, one real bug
&lt;/h2&gt;

&lt;p&gt;The listing took three rounds with the WordPress.org plugin review team. Rounds one and two were what a static scanner flags on any plugin that talks to an API: a hostname it could not resolve from a settings value, a permission callback it could not see through a shared variable, a prepared query assigned through a ternary. Each was restructured so the answer is visible on a read. Round three was the log-scoping bug above, which was real. The full history is in the &lt;a href="https://github.com/mailkite/mailkite-smtp" rel="noopener noreferrer"&gt;repo's changelog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The plugin is free, GPL-2.0, and every feature in it is free: logs, failover, alerts, inbound, migration, WP-CLI. There is no Pro tier. It works fully with your own SMTP server or a SendGrid, Brevo, or Mailgun key and never requires a MailKite account. MailKite itself has a free plan and paid plans, and inbound email is the one feature that needs it, because someone has to run the MX.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get started
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://wordpress.org/plugins/mailkite-smtp/" rel="noopener noreferrer"&gt;WordPress.org listing&lt;/a&gt; has the install and the FAQ, and the &lt;a href="https://mailkite.dev/docs/integrations/wordpress" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; covers each mailer. Bugs and pull requests go to &lt;a href="https://github.com/mailkite/mailkite-smtp" rel="noopener noreferrer"&gt;github.com/mailkite/mailkite-smtp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>email</category>
      <category>opensource</category>
    </item>
    <item>
      <title>23 open source email servers for Linux, sorted by the job each one does</title>
      <dc:creator>Gabe</dc:creator>
      <pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/fijiwebdesign/23-open-source-email-servers-for-linux-sorted-by-the-job-each-one-does-18om</link>
      <guid>https://dev.to/fijiwebdesign/23-open-source-email-servers-for-linux-sorted-by-the-job-each-one-does-18om</guid>
      <description>&lt;p&gt;Most "best open source mail server" lists put a full-stack Docker bundle and a single SMTP daemon in the same ranked table, as if you'd choose between them. You wouldn't. This is 23 Linux mail projects grouped by the slot they fill in the mail path, with licenses, languages, and activity verified on 2026-08-05, plus the part every roundup skips: what self-hosting still costs you after the install script says done.&lt;/p&gt;

&lt;p&gt;Start with the slots, not the projects. A mail server is five distinct jobs wearing one&lt;br&gt;
name, and every project below fills one, some, or all of them. Once you know which slot&lt;br&gt;
you're shopping for, the list stops being a popularity contest and turns into about three&lt;br&gt;
real candidates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09jd8p2ftr6u0ijjngcw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F09jd8p2ftr6u0ijjngcw.png" alt="The mail path as slots: inbound MX, filter, store, IMAP, client; outbound submission, queue, internet — and which project fills each" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Whichever you install, the acceptance test is the same three commands, and it is worth&lt;br&gt;
running them before you read another word of documentation. Two of them check the server.&lt;br&gt;
The third checks the thing that actually decides whether your mail arrives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. does it accept mail on :25 for a domain it claims to own?&lt;/span&gt;
swaks &lt;span class="nt"&gt;--to&lt;/span&gt; you@example.com &lt;span class="nt"&gt;--server&lt;/span&gt; mail.example.com:25 &lt;span class="nt"&gt;--tls&lt;/span&gt;

&lt;span class="c"&gt;# 2. does it serve that mailbox back over IMAP with a real cert?&lt;/span&gt;
openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; mail.example.com:993 &lt;span class="nt"&gt;-quiet&lt;/span&gt; &lt;span class="nt"&gt;-crlf&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
a login you@example.com hunter2
a list "" "*"
a logout
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# 3. does your sending IP have reverse DNS that matches your HELO name?&lt;/span&gt;
dig +short &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://ifconfig.me&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Command 3 is not part of any project's install script, and it is the one that fails. An&lt;br&gt;
IP with no PTR record, or a PTR that doesn't match the name your server announces in&lt;br&gt;
&lt;code&gt;EHLO&lt;/code&gt;, gets your mail filed as spam by Gmail and Outlook no matter which of these 23&lt;br&gt;
projects is behind it.&lt;/p&gt;

&lt;p&gt;Everything below was checked against each project's repository on 2026-08-05: license&lt;br&gt;
from the actual &lt;code&gt;LICENSE&lt;/code&gt; file, language, star count, and the date of the last commit.&lt;br&gt;
Star counts are a proxy for attention, not quality, and I've said so where the two&lt;br&gt;
diverge.&lt;/p&gt;
&lt;h2&gt;
  
  
  The MTAs: they move mail and nothing else
&lt;/h2&gt;

&lt;p&gt;An MTA speaks SMTP. It accepts a message on port 25, decides whether to take it, and&lt;br&gt;
either drops it into a local store or queues it for delivery elsewhere. It does not serve&lt;br&gt;
you a mailbox, filter spam, or render a web UI. Three of the four below have been doing&lt;br&gt;
this since before most of us had email addresses, which is the point: they are the least&lt;br&gt;
surprising software in your stack.&lt;/p&gt;
&lt;h3&gt;
  
  
  Postfix
&lt;/h3&gt;

&lt;p&gt;The default answer, and usually the right one. C, dual-licensed under&lt;br&gt;
&lt;a href="https://www.postfix.org/" rel="noopener noreferrer"&gt;EPL-2.0 or IPL-1.0&lt;/a&gt; since 3.2.5, written by Wietse Venema as&lt;br&gt;
a security-first replacement for Sendmail. Its architecture is a set of small processes&lt;br&gt;
with narrow privileges talking over pipes, which is why its CVE history is as quiet as it&lt;br&gt;
is. Nearly every bundle further down this page is Postfix plus a wrapper. Configuration&lt;br&gt;
is a flat &lt;code&gt;main.cf&lt;/code&gt; of key-value pairs, and it's readable, but the map/transport/restriction&lt;br&gt;
vocabulary takes a weekend to learn properly.&lt;/p&gt;
&lt;h3&gt;
  
  
  Exim
&lt;/h3&gt;

&lt;p&gt;C, GPL-2.0-or-later, stable at 4.99.5 as of 2026-07-22. The Debian default for years and&lt;br&gt;
the engine behind most cPanel hosts. Exim's distinguishing feature is a genuinely&lt;br&gt;
programmable configuration language with string expansions, routers, and transports, so&lt;br&gt;
you can express routing logic in the config that would need a plugin elsewhere. That&lt;br&gt;
power has a cost: Exim has shipped several remote-code-execution CVEs where Postfix&lt;br&gt;
shipped none, largely because more of the message path runs in one privileged process.&lt;br&gt;
Pick it when you need its routing expressiveness, and subscribe to the security list.&lt;/p&gt;
&lt;h3&gt;
  
  
  OpenSMTPD
&lt;/h3&gt;

&lt;p&gt;C, ISC license, from the OpenBSD project. The configuration file is the pitch: a working&lt;br&gt;
mail server in about six lines, with a grammar closer to &lt;code&gt;pf&lt;/code&gt; than to &lt;code&gt;main.cf&lt;/code&gt;. It is&lt;br&gt;
audited to OpenBSD standards and deliberately implements a subset of SMTP rather than all&lt;br&gt;
of it. If you run OpenBSD, or you want the smallest correct thing that can accept mail,&lt;br&gt;
this is the one. Portable builds for Linux exist and are maintained, though they lag the&lt;br&gt;
OpenBSD release.&lt;/p&gt;
&lt;h3&gt;
  
  
  chasquid
&lt;/h3&gt;

&lt;p&gt;Go, Apache-2.0, &lt;a href="https://github.com/albertito/chasquid" rel="noopener noreferrer"&gt;978 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-06-07. Written for small personal servers by one maintainer who kept it small on&lt;br&gt;
purpose: SMTP in, SMTP out, hooks for aliases and spam filtering, no store of its own.&lt;br&gt;
It is the modern-language equivalent of OpenSMTPD's philosophy, and it pairs with Dovecot&lt;br&gt;
the same way. A single-maintainer project is a real risk to weigh, but the codebase is&lt;br&gt;
small enough that you could read all of it in an afternoon.&lt;/p&gt;
&lt;h2&gt;
  
  
  The all-in-one servers: the whole path in one binary
&lt;/h2&gt;

&lt;p&gt;These replace the classic Postfix-plus-Dovecot-plus-Rspamd assembly with a single&lt;br&gt;
process. It's a genuine architectural argument, not just packaging: one config file, one&lt;br&gt;
set of credentials, one thing to upgrade, and no gluing an MTA's idea of a user to an&lt;br&gt;
IMAP server's idea of a user. The trade is that you are betting on a younger codebase in&lt;br&gt;
the position where the old ones have thirty years of hostile-internet exposure.&lt;/p&gt;
&lt;h3&gt;
  
  
  Stalwart
&lt;/h3&gt;

&lt;p&gt;Rust, dual-licensed AGPL-3.0 and a commercial Enterprise license,&lt;br&gt;
&lt;a href="https://github.com/stalwartlabs/stalwart" rel="noopener noreferrer"&gt;14,017 stars&lt;/a&gt;, last commit 2026-08-03. The most&lt;br&gt;
protocol-complete project on this page: SMTP, IMAP4, JMAP, POP3, CalDAV, CardDAV, and&lt;br&gt;
WebDAV in one server, with built-in spam filtering, DKIM/ARC signing, and full-text&lt;br&gt;
search. JMAP support in particular is close to unique here. It's also the most actively&lt;br&gt;
developed, which cuts both ways: features land fast, and the config surface has changed&lt;br&gt;
between minor versions. Read the upgrade notes before you &lt;code&gt;apt upgrade&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  mox
&lt;/h3&gt;

&lt;p&gt;Go, MIT, &lt;a href="https://github.com/mjl-/mox" rel="noopener noreferrer"&gt;5,768 stars&lt;/a&gt;, last commit 2026-08-02. mox's design&lt;br&gt;
goal is that a competent person can run a personal mail server without becoming a mail&lt;br&gt;
administrator. One binary, a &lt;code&gt;quickstart&lt;/code&gt; command that generates your DNS records and&lt;br&gt;
systemd unit, and sane defaults for SPF, DKIM, DMARC, MTA-STS, DANE, and TLSRPT out of&lt;br&gt;
the box. It ships DANE and MTA-STS support that several older projects still don't. If&lt;br&gt;
you want one domain, a handful of mailboxes, and to stop thinking about it, mox is the&lt;br&gt;
shortest honest path.&lt;/p&gt;
&lt;h3&gt;
  
  
  maddy
&lt;/h3&gt;

&lt;p&gt;Go, GPL-3.0, &lt;a href="https://github.com/foxcpp/maddy" rel="noopener noreferrer"&gt;6,055 stars&lt;/a&gt;, last commit 2026-07-24. The&lt;br&gt;
selling point is composability: maddy models the mail path as pipelines of modules you&lt;br&gt;
wire in its config, so you can swap the storage backend or the auth source without&lt;br&gt;
swapping servers. It hits a nice middle ground between "one opinionated binary" and "six&lt;br&gt;
daemons and a socket map." Development is steadier than fast, which for mail software is&lt;br&gt;
not the worst property.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bundles: the classics, assembled
&lt;/h2&gt;

&lt;p&gt;These aren't servers. They're curated stacks (usually Postfix, Dovecot, Rspamd, and a&lt;br&gt;
webmail) plus provisioning, TLS automation, and an admin UI. You get a working mail host&lt;br&gt;
in an afternoon and inherit whatever opinions the maintainers baked in. That is a fair&lt;br&gt;
trade for most people, as long as you know you're now debugging the wrapper as well as&lt;br&gt;
the components.&lt;/p&gt;
&lt;h3&gt;
  
  
  docker-mailserver
&lt;/h3&gt;

&lt;p&gt;Shell, MIT, &lt;a href="https://github.com/docker-mailserver/docker-mailserver" rel="noopener noreferrer"&gt;18,661 stars&lt;/a&gt;, last&lt;br&gt;
commit 2026-08-03. A single container with Postfix, Dovecot, Rspamd, ClamAV, and Fail2ban,&lt;br&gt;
configured by env vars and a &lt;code&gt;setup&lt;/code&gt; CLI. No database and no admin web UI on purpose:&lt;br&gt;
accounts live in a flat file, and the project treats "no persistent state we manage" as a&lt;br&gt;
feature. The most popular project on this page by stars, and the easiest to reason about&lt;br&gt;
when it breaks, because it's just the classics in a box.&lt;/p&gt;
&lt;h3&gt;
  
  
  mailcow: dockerized
&lt;/h3&gt;

&lt;p&gt;GPL-3.0, &lt;a href="https://github.com/mailcow/mailcow-dockerized" rel="noopener noreferrer"&gt;13,229 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-08-04. The opposite bet: a full docker-compose stack (Postfix, Dovecot, Rspamd,&lt;br&gt;
SOGo, Nginx, MariaDB, Redis) with a polished admin panel, per-domain quotas, ActiveSync,&lt;br&gt;
and calendars and contacts through SOGo. If you're replacing a small-business Exchange or&lt;br&gt;
Google Workspace and non-engineers will use the admin UI, this is the strongest option&lt;br&gt;
here. Budget the RAM: a dozen containers is not a $5 VPS workload.&lt;/p&gt;
&lt;h3&gt;
  
  
  Mail-in-a-Box
&lt;/h3&gt;

&lt;p&gt;Python, CC0-1.0 (public domain), &lt;a href="https://github.com/mail-in-a-box/mailinabox" rel="noopener noreferrer"&gt;15,377 stars&lt;/a&gt;,&lt;br&gt;
last commit 2026-05-24. One &lt;code&gt;setup&lt;/code&gt; script turns a clean Ubuntu box into mail, webmail,&lt;br&gt;
contacts, calendar, DNS, and a status console that tells you exactly which DNS records&lt;br&gt;
are wrong. Explicitly designed for one box, one owner, no customization, and it says so:&lt;br&gt;
deviate from the supported layout and upgrades will fight you. The slowest commit cadence&lt;br&gt;
in this group, which reflects a project that considers itself finished more than one that&lt;br&gt;
is stalled.&lt;/p&gt;
&lt;h3&gt;
  
  
  Mailu
&lt;/h3&gt;

&lt;p&gt;Python, MIT, &lt;a href="https://github.com/Mailu/Mailu" rel="noopener noreferrer"&gt;7,421 stars&lt;/a&gt;, last commit 2026-07-27.&lt;br&gt;
Docker-compose stack in the mailcow tradition, with a lighter footprint and a Kubernetes&lt;br&gt;
path that actually works. Web admin, optional webmail, per-user filters. A good middle&lt;br&gt;
choice if mailcow feels heavy and docker-mailserver feels too bare.&lt;/p&gt;
&lt;h3&gt;
  
  
  iRedMail
&lt;/h3&gt;

&lt;p&gt;Shell, GPL-3.0, &lt;a href="https://github.com/iredmail/iRedMail" rel="noopener noreferrer"&gt;1,834 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-07-22. Installs the classic stack straight onto the host (no containers) across&lt;br&gt;
several Linux and BSD distributions, backed by OpenLDAP, MySQL, or PostgreSQL. The&lt;br&gt;
open-source edition is complete; the good admin panel is the paid iRedAdmin-Pro, which is&lt;br&gt;
worth knowing before you plan around it. Choose it when you want packages on metal and an&lt;br&gt;
LDAP directory rather than a compose file.&lt;/p&gt;
&lt;h3&gt;
  
  
  Modoboa
&lt;/h3&gt;

&lt;p&gt;Python (Django), ISC, &lt;a href="https://github.com/modoboa/modoboa" rel="noopener noreferrer"&gt;3,528 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-08-04. Not a mail server: a management layer over Postfix and Dovecot, with domain&lt;br&gt;
and mailbox admin, per-user DNS record checks, a calendar, and a REST API. Reach for it&lt;br&gt;
when you already run the classics and the thing you're actually missing is a control&lt;br&gt;
panel with an API you can automate against.&lt;/p&gt;
&lt;h2&gt;
  
  
  The programmable ones: mail as an event in your application
&lt;/h2&gt;

&lt;p&gt;This is the category that behaves differently from everything above. The projects here&lt;br&gt;
assume the recipient is not a human with a mail client but a program: a support tool that&lt;br&gt;
turns replies into tickets, a CI system that accepts commands by email, an AI agent with&lt;br&gt;
its own address. What you want from a mail server in that case is not a mailbox. It's a&lt;br&gt;
well-formed event delivered to your code, with the MIME already parsed and the&lt;br&gt;
authentication already checked.&lt;/p&gt;
&lt;h3&gt;
  
  
  Haraka
&lt;/h3&gt;

&lt;p&gt;JavaScript, MIT, &lt;a href="https://github.com/haraka/Haraka" rel="noopener noreferrer"&gt;5,613 stars&lt;/a&gt;, last commit 2026-08-04.&lt;br&gt;
A high-performance SMTP server built as a plugin bus: every phase of the SMTP conversation&lt;br&gt;
(&lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;mail&lt;/code&gt;, &lt;code&gt;rcpt&lt;/code&gt;, &lt;code&gt;data_post&lt;/code&gt;) is an event you can hook in JavaScript, so you&lt;br&gt;
can reject a recipient with a live database lookup or hand the parsed message to your own&lt;br&gt;
code. It is the most flexible way to put application logic inside the SMTP conversation&lt;br&gt;
itself, and it's what we run as our own MX edge. It gives you a programmable SMTP server,&lt;br&gt;
not a finished product: storage, auth, retries, and the web UI are yours.&lt;/p&gt;
&lt;h3&gt;
  
  
  WildDuck
&lt;/h3&gt;

&lt;p&gt;JavaScript, EUPL-1.2, &lt;a href="https://github.com/zone-eu/wildduck" rel="noopener noreferrer"&gt;2,106 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-08-04. An IMAP and POP3 server that stores mail in MongoDB instead of on a&lt;br&gt;
filesystem, so mailboxes replicate and shard the way the rest of your infrastructure&lt;br&gt;
does, with no local state on the mail nodes. It also exposes a full REST API over&lt;br&gt;
mailboxes, which is unusual and useful. The right pick when you're building a mail&lt;br&gt;
&lt;em&gt;product&lt;/em&gt; for many users rather than hosting mail for a company.&lt;/p&gt;
&lt;h3&gt;
  
  
  ZoneMTA
&lt;/h3&gt;

&lt;p&gt;JavaScript, EUPL-1.2, &lt;a href="https://github.com/zone-eu/zone-mta" rel="noopener noreferrer"&gt;668 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-07-20. Outbound only, and specialized: named delivery zones let you bind different&lt;br&gt;
traffic to different IPs, so transactional mail and bulk mail stop sharing a reputation.&lt;br&gt;
Per-zone throttling, connection pooling, and hooks for rewriting messages in flight. Low&lt;br&gt;
star count for how good it is at its one job.&lt;/p&gt;
&lt;h3&gt;
  
  
  Postal
&lt;/h3&gt;

&lt;p&gt;Ruby, MIT, &lt;a href="https://github.com/postalserver/postal" rel="noopener noreferrer"&gt;16,719 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-08-03. The self-hosted answer to SendGrid or Mailgun: a full sending platform with&lt;br&gt;
an HTTP send API, per-organization credentials, click and open tracking, bounce&lt;br&gt;
processing, suppression lists, inbound routes that POST to a URL, and a real dashboard.&lt;br&gt;
If your requirement reads "we need a transactional email API but the data has to stay on&lt;br&gt;
our infrastructure," Postal is the closest open-source match. It expects MariaDB, RabbitMQ,&lt;br&gt;
and someone to keep them alive.&lt;/p&gt;
&lt;h3&gt;
  
  
  MailKite Server
&lt;/h3&gt;

&lt;p&gt;JavaScript (Node ≥ 22.5), AGPL-3.0, &lt;a href="https://github.com/mailkite/server" rel="noopener noreferrer"&gt;github.com/mailkite/server&lt;/a&gt;,&lt;br&gt;
v0.5.0, first public release 2026-08-01. This is ours, and it exists because of a specific&lt;br&gt;
gap in the list above: the programmable projects each solve one slot well (Haraka the SMTP&lt;br&gt;
conversation, WildDuck the store, Postal the sending platform), and an application that&lt;br&gt;
wants to &lt;em&gt;receive&lt;/em&gt; mail has to assemble three of them plus the parsing, the signing, and&lt;br&gt;
the retry logic.&lt;/p&gt;

&lt;p&gt;MailKite Server is that assembly, built so that every protocol daemon is stateless and all&lt;br&gt;
state sits behind one small HTTP contract: five IMAP read endpoints, an inbound ingest&lt;br&gt;
hook, an SMTP auth check, and a relay endpoint&lt;br&gt;
(&lt;a href="https://github.com/mailkite/server/blob/main/docs/contract.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/contract.md&lt;/code&gt;&lt;/a&gt;). The&lt;br&gt;
bundled zero-dependency SQLite backend and MailKite Cloud are two implementations of the&lt;br&gt;
same contract, so the Haraka MX edge, the submission edge, the IMAP head, and the web&lt;br&gt;
console run unchanged against either. One MX edge can serve several backends at once,&lt;br&gt;
routing each recipient domain to its owner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/mailkite/server &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;server
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;           &lt;span class="c"&gt;# backend + web console on :8787&lt;/span&gt;
docker compose &lt;span class="nt"&gt;--profile&lt;/span&gt; edges up &lt;span class="nt"&gt;-d&lt;/span&gt;   &lt;span class="c"&gt;# adds the MX edge on :25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mail that arrives is POSTed to your app as parsed JSON, signed with an&lt;br&gt;
&lt;code&gt;x-mailkite-signature: t=&amp;lt;unix&amp;gt;,v1=&amp;lt;hex&amp;gt;&lt;/code&gt; header. Verifying it is one call from the SDK,&lt;br&gt;
and because the self-hosted server and the hosted service sign identically, the same&lt;br&gt;
handler works against both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MailKite&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mailkite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MailKite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-mailkite-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAILKITE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleIncomingEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest scope: it's pre-1.0, the SMTP and IMAP edges run our production mail but the SQLite&lt;br&gt;
backend and console are new, and it is not a groupware server. If you want mailboxes for&lt;br&gt;
humans with calendars and contacts, mailcow or Stalwart is a better fit and it isn't&lt;br&gt;
close. If you want an application (or an AI agent) to own an address and react to mail as&lt;br&gt;
an event, this is the shape we think that should take, and you can run the whole thing&lt;br&gt;
yourself. &lt;a href="https://mailkite.dev" rel="noopener noreferrer"&gt;MailKite Cloud&lt;/a&gt;, which we build and which pays for this&lt;br&gt;
work, is the same contract with the deliverability and retention handled; the DIY path&lt;br&gt;
above is deliberately complete so you never need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supporting cast
&lt;/h2&gt;

&lt;p&gt;Three projects that aren't mail servers but appear in almost every deployment on this&lt;br&gt;
page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dovecot
&lt;/h3&gt;

&lt;p&gt;C, LGPL-2.1 with MIT-licensed parts, &lt;a href="https://github.com/dovecot/core" rel="noopener noreferrer"&gt;1,233 stars&lt;/a&gt; on the&lt;br&gt;
core repo, last commit 2026-08-04. The IMAP and POP3 server that nearly every bundle here&lt;br&gt;
ships. Its Sieve implementation handles server-side filtering, and its LMTP and auth&lt;br&gt;
services are what Postfix hands mail to. The low star count is an artifact of a project&lt;br&gt;
that predates GitHub as a center of gravity, not a measure of use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rspamd
&lt;/h3&gt;

&lt;p&gt;C, Apache-2.0, &lt;a href="https://github.com/rspamd/rspamd" rel="noopener noreferrer"&gt;2,501 stars&lt;/a&gt;, last commit 2026-08-04.&lt;br&gt;
The spam filter that replaced SpamAssassin nearly everywhere: fast, with Bayesian&lt;br&gt;
classification, fuzzy hashing, greylisting, reputation tracking, and DKIM/ARC signing&lt;br&gt;
built in. It also handles the signing side of DMARC alignment, so it often does more than&lt;br&gt;
filtering in a modern stack. If you already run Postfix and your only problem is spam,&lt;br&gt;
adding Rspamd is a smaller change than replacing your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Roundcube
&lt;/h3&gt;

&lt;p&gt;PHP, GPL-3.0, &lt;a href="https://github.com/roundcube/roundcubemail" rel="noopener noreferrer"&gt;7,114 stars&lt;/a&gt;, last commit&lt;br&gt;
2026-07-25. The webmail your users will actually see, sitting on top of whatever IMAP&lt;br&gt;
server you chose. Roundcube Elastic is the modern responsive skin, and the plugin API&lt;br&gt;
covers most of what people ask for. SOGo (bundled in mailcow) is the alternative when you&lt;br&gt;
need calendars and ActiveSync alongside mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  If what you want is a newsletter, not a mail server
&lt;/h2&gt;

&lt;p&gt;Two projects worth naming because people land on mail-server roundups when this is what&lt;br&gt;
they actually need. Neither speaks SMTP to the internet on its own; both hand mail to&lt;br&gt;
something that does (Postfix, SES, or any relay).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;listmonk&lt;/strong&gt; (&lt;a href="https://github.com/knadh/listmonk" rel="noopener noreferrer"&gt;22,624 stars&lt;/a&gt;, Go, AGPL-3.0, last commit&lt;br&gt;
2026-08-03) is a single self-contained binary plus Postgres: subscriber lists, campaign&lt;br&gt;
templating, per-campaign analytics, and a fast admin UI. The highest star count on this&lt;br&gt;
page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keila&lt;/strong&gt; (&lt;a href="https://github.com/pentacent/keila" rel="noopener noreferrer"&gt;2,180 stars&lt;/a&gt;, Elixir, AGPL-3.0, last&lt;br&gt;
commit 2026-08-01) covers the same ground with a friendlier editor and a GDPR-first&lt;br&gt;
posture, which is why it shows up in European deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every project, one table
&lt;/h2&gt;

&lt;p&gt;Verified 2026-08-05. Stars are rounded; "last commit" is the most recent push to the&lt;br&gt;
default branch.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Slot&lt;/th&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Last commit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.postfix.org/" rel="noopener noreferrer"&gt;Postfix&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;MTA&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;EPL-2.0 / IPL-1.0&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.exim.org/" rel="noopener noreferrer"&gt;Exim&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;MTA&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;GPL-2.0-or-later&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;4.99.5, 2026-07-22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.opensmtpd.org/" rel="noopener noreferrer"&gt;OpenSMTPD&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;MTA&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;ISC&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/albertito/chasquid" rel="noopener noreferrer"&gt;chasquid&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;MTA&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;1.0k&lt;/td&gt;
&lt;td&gt;2026-06-07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/stalwartlabs/stalwart" rel="noopener noreferrer"&gt;Stalwart&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;all-in-one&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;AGPL-3.0 / commercial&lt;/td&gt;
&lt;td&gt;14.0k&lt;/td&gt;
&lt;td&gt;2026-08-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/mjl-/mox" rel="noopener noreferrer"&gt;mox&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;all-in-one&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;5.8k&lt;/td&gt;
&lt;td&gt;2026-08-02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/foxcpp/maddy" rel="noopener noreferrer"&gt;maddy&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;all-in-one&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;GPL-3.0&lt;/td&gt;
&lt;td&gt;6.1k&lt;/td&gt;
&lt;td&gt;2026-07-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/docker-mailserver/docker-mailserver" rel="noopener noreferrer"&gt;docker-mailserver&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;bundle&lt;/td&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;18.7k&lt;/td&gt;
&lt;td&gt;2026-08-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/mailcow/mailcow-dockerized" rel="noopener noreferrer"&gt;mailcow&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;bundle&lt;/td&gt;
&lt;td&gt;Shell / JS&lt;/td&gt;
&lt;td&gt;GPL-3.0&lt;/td&gt;
&lt;td&gt;13.2k&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/mail-in-a-box/mailinabox" rel="noopener noreferrer"&gt;Mail-in-a-Box&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;bundle&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;CC0-1.0&lt;/td&gt;
&lt;td&gt;15.4k&lt;/td&gt;
&lt;td&gt;2026-05-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Mailu/Mailu" rel="noopener noreferrer"&gt;Mailu&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;bundle&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;7.4k&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iredmail/iRedMail" rel="noopener noreferrer"&gt;iRedMail&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;bundle&lt;/td&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;GPL-3.0&lt;/td&gt;
&lt;td&gt;1.8k&lt;/td&gt;
&lt;td&gt;2026-07-22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/modoboa/modoboa" rel="noopener noreferrer"&gt;Modoboa&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;admin layer&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;ISC&lt;/td&gt;
&lt;td&gt;3.5k&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/haraka/Haraka" rel="noopener noreferrer"&gt;Haraka&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;programmable SMTP&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;5.6k&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/zone-eu/wildduck" rel="noopener noreferrer"&gt;WildDuck&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;IMAP + store&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;EUPL-1.2&lt;/td&gt;
&lt;td&gt;2.1k&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/zone-eu/zone-mta" rel="noopener noreferrer"&gt;ZoneMTA&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;outbound MTA&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;EUPL-1.2&lt;/td&gt;
&lt;td&gt;0.7k&lt;/td&gt;
&lt;td&gt;2026-07-20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/postalserver/postal" rel="noopener noreferrer"&gt;Postal&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;sending platform&lt;/td&gt;
&lt;td&gt;Ruby&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;16.7k&lt;/td&gt;
&lt;td&gt;2026-08-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/mailkite/server" rel="noopener noreferrer"&gt;MailKite Server&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;app / agent mail&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/dovecot/core" rel="noopener noreferrer"&gt;Dovecot&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;IMAP + store&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;LGPL-2.1 / MIT&lt;/td&gt;
&lt;td&gt;1.2k&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/rspamd/rspamd" rel="noopener noreferrer"&gt;Rspamd&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;filter&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;2.5k&lt;/td&gt;
&lt;td&gt;2026-08-04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/roundcube/roundcubemail" rel="noopener noreferrer"&gt;Roundcube&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;webmail&lt;/td&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;GPL-3.0&lt;/td&gt;
&lt;td&gt;7.1k&lt;/td&gt;
&lt;td&gt;2026-07-25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/knadh/listmonk" rel="noopener noreferrer"&gt;listmonk&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;newsletter&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;22.6k&lt;/td&gt;
&lt;td&gt;2026-08-03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/pentacent/keila" rel="noopener noreferrer"&gt;Keila&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;newsletter&lt;/td&gt;
&lt;td&gt;Elixir&lt;/td&gt;
&lt;td&gt;AGPL-3.0&lt;/td&gt;
&lt;td&gt;2.2k&lt;/td&gt;
&lt;td&gt;2026-08-01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What none of them fix
&lt;/h2&gt;

&lt;p&gt;Every project on this list will accept mail on port 25 within an hour. None of them can&lt;br&gt;
make anyone else accept mail from you, and that is where self-hosted email actually goes&lt;br&gt;
wrong. Four things decide it, and all four are outside the software:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port 25 is blocked&lt;/strong&gt; on most residential ISPs and on several cloud providers by
default. Check before you build. Some providers unblock on request, others never will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your IP has a history you didn't write.&lt;/strong&gt; Cloud IPs get recycled. A fresh instance can
arrive already on a blocklist because of whoever had it in March, and no amount of
correct configuration overrides that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PTR records live at your hosting provider&lt;/strong&gt;, not in your DNS zone, and they must match
the hostname your server announces. This is command 3 at the top of this page, and it's
the single most common miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warming is real for volume.&lt;/strong&gt; Sending a thousand messages on day one from a new IP is
the behavior of a spammer, and receivers model it that way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this argues against self-hosting. It argues for being honest about the job: the&lt;br&gt;
install is an afternoon, the reputation is a practice. A common and entirely reasonable&lt;br&gt;
split is to receive on your own server, where reputation doesn't matter much, and relay&lt;br&gt;
outbound through a service whose IPs are already warm.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pick
&lt;/h2&gt;

&lt;p&gt;The decision is almost never "which is best." It's "which sentence sounds like me."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs26odq61nramqckfyvv6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs26odq61nramqckfyvv6.png" alt="Decision guide: the sentence that sounds like you, and what to install" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're still undecided after that, the default that disappoints the fewest people is&lt;br&gt;
mailcow for human mailboxes and Postfix plus Dovecot plus Rspamd for anything you intend&lt;br&gt;
to operate for a decade. Both are boring in the way infrastructure should be.&lt;/p&gt;

&lt;p&gt;And if you got here because an application of yours needs to receive email rather than&lt;br&gt;
host it, that's a different problem with a different shape, which is&lt;br&gt;
&lt;a href="https://mailkite.dev/blog/mailkite-server-open-source/" rel="noopener noreferrer"&gt;why we open-sourced our answer to it&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://mailkite.dev/blog/open-source-email-servers-linux/" rel="noopener noreferrer"&gt;mailkite.dev&lt;/a&gt;. I'm Gabe; I build &lt;a href="https://mailkite.dev" rel="noopener noreferrer"&gt;MailKite&lt;/a&gt;, and &lt;a href="https://github.com/mailkite/server" rel="noopener noreferrer"&gt;MailKite Server&lt;/a&gt; (AGPL-3.0) is #18 on the list above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosting</category>
      <category>comparison</category>
      <category>smtp</category>
    </item>
  </channel>
</rss>
