Postfix and Sendmail

Spam Scanner connects to Postfix in two ways:

  • As a milterMail filter: a protocol from Sendmail, also used by Postfix, that lets a separate program inspect each message during the SMTP session. (recommended). Postfix asks it about each message during the SMTP session, before accepting it. Spam can be refused with a 4xx or 5xx reply, so the sending server, not yours, deals with it. Sendmail uses the same protocol.
  • As a content filter. Postfix accepts the message, pipes it to spamscanner filter, which adds headers and hands it back with sendmail. Nothing is ever refused during the SMTP session.

Both add these headers to every message:

Text
X-Spam-Flag: YES
X-Spam-Score: 8.0
X-Spam-Level: ********
X-Spam-Status: Yes, score=8.0 required=5.0 tests=BAYES_99,DECEPTIVE_LINK version=7.0.0
X-Spam-Action: tag

X-Spam-* headers already in the message are removed first, so a sender cannot mark its own mail as clean.

Milter#

1. Run the milter#

Shell
npm install --global spamscanner
spamscanner milter --port 7831 --auth --subject-tag "[SPAM]" --verbose

With --reject, messages at the reject threshold (15 points) are refused with 451 4.7.1 Message rejected as spam. A 451 is temporary: the sender tries again later and a mistake can still be corrected by changing a setting. Use --reject-code 550 for a permanent refusal once the results look right. With --quarantine, spam goes to Postfix's hold queue instead.

As a systemd service, in /etc/systemd/system/spamscanner-milter.service:

INI
[Unit]
Description=Spam Scanner milter
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/spamscanner milter --port 7831 --auth --subject-tag [SPAM]
User=spamscanner
Group=spamscanner
Restart=on-failure
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

[Install]
WantedBy=multi-user.target
Shell
sudo useradd --system --no-create-home --shell /usr/sbin/nologin spamscanner
sudo systemctl daemon-reload
sudo systemctl enable --now spamscanner-milter

2. Point Postfix at it#

In /etc/postfix/main.cf:

INI
smtpd_milters = inet:127.0.0.1:7831
milter_protocol = 6
# If the milter is down: accept mail unfiltered (accept) or ask senders to retry (tempfail).
milter_default_action = accept
# Long enough for a language model, if one is configured.
milter_content_timeout = 120s
Shell
sudo postfix reload

smtpd_milters covers mail arriving over SMTP. Leave non_smtpd_milters empty unless mail submitted with the sendmail command should be scanned too.

3. Test it#

swaks sends test messages. GTUBEGeneric Test for Unsolicited Bulk Email: a fixed string every spam filter treats as spam, for testing. is a test string every spam filter treats as spam:

Shell
swaks --to you@example.com --server 127.0.0.1 \
  --body 'XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X'

Without --reject the message is delivered with X-Spam-Flag: YES and a tagged subject. With --reject, swaks shows the 451 or 550 reply.

Content filter#

Use this when mail must never be refused during the SMTP session, or for a server that cannot use milters.

In /etc/postfix/master.cf, add a filter service and use it on the SMTP listener:

Text
smtp      inet  n       -       n       -       -       smtpd
  -o content_filter=spamscanner:dummy
spamscanner unix -      n       n       -       10      pipe
  flags=Rq user=spamscanner null_sender=
  argv=/usr/bin/node /usr/local/lib/node_modules/spamscanner/dist/esm/cli.js filter --subject-tag [SPAM] -f ${sender} -- ${recipient}

Postfix runs the filter with an almost empty environment, so argv names Node.js and the script by their full paths (command -v node and npm root --global show them). Then:

Shell
sudo postfix reload

The filter passes the message back with sendmail -G -i. Mail submitted this way does not pass through the smtp listener again, so it is not filtered twice.

Exit codes tell Postfix what happened: 0 delivered, 69 refused (with --reject: Postfix bounces it to the sender), 75 temporary failure (Postfix keeps the message and tries again). Any scan or delivery failure is 75, so a broken setting never loses or bounces mail.

Sendmail#

In sendmail.mc:

Text
INPUT_MAIL_FILTER(`spamscanner', `S=inet:7831@127.0.0.1, F=T, T=S:30s;R:30s;E:5m')

F=T makes Sendmail answer with a temporary failure while the milter is unavailable; drop it to accept mail unfiltered instead. Rebuild sendmail.cf and restart Sendmail.

Sorting spam into a Junk folder#

Tagging alone delivers spam to the inbox. With Dovecot, a Sieve rule moves it:

Text
require ["fileinto"];
if header :is "X-Spam-Flag" "YES" {
  fileinto "Junk";
  stop;
}

Other mail servers covers Dovecot, Exim, Haraka and procmail, and training shows how to learn from mail that users move in and out of Junk.

Tested#

The repository's end-to-end tests run a real Postfix: hamWanted mail: the opposite of spam. is delivered with headers, a forged X-Spam-Flag is removed, spam is tagged, GTUBE is refused with a 550 during the SMTP session, and the content filter tags mail on a second port. scripts/e2e-postfix.sh sets up that Postfix and test/e2e/postfix.test.js sends the mail.

Edit this page on GitHub · Markdown ·