Self-hosting email with Stalwart: deliverability and data-store traps
Blog · self-hosting

Self-hosting email with Stalwart: deliverability and data-store traps

A Gmail relay, broken DKIM, DMARC rejections, bouncing reports, a rate-limited alert channel and a mail store that depended on one stale password

In October 2026 Gmail started rejecting mail from my own domain with 550 5.7.26, the DMARC failure code. My self-hosted mail server had been running since spring, serving 9 accounts on 2 domains. When I looked at the DKIM key published in DNS, it had lost every letter “n” and had never decoded. Not once, for any receiver.

That was one of about a dozen traps I hit running Stalwart Mail Server at home. Some were about deliverability: relays that rewrite headers, signatures that break, reports that bounce. Others were about operations: a rate limiter that throttled my own alerts, TLS certificates that renewed everywhere except in the mail server, and a database password that would have taken the whole mailbox store down on the next restart. This post collects them, with the mechanism behind each one.

Contents · 13 sections

TL;DR #

  • From a residential dynamic IP, direct outbound delivery is close to hopeless. Receive directly; send through a relay.
  • A relay that rewrites headers breaks any DKIM signature made before it. A broken signature is worse than none.
  • The big mailbox providers now require sender authentication, and in October 2026 Gmail rejected my mail outright over DMARC. Fix alignment, not just signing.
  • Sign only headers the relay does not touch, and verify the public key in DNS actually decodes.
  • Default outbound reports (TLS-RPT, DMARC aggregates) can bounce every day if they go through the same relay.
  • Rate limits for strangers should not apply to your authenticated senders.
  • Stalwart can keep all mail data in PostgreSQL. Then the database password is the mail server.
  • If TLS is renewed by your reverse proxy, the mail server needs a sync job, or IMAP clients freeze when the old certificate expires.

The architecture, and why #

Inbound mail arrives directly at Stalwart, which is the MX for both domains. Outbound mail goes through Gmail’s SMTP relay.

That split is deliberate. My internet connection has a residential, dynamic address. Mail sent directly from residential address space is widely blocked or spam-foldered regardless of how well it is signed: such ranges are on policy block lists precisely because they are not supposed to run mail servers. So I borrow the reputation of a big relay for sending, and keep receiving local, so private mail like password resets never leaves the house.

The trade-off is that the relay is now the last hop that touches every message, and it changes them.

Trap 1: the relay rewrites the From #

Gmail’s relay keeps a custom From: address only if that address is a verified “Send mail as” alias of the Gmail account used to authenticate. Any other sender is rewritten to the Gmail address. Recipients of my service mail saw my personal Gmail address instead of the service’s address.

The fix was to add each service sender as a verified “Send mail as” address. Gmail confirms an alias by emailing a link to it, and those mailboxes are on my own server, so a small script reads the confirmation mail from inside a pod that already has the service’s credentials. No password goes through my terminal.

Trap 2: signing before the relay breaks the signature #

In May I set up DKIM signing in Stalwart. Version 0.11.8 had three syntax surprises that cost hours, because my backup config came from an older version:

  • signature IDs are generated as {algorithm}-{domain}, for example rsa-example.com;
  • the “which key signs” rule must be an indexed expression (numbered if, then and else keys), not a TOML array;
  • %{file:...}% macros work only in the config file, not in values stored in the settings database, so keys stored there must be inline PEM.

Signing then worked, and broke delivery. The relay changes From: for non-alias senders and Message-ID: for alias senders. Both invalidate a signature made before the relay. A receiver that sees dkim=fail treats it worse than no signature at all, and Yahoo rejected those messages. I turned signing off again.

Trap 3: DMARC enforcement arrives #

That arrangement worked until October, when Gmail began enforcing my domain’s p=reject DMARC policy on mail that it was itself relaying. For an alias sender the From: is my domain, but SPF passes for the Gmail envelope domain and DKIM is signed by gmail.com. Neither is aligned with my domain, so DMARC fails, and with p=reject the result is 550 5.7.26.

The fix has two parts.

Sign only what the relay leaves alone. Gmail rewrites Message-ID and Date on relayed mail, so the Stalwart signature now covers only From, To, Subject, MIME-Version and Content-Type. With d= set to my domain, an intact DKIM signature aligns with the From: domain and DMARC passes.

Check the DNS record decodes. The public key under <selector>._domainkey had lost every letter “n” in both domains. The record looked like a key and was not one. It is now published by a script through the DNS provider’s API and read back to compare. You can check yours:

dig +short TXT <selector>._domainkey.example.com | tr -d '" ' | sed 's/.*p=//' | base64 -d | openssl pkey -pubin -inform DER -noout && echo "key decodes"

After both fixes, a signed test message was accepted by Gmail, where the unsigned one had been rejected.

One more lesson from the key script: it built a list of keys with printf '%s.%s' over a list of values. printf reuses its format string and consumes two arguments per pass, so the generated list was wrong. The update ran inside a transaction and rolled back safely. Loop explicitly when building lists for SQL.

Trap 4: reports that bounce every day #

After the DMARC fix, my Gmail inbox received a “Delivery Status Notification (Failure)” every morning.

Stalwart’s defaults send daily aggregate reports (TLS-RPT and DMARC) to any domain that publishes a reporting address. Those reports go out from a no-reply address on my domain. Through the relay, that address is not a “Send mail as” alias, so its From: is rewritten, my DKIM signature breaks, and Yahoo’s report mailbox answers 550 5.7.9 because it requires DKIM. The bounce comes back to the relay account: me.

Sending reports is a courtesy, not a requirement, so outbound reporting is now off (report.*.send = false for TLS, DMARC, DKIM and SPF reports). Receiving reports is unchanged.

The next morning there was one more bounce. The report window for that day had opened before the fix, so the report was already being prepared. A verification note written by my local AI agent the day before had already declared the fix confirmed, for a run that had not happened yet. The real confirmation was the run after that. When you verify a daily job, wait for a run that started entirely after the change.

Trap 5: my own alerts were rate-limited #

Stalwart has a sender rate limiter. Mine allowed 25 messages per hour per sender domain and recipient. That is a sensible limit for unknown servers sending to my users. It also applied to Alertmanager, which authenticates and sends alert mail to me. During a busy hour, alerts got 452 temporary failures.

The limiter now applies only to unauthenticated sessions:

[queue.limiter.inbound.sender]
match = "is_empty(authenticated_as)"

In Stalwart 0.11 such settings live in its settings store, not only in the config file, so check where a value really comes from before editing.

Trap 6: the database password is the mail server #

Stalwart can keep everything (message blobs, metadata, full-text index, lookups) in PostgreSQL. Mine does. Its database password sat in plain text in the config file.

In October that password was rotated in the database. Stalwart kept working, because its existing connections were already authenticated. A config reload showed the authentication failures. The next restart would have taken the entire mail store offline, with no error until then.

The config file no longer holds the value. The store’s credential setting uses Stalwart’s environment macro, %{env:<VARIABLE>}%, and the variable is filled from a Kubernetes Secret. The rotation runbook updates the Secret and restarts Stalwart (about 30 seconds of downtime with a Recreate strategy). The general version of this trap is in passwords that only fail after a reboot.

Trap 7: certificates renewed, but not for the mail server #

My reverse proxy renews certificates automatically. Stalwart read static certificate files from its own volume. Nothing connected the two. On 25 May both mail certificates expired.

Webmail kept working, because it talks to Stalwart over its HTTP API. The Gmail app on my phone silently stopped syncing at the expiry date. It looked like a mailbox state problem; it was a failed TLS handshake on the IMAP port.

A weekly CronJob now reads the renewed certificate from the proxy’s store, compares fingerprints with the Kubernetes TLS Secrets Stalwart mounts, patches them and restarts Stalwart only when something changed, and alerts on failure. In June the proxy renewed between two weekly runs, so a manual run of the same job was needed. A daily schedule, or a check of the served certificate’s expiry, closes that gap.

openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate

A small one: folder names #

Gmail’s Android app could not sync my spam folder. It was named “Junk Mail”, with a space. Renaming it to “Spam” with the right special-use flag fixed the server side, but the app caches folder mappings, so the account had to be re-added on the phone.

Checklist #

  • Send through a relay from residential IPs; receive directly.
  • Know exactly which headers your relay rewrites, and sign only the others.
  • Check DMARC alignment (the d= domain and the From: domain), not only “DKIM present”.
  • Decode the published DKIM key from DNS at least once.
  • Turn off outbound reports if they cannot pass your own authentication.
  • Scope rate limits to unauthenticated senders.
  • Inject the mail store’s database password from a Secret and include the mail server in every rotation.
  • Sync TLS certificates from the component that renews them, and alert on the served expiry date.

Frequently asked questions #

Can I send email directly from a home internet connection? #

Technically yes, practically rarely. Residential ranges are listed on policy block lists and many receivers reject or spam-folder mail from them. Use a relay or a small server with a clean static IP.

Why does my DKIM signature fail after a relay? #

Because the relay changed a signed header or the body. Find out which headers it rewrites (for Gmail: From for non-aliases, Message-ID and Date) and leave them out of your signature, or let the relay sign.

What does 550 5.7.26 mean from Gmail? #

The message failed DMARC for a domain whose policy is reject or quarantine: neither SPF nor DKIM passed with a domain aligned to the From: address.

Why did my phone stop syncing mail while webmail still works? #

Often an expired or invalid certificate on the IMAP port. Webmail reaches the server another way, so it keeps working. Check the certificate served on port 993.

Should a self-hosted mail server send DMARC aggregate reports? #

Only if they authenticate properly. Reports are optional; receiving them is the useful half for a small domain.

The lesson #

Self-hosted email works, but every hop is a contract you did not write. The relay rewrites what it wants, receivers enforce policies on their own schedule, and your own components (the proxy, the database, the rate limiter) each hold a piece of the mail server’s health. Write down which component owns each piece, check the published records and the served certificates from the outside, and test every fix on a run that starts after it.