If you've ever tried to plug a legacy application, an old monitoring script, or an enterprise tool into a modern email delivery service like Mailgun, you've probably hit a brick wall: your app only speaks SMTP, but many cloud providers block outbound port 25 entirely.
Instead of dealing with heavy traditional mail transfer agents (MTAs) like Postfix or Exim just to relay transactional emails, I decided to build something simpler, lighter, and modern. Meet Mailgun Passport, a minimal SMTP-to-Mailgun bridge written in Go.
I coded this project from scratch in a little over two days to solve a very specific pain point. Here is how it's built, the technical hurdles I solved along the way, and why it's structured for production from day one.
The Architecture: Keeping it Simple
The core idea is straightforward:
-
Listen: A local SMTP server accepts incoming mail (e.g., on
127.0.0.1:587). - Parse: It intercepts the raw bytes, parses the MIME structure, and maps SMTP envelope recipients.
-
Forward: It translates everything cleanly into a call to Mailgun’s HTTP API (
/v3/{domain}/messagesor raw MIME).
The Tech Stack
- Language: Go 1.25+ (for top-tier concurrency, small binaries, and great standard libraries).
-
SMTP Server:
github.com/emersion/go-smtp—the gold standard for building Go mail servers. -
MIME Parsing:
github.com/jhillyerd/enmimeto cleanly extract text, HTML, headers, and attachments. -
Logging: Go's built-in structured logger (
log/slog).
Two Technical Challenges I Solved
Building an email bridge exposes you to the quirky realities of email RFCs. Here are two highlights from the codebase:
1. Bridging SMTP Envelopes and MIME Headers (RCPT TO vs Bcc)
In SMTP, recipients are passed via the envelope command (RCPT TO), which might not match the To or Cc headers inside the MIME message (especially for blind carbon copies, Bcc).
In parser.go, the application intelligently reconciles this: if a recipient from the SMTP envelope isn't explicitly listed in the MIME headers, it treats them as a Bcc automatically, ensuring no recipient gets dropped during the translation.
func (m *Message) applyEnvelopeRecipients() {
if len(m.To) == 0 && len(m.Cc) == 0 && len(m.Bcc) == 0 {
m.To = append([]string{}, m.RcptTo...)
return
}
// ... maps envelope RCPT TO into Bcc safely if missing from headers
}
2. Bulletproof Systemd Hardening
Because this service runs on Linux servers, security matters. The companion systemd service file is heavily sandboxed using modern Linux capabilities:
-
Privilege separation: Runs under a dedicated low-privilege user (
mailgun-passport). -
Port binding without root: Uses
AmbientCapabilities=CAP_NET_BIND_SERVICEso it can bind to privileged port 587 without running asroot. -
Filesystem lockdown: Enforces
ProtectSystem=strictand isolates temporary paths.
Getting Started & Testing Locally
Clone the repository and spin it up in seconds:
git clone [https://github.com/devrfc83/mailgun-passport.git](https://github.com/devrfc83/mailgun-passport.git)
cd mailgun-passport
cp .env.example .env
# Edit .env and add your MAILGUN_API_KEY and MAILGUN_DOMAIN
go run ./cmd/mailgun-passport
You can test it locally using swaks (the Swiss Army knife for SMTP transactions):
swaks --to dest@example.com --from you@yourdomain.com \
--server 127.0.0.1:587 --port 587 \
--header "Subject: Hello from Passport" --body "It works!"
What's Next?
This is just v0.1.0, built over a single weekend to solve a real infrastructure problem. It already supports native RPM/DEB packaging, structured logging, flexible configuration via .env or systemd, and dual parsing/MIME modes.
Check out the repository on GitHub: devrfc83/mailgun-passport.
If you encounter edge cases with legacy clients, find bugs, or have ideas for improvement, PRs, issues, and stars are very welcome!
About the Author
Hi! I'm Rodrigo Fuentealba Cartes, an entrepreneur, Bachelor of Engineering Sciences and Computer and Informatics Engineer. For more than two decades I've combined data analysis with software engineering to take projects from idea to production. Now I offer my services through a small company named Sea Dragon.
I'm also a chess player, musician, motorcyclist, amateur radio operator (license CA5NFS), and a low performance athlete.
You can find me around the web:
- Website: https://www.devrfc83.com/
- GitHub: @devrfc83
- Instagram: @devrfc83
Top comments (0)