The Postfix build in the last post is four packages wired together, with a milter socket, a SASL socket and a socketmap holding them in step. Stalwart does the lot in one binary. All of it.
SMTP, IMAP and JMAP, the mail store, SPF, DKIM, DMARC, ARC, DKIM signing, DANE, MTA-STS, TLS reporting, a spam filter and a Sieve engine, in one process you start once. No milter to wire, no policy daemon on a socket, no second package that has to agree with the first. For every rule in this series that took a separate tool on Postfix, Stalwart has the answer already inside it.
That is a real advantage, and this post does not pretend otherwise. But “built in” and “safe by default” are different claims, and the gap between them is the whole of this post. I held Stalwart 0.16.27 to exactly the house rules the Postfix build met, in the same lab, and captured every reply. The rules all go on. Getting them on is another matter.
The Settings Are A Database, Not A File
Start with the thing that surprises everyone, because it shapes everything after. Postfix is configured in main.cf, a text file you can read, diff and copy to the next server. Stalwart has one configuration file, and this is all of it:
{"@type":"RocksDb","path":"/var/lib/stalwart/"}
That names the database. Every other setting, the listeners, the TLS policy, the spam rules, the Sieve scripts, lives inside that database, and you change it over the JMAP API or with stalwart-cli, followed by a reload:
stalwart-cli apply --file house-policy.json
stalwart-cli create Action/ReloadSettings
There is a web interface too.1 The configuration is state in a running service, not a file on disk, and that cuts both ways. You get a live API and no daemon to reload by hand. You lose what a text file gives you for free: a diff, a comment, a commit, a known-good copy to redeploy. People who run Stalwart declaratively end up keeping the plan file that apply consumes under version control, which is the right instinct, and also exactly the file-based config they started by not having.
What It Accepts As Shipped
Before the rules go on, here is Stalwart doing what it does out of the box. It is the fair baseline, and it is not reassuring.
| As shipped, Stalwart… | |
|---|---|
| a plaintext session, no STARTTLS | accepted |
| a tampered signed invoice | accepted |
| a message that fails SPF | accepted |
| a forgery of its own domain | accepted |
| a sender domain that does not exist | accepted |
| delivery to a cleartext-only server | sent in the clear |
| an open relay on port 25 | refused |
| a login with the wrong envelope sender | refused |
a login forging the From: header | accepted |
| outbound to an invalid certificate, on retry | accepted |
Two of those refusals are the only good news in the column, and a logged-in user could still forge the visible From:.
None of that is unique to Stalwart. It is the same set of permissive defaults almost every mail server ships with, for the same twenty-year-old reason, and it is the entire argument of the lead post in this series: /en/email/why-every-mail-setting-has-to-be-right/. A server that does everything is not a server that does everything safely. That is still yours to configure.
Four Places To Put A Rule
Here is where the one-binary design gets genuinely different to work with. On Postfix, almost every inbound rule is a smtpd_*_restrictions line or a milter. On Stalwart, the same set of rules lands in four separate mechanisms, and you need all four.
An expression language, a tag-to-action map and a Sieve script are three different syntaxes, and that is before the listener settings. One binary, four things to learn. But with the policy applied, the captures line up rule for rule with the Postfix build:
| Rule | Stalwart’s reply |
|---|---|
| No plaintext in | 550 5.7.1 Sender address not allowed. |
| Relay on port 25 | 550 5.1.2 Relay not allowed. |
| Relay on 587 without a login | 503 5.5.1 You must authenticate first. |
| Login sending as another envelope sender | 501 5.5.4 You are not allowed to send from this address. |
Login forging the From: header | 503 5.5.3 You may only send as your own address |
| A signed, intact, aligned message | accepted |
| A tampered signed message | 503 5.5.3 DKIM signature does not verify |
| An SPF hard fail | 550 5.7.1 … excessive spam score. |
Every reply is from the running lab server, over IPv4 and IPv6. The rules hold. Now the three places where getting there taught me something.
DKIM Failure Needs A Sieve Script, Not The Obvious Setting
Stalwart has a built-in DKIM result and a spam tag for a failed signature, DKIM_REJECT, and the obvious thing is to reject on it. It does not catch the case that matters. When a signed message has its body changed in flight, which is the invoice-fraud case the whole series keeps coming back to, Stalwart’s DKIM verifier reports the result as neutral, not fail. Reading the mail-auth source, a body-hash mismatch lands in the neutral bucket, so the reject tag never fires and the tampered message is delivered.2
So the DKIM rule is a Sieve script instead, which looks at the verification result directly and refuses anything that is not a clean pass or a genuine absence of signature:
if anyof (string :is "${env.dkim.result}" "neutral",
string :is "${env.dkim.result}" "fail",
string :is "${env.dkim.result}" "permerror") {
ereject "DKIM signature does not verify: the message was changed or forged";
stop;
}
Unsigned mail leaves the result empty and is left alone, because unsigned is not a DKIM failure; DMARC at p=reject handles that. With the script in place, the tampered invoice is refused at DATA:
B REFUSED (503, b'5.5.3 DKIM signature does not verify: the message was changed or forged')
The same script carries the From:-header rule, since comparing the header against the login is a Sieve job too. Two rules, one script, and neither is the setting you would first reach for.
The Unknown-Sender Check Is Weaker Than It Looks
On Postfix, a sender domain that does not exist is refused at MAIL FROM, before a byte of the body is sent, with a clean 550 5.1.8 Domain not found. Stalwart gets to the same refusal, but later and more fragile.
The check is a spam tag, FROMHOST_NORES_A_OR_MX, that fires when the sender’s domain resolves to no A and no MX record. The refusal therefore comes after the whole message has been received, as a spam-score rejection:
550 5.7.1 Message rejected due to excessive spam score.
That is the right answer by a longer road, and two things about it matter. First, reading the spam-filter source, the check only runs once the sender address passes an “is valid” test, where valid means the domain sits under a known public suffix, so a made-up top-level domain skips the A-and-MX check entirely. Second, and this is the one that bites: the lookup treats a DNS error differently from a DNS absence. A domain returning SERVFAIL, rather than a clean “no such name”, gets no tag, and the message is accepted. Postfix defers it. Stalwart lets it in. That is a fail-open on exactly the ambiguous case a security control exists for, and the one place in this build where Stalwart is measurably less safe than the Postfix equivalent.
IPv6 Is A Fallback Until You Change It
IPv6 is mandatory in this series, both directions. Stalwart listens on both families without fuss. Outbound is the catch: the shipped route resolves IPv4-first, so a dual-stack destination is reached over IPv4 and IPv6 is only ever a fallback. In the same lab, Postfix used IPv6. Stalwart used v4 until the route was switched to prefer v6:
"ipLookupStrategy": "v6ThenV4"
After that, delivery to a dual-stack server goes over IPv6, confirmed in the logs reaching the v6 address. It is one setting. It is also not the default, and on the IPv6 mandate the default is the wrong way round.
MTA-STS Enforces, And Bounces Too Soon
Stalwart publishes its own MTA-STS policy, which is a genuinely nice touch, no separate web server needed the way the Postfix build needs nginx. But it ships in testing mode, which enforces nothing, and it is served under the mail host’s certificate rather than one that matches the policy host name, so a verifying fetch fails until you fix both. Set the mode to enforce and load a certificate for the mta-sts host, and the policy is served and checked properly:
mta-sts.policy-fetch strict = true
mta-sts.authorized
The sending side enforces too, and refuses a destination whose policy does not list its MX. But it refuses the wrong way: a mismatch is a permanent failure on the first attempt, and the message bounces. RFC 8461 is explicit that it should not:
In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later.3
Stalwart does re-fetch the policy before failing, so the standard’s hard rule, that you check for a new policy first, is met. The softer SHOULD, that you treat the failure as temporary and retry, is not. The cost is a bounced message where a deferral would have been kinder, on a failure that is usually transient.
Outbound TLS: Required, Then Stronger Where It Can Be
Like the Postfix build, the outbound side makes encrypt the floor and lifts each destination above it when the destination earns it. Stalwart’s MtaTlsStrategy sets a level for each mechanism, and the house policy is:
| Mechanism | House setting | Effect |
|---|---|---|
| STARTTLS | require | never send in the clear, to anyone |
| DANE | optional | authenticate where a signed TLSA is published |
| MTA-STS | optional | honour an enforced policy where one is published |
| Invalid certs | off | a certificate that does not verify defers the mail |
require on STARTTLS is the floor the shipped default lacks: out of the box, the outbound strategy fell back to accepting an invalid certificate on retry. With the floor set, a destination that offers no usable TLS is deferred, not downgraded.
DANE Is There, But The Lab Could Not Prove It
The strongest outbound control, DANE, is built in like the rest, and its outbound TLS fails closed the way it should: pointed at a server whose certificate it could not verify, Stalwart refused and held the message rather than send in the clear. What the sealed lab could not do is show DANE succeeding, and that is a real constraint worth recording.
Stalwart only turns DANE on when it can validate the DNS root key itself, through a resolver connection that is not plain UDP. A private lab root cannot offer that, so it disables DANE and logs a warning to a path that does not exist by default. Easy to miss. Worse, for a signed zone it then reports the TLSA records as “not DNSSEC signed” when the real cause is its own resolver. The message blames the wrong thing.
Stalwart ships a read-only delivery diagnostic for exactly this: an endpoint that runs the whole outbound path and never sends a message. It streams each step as it goes:
GET /api/live/delivery/<domain>?timeout=20
| Step | What it reports |
|---|---|
| MX lookup | the mail servers, and whether the lookup validated |
| MTA-STS | the policy fetched, and whether the MX is authorised |
| TLS-RPT | the reporting address the domain publishes |
| TLSA | the DANE records, and whether they are DNSSEC signed |
| Connect, EHLO, STARTTLS | the session up to TLS, with the cipher |
| DANE verify, QUIT | the certificate match, then a clean hang-up |
No MAIL FROM, no RCPT TO, nothing delivered. It is the check to run against a real DANE domain to see the positive case this lab cannot show.
Proving the DANE positive case means running that against real DANE-publishing domains from an internet-connected instance. It is the one test in this series a sealed lab cannot do, and the honest edge of what a lab can show you.
So Is Built-In Better?
Here is the thing the whole post has been circling. Built in is less to install, less to wire, less to keep in step across versions, and there is real value in a single process where the SPF result is simply there for the DKIM check and both are there for the Sieve script, no socket between them. For a small deployment that wants one thing to run and update, it is a genuinely good answer, and nothing above is a reason not to use it.
But every hard part of this series survived the move into one binary. The defaults still ship permissive. The rules still have to be found and turned on, across four mechanisms instead of one. DKIM-fail rejection still needs a hand-written script. The unknown-sender check still fails open on a DNS error. MTA-STS still bounces when it should defer. None of that got easier because the code lives in one process. These are properties of the problem, not the packaging.
Line the two builds up and the pattern is plain:
| Postfix build | Stalwart | |
|---|---|---|
| Parts to run | 4 packages, 3 sockets | 1 binary |
| Configuration | main.cf, a text file | a database, over an API |
| SPF, DKIM, DMARC | Rspamd milter | built in |
| DANE, MTA-STS out | postfix-tlspol | built in |
| MTA-STS publishing | separate nginx | built in |
| DKIM-fail reject | Rspamd force action | a Sieve script (built-in tag misses it) |
| Unknown sender | 550 at MAIL FROM | 550 after DATA, fails open on SERVFAIL |
| MTA-STS mismatch out | defers, retries | bounces on the first attempt |
| IPv6 outbound | used it by default | fallback until switched to v6-first |
| Defaults as shipped | permissive | permissive |
Which is the honest conclusion, and the same one the Postfix post reached by a different road. What makes mail safe is not the number of binaries. It is somebody deciding each rule matters, finding where it lives, turning it on, and sending the message it is meant to stop to check that it did. Stalwart lets you do all of that, in one process, over a tidy API. It will also, left alone, accept a plaintext session, a forged sender and an altered invoice, exactly like everything else. The binary is one. The job is not.
Stalwart documentation — configuration over the JMAP management API and
stalwart-cli, with settings held in the data store rather than a file. Version 0.16.27 used here. ↩︎mail-auth, the library Stalwart uses for DKIM — its DKIM verifier reports a body-hash mismatch as a neutral result rather than a failure, which is why a reject on the failure tag alone does not catch a tampered body. ↩︎
RFC 8461 §5 — SMTP MTA Strict Transport Security: on a policy-validation failure, “In all cases, MTAs SHOULD treat such failures as transient errors and retry delivery later”, and a message “MUST NOT be permanently failed” before the sender has checked for an updated policy. ↩︎