We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.Privacy Policy

EvilMailEvilMail
FeaturesEverything EvilMail can doTemp Mail for…Temp mail for Discord, ChatGPT & moreNetwork ToolsDNS, WHOIS, port & network toolsCompareEvilMail vs other temp mail services
APIREST API reference & guidesTemp Mail APICopy-paste code in every language
AppsPricingBlog
About UsWho we are & what we buildTrust & SecurityHow we protect your dataFAQAnswers to common questionsContactGet in touch with the team
LoginRegister
EvilMailEvilMail
AppsPricingBlog
LoginRegister
HomeBlogRspamd + Postfix over Milter: the Proxy Worker Done Right
Tutorial

Rspamd + Postfix over Milter: the Proxy Worker Done Right

Most guides bolt rspamd onto Postfix and stop at "it works." The correct 2026 default is the milter path through the rspamd proxy worker in self-scan mode: one ingress that scans inbound and DKIM-signs outbound. Here's the exact wiring, the macros that silently kill scoring, and how to read the verdict from headers instead of guessing.

EvilMail Team
July 21, 2026
13 min read
Rspamd + Postfix over Milter: the Proxy Worker Done Right
Contents
Milter beats the alternatives, and here's the mechanismThe proxy worker is the front doorWiring Postfix to the socketThe macros rspamd needs — the silent scoring killerReading the verdict: actions, thresholds, headersSame path, outbound: DKIM signing without a second daemonVerify it end to end

A message hits your MX. Before Postfix writes a queue entry it can never cleanly bounce, you want that message scanned, scored, maybe greylisted, maybe rejected outright — while the sending server is still on the line and can be handed a real 5xx. That timing is the entire decision. Do it after the queue and every rejection becomes a bounce to a forged return-path: backscatter you'll spend the next month explaining to blocklist operators. Do it in-line, during the SMTP conversation, and a spam verdict turns into a 550 the remote MTA has to deal with itself.

That in-line hook is the milter protocol, and rspamd's own recommended way to use it is the proxy worker. If you are still running spamass-milter in front of SpamAssassin, or the old amavisd-new sandwich with a separate opendkim daemon, you are running three processes to do what one socket does now. This is the wiring, the failure modes, and how to actually read what rspamd decided.

Milter beats the alternatives, and here's the mechanism

A milter is a callout. During the SMTP transaction Postfix connects to a milter socket and consults it at each protocol phase — CONNECT, HELO, MAIL FROM, RCPT TO, DATA, and end-of-message. At each stop the filter can accept, reject, tempfail, quarantine, or ask Postfix to add headers. The one that matters is end-of-message: rspamd has now seen the full body and every macro, computes a score, and returns a verdict Postfix enforces *before* it acknowledges DATA with a 250.

Milter handshake across the SMTP conversation Postfix consults rspamd at each phase; the verdict lands at end-of-message Remote sender Postfix smtpd rspamd proxy :11332 CONNECT / HELO MAIL FROM / RCPT TO DATA + body end-of-message action: no action / add header / greylist / reject (score 15+) 550 5.7.1 at DATA — no backscatter

Weigh the alternatives honestly. spamass-milter is a second daemon that shells out to SpamAssassin, has no native DKIM signing, and historically passes fewer macros, so its authentication context is weaker. LDA-time filtering (sieve, procmail) runs *after* Postfix has already accepted the message — far too late to reject, so your only options are silent discard or a spam folder, both of which cost you legitimate mail you can never explain. The milter path is the only one where "this is spam" and "the sender learns about it right now" are the same event.

The proxy worker is the front door

This is the single most misunderstood piece of an rspamd deployment. rspamd is not one process; it is a set of workers, each on its own port, and only one of them speaks milter. Postfix talks to the proxy worker on `11332` and nothing else.

  • `11332` — proxy worker (milter): the front door for Postfix.
  • `11333` — normal / scan worker (HTTP): does the actual scanning over rspamd's HTTP protocol. Postfix never connects here.
  • `11334` — controller (WebUI, learn, stats): the dashboard and the rspamc learn_spam / learn_ham endpoint.
  • `11335` — fuzzy_storage: the fuzzy-hash database.

There are two topologies, and self_scan is the switch between them.

Self-scan vs proxy topology — Postfix always hits :11332 Single box — self_scan = yes Postfix proxy :11332 milter in-process normal :11333 controller :11334 fuzzy :11335 proxy scans locally; still needs normal worker for stats + bayes learning Scale-out — proxy mode Postfix proxy :11332 no self_scan scan A :11333 scan B :11333 scan C :11333 proxy forwards milter session to a remote scan cluster

For a single-box mail server — almost everyone — you want self_scan = yes. The proxy scans the message itself on the in-process scan path instead of proxying the milter session to a remote node. The config lives in /etc/rspamd/local.d/worker-proxy.inc:

nginx
milter = yes;
timeout = 120s;
upstream "local" {
  default = yes;
  self_scan = yes;   # single box: scan in the proxy, don't forward to 11333
}

Drop self_scan, add hosts = "...", and the proxy becomes a pure router that forwards the milter session to a scan cluster on 11333 for horizontal scale. One trap survives even in self-scan mode: you still need the normal worker running, because bayes learning and rspamc stat go through it. Postfix just never touches it directly.

Wiring Postfix to the socket

Everything on the Postfix side lives in main.cf — the minimum that works, plus the parts most people forget:

ini
smtpd_milters      = inet:localhost:11332
non_smtpd_milters  = inet:localhost:11332
milter_protocol    = 6
milter_default_action = accept
milter_mail_macros = i {auth_authen} {auth_author} {auth_type} \
                     {mail_addr} {client_addr} {client_name} {sasl_username}

milter_protocol = 6 is the correct value for modern Postfix (2.6+). Fall back to protocol 2 or 4 and the milter still connects and appears to work while silently dropping macros — which degrades scoring in ways that are miserable to debug. Use inet:localhost:11332 unless you have a reason to prefer a unix socket; the socket saves a TCP round trip, but then you have to get the filesystem permissions right so postfix can reach a path rspamd owns.

milter_default_action is the fail-mode decision, and it is a real policy choice, not a default to leave alone. With accept, if rspamd is down or times out, Postfix passes the mail through unscanned — you fail open, never lose legitimate mail, and eat whatever spam arrives during the outage. With tempfail, Postfix hands back a 451 and the sender retries later, so nothing goes unscanned, but a prolonged rspamd outage backs your queue up and delays real mail. Most operators should run accept and *alert on rspamd being down* rather than let a filter crash become a mail outage. If you handle mail where an unscanned message is genuinely worse than a delayed one, choose tempfail and mean it.

Pointing non_smtpd_milters at the same socket is deliberate: locally-submitted and authenticated-submission mail flows through rspamd too, which is how outbound gets DKIM-signed. The catch is that authenticated mail must never be spam-rejected — which brings us to the macros.

The macros rspamd needs — the silent scoring killer

Postfix does not volunteer everything to a milter. You have to name the macros you want passed, and if you skip the authentication ones, rspamd treats your own logged-in users as strangers off the street. The critical one is {auth_authen} — the SASL login name. Without it, the settings module rule that whitelists authenticated senders never matches, and your customers' outbound mail gets scored, greylisted, and occasionally rejected as if it came from a random botnet.

ini
milter_mail_macros        = i {auth_authen} {auth_author} {auth_type} \
                            {mail_addr} {client_addr} {client_name} {sasl_username}
milter_end_of_data_macros = i {auth_authen}

With {auth_authen} flowing, a settings rule like this actually fires:

hcl
authenticated {
  authenticated = yes;
  apply { actions { reject = null; greylist = null; } }
}

Read it as: "if the session authenticated, disable the reject and greylist actions" — outbound mail from your users gets DKIM-signed and delivered, never bounced by your own filter. rspamd also leans on {daemon_name}, {v}, the queue id i, {cert_subject}, and the address macros to build SPF/DKIM/DMARC context and per-user settings. If your scores look random, check the macros before you touch a single symbol weight.

Reading the verdict: actions, thresholds, headers

rspamd maps a numeric score to an action. The defaults, tunable in /etc/rspamd/local.d/actions.conf, are guidelines, not scripture:

hcl
greylist = 4;
add_header = 6;
rewrite_subject = 8;
reject = 15;

Below greylist it's "no action." These numbers are worth tuning against your own corpus once you have volume — reject at 15 is deliberately conservative so you don't reject on a single noisy symbol.

The verdict is visible in headers rspamd injects via the milter_headers module. On any delivered message, X-Spamd-Result is the one to read: it lists every symbol that fired with its individual weight and the total, so you can see *why* a message scored what it did instead of guessing. You'll also see X-Spam (Yes/No), X-Spam-Status, and X-Rspamd-Queue-Id for correlating a header back to a log line. A raw dump:

text
X-Spamd-Result: default: False [2.10 / 15.00];
 DKIM_SIGNED(0.00), MIME_GOOD(-0.10), R_SPF_ALLOW(-0.20),
 RCVD_IN_DNSWL_LOW(-0.10), BAYES_HAM(-1.20), MIME_TRACE(0.00)
X-Spam: No
X-Rspamd-Queue-Id: 4Wp8Kf2xYzz

Same path, outbound: DKIM signing without a second daemon

Because the proxy worker sees submission traffic — via non_smtpd_milters, keyed on {auth_authen} — the dkim_signing module signs on the way out over the exact same socket. This replaces opendkim entirely: one daemon, one milter, no separate signing socket to keep in sync.

Generate a key and configure the selector:

bash
rspamadm dkim_keygen -s 2026a -b 2048 -d evilmail.pro \
  -k /var/lib/rspamd/dkim/evilmail.pro.2026a.key

/etc/rspamd/local.d/dkim_signing.conf:

hcl
selector = "2026a";
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
allow_username_mismatch = true;
use_domain = "header";

Then publish the public half at 2026a._domainkey.evilmail.pro as a TXT record — v=DKIM1; k=rsa; p=MIIB... (the keygen prints it, split across quoted strings if the 2048-bit key overflows 255 chars). Once it validates, systemctl stop opendkim && systemctl mask opendkim; it is dead weight.

Verify it end to end

Run through this before you call it done:

  • 1.Config parses: rspamadm configtest — must say syntax OK.
  • 2.Workers bound: ss -lntp | grep -E '1133[2-5]' — you should see all four ports listening. If 11332 is missing, Postfix has nothing to talk to.
  • 3.Postfix sees the milter: postconf -n | grep milter — confirm the socket, milter_protocol = 6, and your macro line.
  • 4.Forced reject: send a test message containing the GTUBE string XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X and watch tail -f /var/log/rspamd/rspamd.log | grep -E 'action:|reject'. You should see action: reject and, in the Postfix log, a 550

One operational note to close. The failure you will actually hit in production is not a mis-weighted symbol — it's a missing macro or a fail-open timeout. Never edit /etc/rspamd/rspamd.conf directly; it gets overwritten on upgrade, so override in local.d/*.inc and override.d/. And because milter_default_action = accept means a crashed rspamd silently passes every message unscanned, your monitoring should alert on the daemon being down and on your fail-open state — not on spam scores.

Share
𝕏 TwitterLinkedInReddit
PreviousRspamd Rate Limiting and Per-User Sending Caps: Contain a Compromised Mailbox Before Spamhaus DoesNextCatching Compromised Mailboxes: Outbound Spam Detection with rspamd and Volume Baselines

Related Articles

Tutorial

imapsync at Scale: Folder Mapping, Quota, and Rate-Limit Gotchas for 500-Mailbox Migrations

13 min read
Tutorial

Splitting the Postfix Queue Across Per-Destination Transports

12 min read
Tutorial

Running Two Postfix Instances on One Host: Inbound Filtering vs. Outbound Sending

11 min read
EvilMailEvilMail

EvilMail — free temp mail and disposable email service. No registration required.

Product

FeaturesPricingAPITools

Resources

BlogFAQGlossaryRegisterAbout Us

Legal

Privacy PolicyTerms of ServiceTrust & SecurityContact
© 2026 EvilMail. All rights reserved.
returned at DATA.
  • 5.Headers on ham: send a normal message and confirm X-Spamd-Result landed with a total well under 15.
  • 6.Stats move: rspamc stat — scanned/learned counters and the actions histogram should be incrementing.
  • 7.Auth is exempt: submit an authenticated message and confirm it is not rejected and is DKIM-signed. If it gets scored like a stranger, your {auth_authen} macro isn't reaching rspamd.
  • 8.Offline sanity: rspamc symbols < message.eml dumps the full scoring for any saved .eml without re-sending it.