|
Postfix milter written in Go that enforces alignment between the SMTP envelope sender ( Licensed under AGPLv3 — see LICENSE. |
When a mail server hosts multiple domains, an authenticated user can set MAIL FROM to their own domain but forge the From: header with a different domain. The DKIM signer signs using the From: domain — producing a valid DKIM signature for a domain the sender does not own.
This milter rejects such messages before DKIM signing occurs.
Only authenticated SMTP sessions (SASL) are checked. Unauthenticated connections (inbound MX delivery) pass through without inspection.
For every authenticated session the milter performs two checks:
| Flag | Check | Values |
|---|---|---|
flag_check_auth |
SASL username domain vs MAIL FROM domain |
pass / fail |
flag_check_data |
MAIL FROM domain vs From: header domain |
pass / fail |
Configured via the MF_ACTION environment variable.
| Action | On flag_check_auth fail |
On flag_check_data fail |
On both pass |
|---|---|---|---|
reject |
421 4.7.1 … MFC010001 |
421 4.7.1 … MFC010002 |
log + accept (+ X-MF-Envelope-From if MF_SENDER_ADD=yes) |
discard |
silent drop, log MFC020001 |
silent drop, log MFC020002 |
log + accept (+ X-MF-Envelope-From if MF_SENDER_ADD=yes) |
quarantine_header |
log + add headers (X-MF-Quarantine: yes) |
log + add headers (X-MF-Quarantine: yes) |
log + add headers (X-MF-Quarantine: no) |
accept |
log only, accept | log only, accept | log only, accept |
Default: reject.
| Header | Value |
|---|---|
X-MF-Envelope-From |
MAIL FROM address |
X-MF-From |
Address extracted from From: header |
X-MF-Quarantine |
yes if any check failed, no if all passed |
When MF_SENDER_ADD=yes, accepted authenticated messages (both checks passed) get:
| Header | Value |
|---|---|
X-MF-Envelope-From |
MAIL FROM address |
Every processed authenticated message produces one JSON log entry:
{
"time": "...",
"level": "INFO",
"msg": "milter",
"queue_id": "AE4F61C005B",
"envelope_from": "user@attacker.com",
"auth_user": "user@attacker.com",
"flag_check_auth": "pass",
"from_header": "ceo@victim.com",
"flag_check_data": "fail",
"return_code": "reject"
}queue_id matches the Postfix queue ID and can be used to correlate milter log entries with Postfix logs. Empty for messages rejected in the MAIL FROM phase (before headers are received).
return_code values: reject, discard, accept. (For quarantine_header, return_code is always accept; use the X-MF-Quarantine header or mailfrom_messages_total{action="quarantine"} metric to detect flagged messages.)
smtpd_milters = inet:mailfrom-milter.mail.svc.cluster.local:10031
milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen} {auth_type}
milter_default_action = accept
{auth_authen} must be present in milter_mail_macros (included in Postfix defaults).
| Path | Description |
|---|---|
/healthz |
Liveness — always 200 OK while the process is running |
/readyz |
Readiness — 200 OK after the milter socket is bound, 503 during shutdown |
/metrics |
Prometheus metrics in text format |
mailfrom_connections_total — counter, total SMTP connections accepted.
mailfrom_messages_total — counter, SMTP messages processed.
Labels:
| Label | Values | Description |
|---|---|---|
action |
accept / reject / discard / quarantine |
Final disposition |
check_auth |
pass / fail / skip |
SASL username domain vs MAIL FROM domain |
check_data |
pass / fail / skip |
MAIL FROM domain vs From: header domain |
skip means the check was not reached (unauthenticated session, or action decided before the check ran).
Example query — rejection rate over 5 minutes:
rate(mailfrom_messages_total{action="reject"}[5m])
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
0.0.0.0:10031 |
TCP address for the milter socket |
METRICS_ADDR |
0.0.0.0:8081 |
TCP address for /healthz, /readyz, /metrics |
MF_ACTION |
reject |
reject / discard / quarantine_header / accept |
REJECT_CODE |
421 |
SMTP reply code for reject action: 421 (temp) or 550 (perm) |
MF_SENDER_ADD |
no |
Set to yes to add X-MF-Envelope-From on accepted authenticated messages (reject and discard actions only) |
LOG_LEVEL |
— | Set to debug for verbose per-message logging |
- Go 1.26
0kaba0hub/go-milterv0.5.0 — fork ofemersion/go-milterwithsloglogging andsync.Poolwrite buffer (module renamed togithub.com/0kaba0hub/go-milter)0kaba0hub/go-messagev0.19.0 — fork ofemersion/go-message(indirect dep of go-milter, module renamed togithub.com/0kaba0hub/go-message)- Both forks have weekly upstream release monitors
- Alpine 3.21 runtime image
app/go/
|- main.go
|- metrics.go
|- Dockerfile
|- go.mod
\- go.sum
helm/
|- Chart.yaml
|- values.yaml
\- templates/
|- deployment.yaml
\- service.yaml
helm_values/
\- values-sandbox.yaml
argocd-app.yaml
.github/workflows/ci.yaml
A Helm post-install/post-upgrade hook Job that verifies all four milter cases after every deployment.
| Case | Auth | MAIL FROM | From: header | Expected |
|---|---|---|---|---|
| 1 | none | own domain | own domain | accept (skip) |
| 2 | yes | other domain | — | reject MFC010001 |
| 3 | yes | own domain | other domain | reject MFC010002 |
| 4 | yes | own domain | own domain | accept |
The Job skips automatically if the credentials secret is absent.
Setup (one-time, outside Helm):
kubectl create secret generic mailfrom-test-creds \
--from-literal=TEST_SMTP_HOST='relay.relay.svc.cluster.local' \
--from-literal=TEST_SMTP_PORT='587' \
--from-literal=TEST_SMTP_PORT25='25' \
--from-literal=TEST_SASL_USER='noreply@mail.example.com' \
--from-literal=TEST_SASL_PASSWORD='<smtp-password>' \
--from-literal=TEST_MAIL_FROM='noreply@mail.example.com' \
--from-literal=TEST_MAIL_TO='test@mail.example.com' \
-n <namespace>Enable in values:
postDeployTest:
enabled: true
credentialsSecret: "mailfrom-test-creds"Check results:
kubectl logs -l job-name=mailfrom-post-deploy-test -n <namespace>kubectl apply -f argocd-app.yamldocker build -t mailfrom-milter:dev app/go/
docker run --rm -p 10031:10031 -e MF_ACTION=accept -e LOG_LEVEL=debug mailfrom-milter:devEvery push to main triggers lint → test → build:
| Trigger | Image tags | Release |
|---|---|---|
Push to main |
<sha> + latest |
— |
Push to main with new appVersion in helm/Chart.yaml |
<sha> + latest + v{appVersion} |
GitHub Release created automatically |
The sandbox values file (helm_values/values-sandbox.yaml) is always updated with the short SHA of the latest build.
- Bump
appVersioninhelm/Chart.yamlin your PR (e.g."1.2.0") - Merge to
main - CI automatically creates git tag
v1.2.0, GitHub Release, and pushes the versioned image