Doing mail myself
When I shipped the day-two MVP, the auth flows talked to a Postfix instance bundled in the same container. I hadn’t thought hard about it. SMTP felt like one of those problems other people had already solved, and Postfix was the obvious thing to drop in. Most testing happened in dev mode anyway, where the email-verification token gets logged to stdout instead of mailed. So Postfix was a placeholder — wired up, mostly unused, sitting there waiting for me to come back to it.
When I did come back, the question I ended up with wasn’t “how should I configure Postfix” or “which mail-as-a-service should I sign up for.” It was: how complicated is this actually?
Why
Mail-as-a-service is a healthy market. There are a handful of vendors with usable free tiers, each with their own flavor of REST API or proprietary SMTP shape. They’re well-engineered. They handle deliverability, DKIM, suppression lists, all the stuff people who actually do email a lot have to think about.
The mail FeedFilters needs to send is small and boring, though. Account verification when someone signs up. Password reset when they ask for one. The occasional email-change confirmation. That’s the entire surface. None of it is marketing email; none of it has unsubscribe semantics; none of it cares about opens and clicks. It’s a few transactional templates fired at the user’s address when they ask for them.
So the trade I was being asked to make was: take on a vendor relationship, an external dependency, and an interface specific to whoever I picked, in exchange for problems I don’t actually have. SMTP is forty years old. It’s well-specified, well-documented, and Go’s standard library can speak it out of the box. I figured I’d see how far I could get on my own.
What “doing it myself” actually means
The standard mail-sending shape is layered. Your app talks SMTP to your relay; your relay does an MX lookup against the recipient domain; your relay opens an SMTP connection to whichever MX answers; your relay sends the message; if the recipient bounces, the bounce comes back to your relay’s domain and the relay tells you about it.
Direct-to-MX means the app does the middle steps itself. Look
up the recipient’s MX, open a connection to it, send the
message, handle the response. There’s no separate relay
process; the Go binary is the mail sender. For outbound-only
transactional traffic, that’s a remarkably small amount of
code, and Go’s net/smtp and net packages give you the
primitives.
Mine ended up structured as an outbox queue with a worker.
Calls into the package don’t send synchronously; they enqueue a
row in email_outbox and return. A background worker picks
rows up, does the MX lookup, opens a connection, opportunistically
does STARTTLS, sends, marks the row delivered or failed, and on
transient failures schedules a retry. On terminal failures the
row goes to bounced with a reason. The worker is
fault-tolerant in the boring ways: it survives process restarts
(the queue is in SQLite, the same as everything else), and it
doesn’t lose messages on the kinds of network blips that make
SMTP miserable.
DKIM signing
The flag that turned this from “works on test addresses” into
“actually deliverable” was DKIM. DKIM is a cryptographic
signature on the outbound message, generated with a private key
the sender holds. Receiving servers verify the signature
against a public key published as a DNS TXT record at
<selector>._domainkey.<sender-domain>. Combined with SPF
(which says “this IP is allowed to send for this domain”) and
DMARC (which tells receivers what to do when SPF or DKIM
fail), it’s the way mail in 2026 establishes that it isn’t
forgery.
In code it’s small. You generate a keypair, publish the public half in DNS, sign each outbound message with the private half. A Go library handles the protocol-level work; the integration into the outbox is a flag on the config.
In operations it’s bigger. The DNS records have to exist. The keys have to live somewhere durable. The selector has to match. Without all three, deliverability craters and your password-reset emails go to spam — which I learned by sending myself one. With all three, the major receivers actually do honor good DKIM.
Catching async bounces
Direct-to-MX has one major gap that a relay would normally cover. Sometimes the recipient’s MX accepts a message — returns 250 OK to the SMTP transaction — and then bounces it asynchronously, after some downstream filter or quota check rejects it. The recipient signals this by sending a DSN (a delivery-status-notification email) back to the envelope sender’s domain. If you’re running Postfix, Postfix accepts the DSN, parses it, and writes the result to its logs. The standard advice for “track bounces” is “tail those logs.”
That advice didn’t appeal to me. It meant coordinating between Postfix and the app — running the right log-shipping or log-parsing pipeline, getting the parsing right, dealing with format changes across Postfix versions, and ending up with bounce data in a place where it had to be stitched back to outbox rows after the fact.
The approach I took instead was to listen for the DSN myself.
The same Go process that sends mail also runs an SMTP server on
port 25, configured to accept incoming mail addressed to a
single bounce alias (bounces@feedfilters.net). When a DSN
arrives, the listener parses it, looks up the original outbox
row by the bounce token embedded in the alias’s local part, and
updates the row to bounced with the reason. No log shipping,
no parser maintenance, no inter-system coordination. The two
halves of the conversation — outbound delivery and inbound
bounce — happen in the same package, sharing the same
database.
The DNS side is one MX record pointing at the host. The
container side is exposing port 25 with cap_add: NET_BIND_SERVICE so the process can bind a privileged port.
That’s the entire infrastructure.
The outbox as the dashboard
Because everything runs through that one email_outbox table,
the table effectively is the operational dashboard. The admin
UI’s Email page is a render of recent rows: id, recipient,
subject, current state (pending / delivered / bounced /
failed), the SMTP transcript or bounce reason, and how many
attempts it’s taken. You can click into any row and see exactly
what happened and when. There’s also a small “send a test
email” form at the top, which has been the single most
useful piece of UI for verifying live deliverability after a
DNS or DKIM change.
None of this was a separate thing I built. It came almost free from the queue-worker architecture — the data was already there because the worker had to track state to do its job; surfacing it as an admin view took a page handler and a template. It’s now the first place I look when a user reports they didn’t get an email.
A small discipline worth mentioning: the body of the message is wiped from the outbox row the moment it reaches a terminal state. Before that, the body has to stay in the row so the worker can retry. After that, there’s no operational reason to keep it, and storing former password-reset email contents around indefinitely would be its own kind of problem. The status, the timestamps, and the bounce reason all stay; the body goes.
Dropping Postfix
The last commit in the chapter was deleting the Postfix sidecar. Once direct-to-MX was running cleanly in production for a few hours, the sidecar wasn’t doing anything any of the live traffic needed. Removing it took out a container, a docker-compose stanza, a couple of network ports, and a category of configuration drift I had been quietly nervous about. The deployment shape is one app process now, no helper.
Looking back
This was the most fun stretch of the project so far. The problem space had the right shape for me — bounded, well-specified, end-to-end testable, and full of operational details that were actually interesting to figure out. The end result is one Go package, one SQLite table, and one MX record. It does what mail-as-a-service was going to do for me, except I understand every part of it and nothing in the path costs me money or knows anything about my users beyond the fact that they signed up for a feed-filtering app.
That’s a worthwhile trade for FeedFilters. It might not be for a service that sends millions of marketing emails, where deliverability work is ongoing and a vendor’s reputation engine pays for itself. For an indie app sending a handful of transactional emails to people who asked for them, doing it yourself turns out to be reasonable.