The problem nobody wants to look at
Email is a solved problem. SMTP is from 1982. The standards for bounces (RFC 3464), spam complaints (RFC 5965), and DMARC reports (RFC 7489) have existed for decades. They're well-documented, narrow in scope, and run the entire internet's mail.
And yet — every cold-email SaaS, every transactional email API, every marketing platform secretly rebuilds the same handful of plumbing pieces. Badly. With dependencies. Behind paywalls.
I noticed this when I started running cold outreach for my own development agency. Every tool I evaluated charged me to solve problems I could see were already standardized. Validation: $0.008 per address. Bounce handling: built into the SaaS subscription. DMARC dashboards: enterprise tier. Tracking pixels: a feature, somehow.
Most of it is two pages of code.
So I wrote it.
What I built
open-mailgate — six small Go packages that cover the full pre-send and post-send loop around any mail transfer agent (Postfix, Exim, Sendgrid SMTP relay, whatever you've got).
mailgate/ → 3-layer email validator
├── suppression/ → "never email again" store
├── bounce/ → RFC 3464 DSN parser + Maildir scanner
├── fbl/ → RFC 5965 ARF parser + Maildir scanner
├── dmarc/ → RFC 7489 RUA parser + JSON rollup
└── engagement/ → HMAC-signed open pixel + click tracking
Total: ~4,500 lines of Go including tests. Zero third-party dependencies. Just the standard library.
The repo is here: github.com/ahmedEssyad/open-mailgate
This post is about what I learned writing it. If you want a feature tour, the README has that.
Lesson 1: The Go standard library is way more than enough
The biggest temptation in any new Go project is to reach for a library. There's a popular package for almost everything — go-mail, gomail, email-verifier, dmarcian-client, you name it.
I forced myself to use only net/smtp, net/mail, mime, mime/multipart, archive/zip, compress/gzip, crypto/hmac, crypto/sha256, and a handful of others. All standard library.
Result: zero dependencies in go.mod. A user who runs go get github.com/ahmedEssyad/open-mailgate adds nothing transitive. No supply-chain risk. No version churn. No abandoned upstream.
Was it harder? Slightly. Was it better? Significantly.
There were moments — parsing multipart MIME messages with multiple nested boundary types, dealing with the quirks of Yahoo's ARF reports — when I almost reached for a parser. Each time, the stdlib turned out to handle it.
This is the thing the Go community gets right and the JavaScript community gets wrong: the standard library is a feature, not a starting point you're supposed to leave behind.
Lesson 2: SMTP probing has fewer states than you think — and they all matter
The SMTP probe is the most "interesting" part of the validator. Here's the state machine:
- Dial MX on
:25→connect_failed - Read banner (220) →
blocked(421) - EHLO (or HELO on 502) →
blocked - MAIL FROM:
<probe@yourdomain>→blocked - RCPT TO:
<target>→250= mailbox accepted (need catchall probe)550/551/553= invalid (mailbox not found)552= invalid (mailbox full)450/451= unknown (greylisted)4xxother = unknown (blocked)5xxother = invalid (mailbox not found)
- RCPT TO:
<random-garbage@target-domain>250= catchall (we can't trust the real RCPT)5xx= not catchall; original verdict stands
- QUIT
The catchall probe is what makes this honest. Without it, every Gmail address comes back "valid" because Gmail accepts almost everything at the edge and bounces asynchronously. With it, you correctly mark the entire google.com domain as catchall — we can't tell from SMTP alone.
That's a more useful signal than a false positive.
The non-obvious bit: when does a probe failure mean "try the next MX" vs "this verdict stands"?
Naive answer: if err != nil, try the next MX. That's wrong. A read timeout on an open SMTP conversation is a network error, but trying the backup MX gets you the same answer from the same backend (most large providers share mailbox infrastructure across all their MXs). You'd just waste a connection.
Real answer:
func isConnectError(err error) bool {
var opErr *net.OpError
if errors.As(err, &opErr) {
if opErr.Op == "dial" {
return true
}
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
return false
}
Only retry on dial-phase failures. Once you've exchanged bytes with the server, the verdict is what it is.
Lesson 3: Bounce parsing is the one place where comments matter
Most of my code is sparsely commented — well-named functions and types do the explaining. But the bounce parser (bounce/dsn.go) is the exception. RFC 3464 is full of subtle "if X is present, prefer it over Y" rules that don't survive code review without a comment.
For example: a DSN can carry both Original-Recipient and Final-Recipient. Original is the address the sender wrote; Final is what it became after the receiver's rewriting (aliases, plus-extensions, address mapping). Which one do you want?
// Prefer Original-Recipient (envelope spelling) over
// Final-Recipient (post-rewrite). Both are in "type; value" form.
if v := headers["original-recipient"]; v != "" {
rec.Recipient = extractAfterSemi(v)
}
if rec.Recipient == "" {
if v := headers["final-recipient"]; v != "" {
rec.Recipient = extractAfterSemi(v)
}
}
You want the original — it's the address you know about and stored in your database. The final one might be jdoe-archive-2019@corp.internal while you sent to jane@company.com and never knew about the alias.
This kind of comment doesn't explain what the code does. It explains why the choice was made. That's the only kind of comment worth writing in 2026.
Lesson 4: Concurrency in batch validation needs two semaphores, not one
The validator's batch mode looks simple: validate N addresses in parallel, return the results in order.
The naive implementation uses one semaphore to cap total concurrency:
gate := make(chan struct{}, concurrency)
This works fine — until you batch-validate a CSV where 5,000 addresses share the same domain. Suddenly you have 32 goroutines all hammering gmail-smtp-in.l.google.com simultaneously. From Google's perspective, that looks like an attack. They'll rate-limit you, then blacklist your IP.
The fix is a second semaphore, keyed by domain:
var (
domainMu sync.Mutex
domainSems = make(map[string]chan struct{})
)
getDomainSem := func(domain string) chan struct{} {
domainMu.Lock()
defer domainMu.Unlock()
if s, ok := domainSems[domain]; ok {
return s
}
s := make(chan struct{}, perDomain) // default: 3
domainSems[domain] = s
return s
}
Now your overall pool can be 32 workers, but no more than 3 of them ever talk to the same MX at the same time. Same throughput on a diverse list, polite behavior on a concentrated one.
This is the kind of thing every engineer eventually figures out the hard way. Now it's in a library, default-on, so you don't have to.
Lesson 5: Know your threat model before applying defaults
The engagement package signs every tracking URL with HMAC-SHA256. The full 256-bit output produces 65 characters of base64 per link. That's a lot of bloat in every email.
I truncated to 128 bits. With 128 bits, the chance of a random guess validating is 1 in 2^128 — still far beyond brute-force range. For click tracking, where the threat model is a script kiddie flooding your analytics rather than a nation-state forging events, this is wildly overkill. And it saves 22 characters per URL.
Understand your threat model before applying defaults. Crypto best practices are calibrated for unknown stakes. When you know your stakes are low, spend the bits elsewhere.
What I deliberately did NOT build
Open-source projects fail more often from scope creep than from missing features. I made a list of things this library will never do, and put it in the README:
- DKIM signing. OpenDKIM is a battle-tested C implementation that's been deployed for 15+ years. Writing my own would be ego, not engineering.
- The MTA itself. Postfix is 25 years old. Anyone who tries to replace it in Go and isn't named Mike Hearn is going to ship something worse.
- Inbox placement testing. Gmail and Outlook only expose this in aggregate (Postmaster Tools, SNDS). I can't fake what they don't tell me.
- Automatic IP warmup. This requires a network of cooperating mailboxes. Mailwarm and Lemwarm own this category for $30/month. Beating them requires being a different kind of company.
The discipline of saying "no" to features is what keeps a library small enough to actually understand.
Who is this for?
- Engineers self-hosting email at small scale (under 10K messages/month) on Postfix/Exim/etc.
- Anyone who wants to validate addresses before hitting a paid API to save credits.
- Cold-email tool builders who need to own their own deliverability stack.
- Researchers and educators who want a clean, documented reference implementation of the relevant RFCs.
It's infrastructure for the people who already have a reason to want infrastructure.
What's next
I'm going to keep this library small. It will not grow into "open-source SendGrid." If you want SendGrid, use SendGrid.
Things I'd like to add over time:
- A DNSBL monitor package (~200 lines)
- More edge-case coverage in the DSN parser (Yahoo's ARF format has quirks)
- Benchmarks for the validator's batch mode
- An
examples/directory showing real wiring patterns
Things I will not add:
- A web dashboard (use
pkg/dmarc/Rollup.Handler()and stand up your own) - A hosted SaaS version (that's a different business)
- Configuration through YAML / environment / spaghetti (Go structs are fine)
- Logging frameworks (pass an
OnErrorcallback)
If any of this is useful to you, the repo is at github.com/ahmedEssyad/open-mailgate. MIT licensed. Issues and PRs welcome.
If it's not useful but you found the engineering choices interesting — that's also a good outcome. Sometimes the value of a small library is the conversation about what's in it and what isn't.

Ahmed essyad
the owner of this space
A nerd? Yeah, the typical kind—nah, not really.
View all articles by Ahmed essyad→Comments
If this resonated
I write essays like this monthly.