Sign inSign up

0kaba0/mailfrom-milter

By 0kaba0

Updated 21 days ago

Postfix milter — enforces MAIL FROM / From: header alignment for authenticated SMTP sessions.

Image
Security
0

3.0K

0kaba0/mailfrom-milter repository overview

mailfrom-milter

mailfrom-milter logo

Postfix milter written in Go that enforces alignment between the SMTP envelope sender (MAIL FROM) and the From: message header.

Licensed under AGPLv3 — see LICENSE.

CI License: AGPL v3 Go version Container Docker Hub

Architecture

Architecture


The problem

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.


Checks

For every authenticated session the milter performs two checks:

FlagCheckValues
flag_check_authSASL username domain vs MAIL FROM domainpass / fail
flag_check_dataMAIL FROM domain vs From: header domainpass / fail

Actions

Configured via the MF_ACTION environment variable.

ActionOn flag_check_auth failOn flag_check_data failOn both pass
reject421 4.7.1 … MFC010001421 4.7.1 … MFC010002log + accept (+ X-MF-Envelope-From if MF_SENDER_ADD=yes)
discardsilent drop, log MFC020001silent drop, log MFC020002log + accept (+ X-MF-Envelope-From if MF_SENDER_ADD=yes)
quarantine_headerlog + add headers (X-MF-Quarantine: yes)log + add headers (X-MF-Quarantine: yes)log + add headers (X-MF-Quarantine: no)
acceptlog only, acceptlog only, acceptlog only, accept

Default: reject.


Headers added

quarantine_header action
HeaderValue
X-MF-Envelope-FromMAIL FROM address
X-MF-FromAddress extracted from From: header
X-MF-Quarantineyes if any check failed, no if all passed
reject and discard actions (when MF_SENDER_ADD=yes)

When MF_SENDER_ADD=yes, accepted authenticated messages (both checks passed) get:

HeaderValue
X-MF-Envelope-FromMAIL FROM address

Log format

Every processed authenticated message produces one JSON log entry:

{
  "time": "...",
  "level": "INFO",
  "msg": "milter",
  "queue_id": "AE4F61C005B",
  "envelope_from": "[email protected]",
  "auth_user": "[email protected]",
  "flag_check_auth": "pass",
  "from_header": "[email protected]",
  "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.)


Postfix configuration

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).


Observability

HTTP endpoints (port 8081)
PathDescription
/healthzLiveness — always 200 OK while the process is running
/readyzReadiness — 200 OK after the milter socket is bound, 503 during shutdown
/metricsPrometheus metrics in text format
Prometheus metrics

mailfrom_connections_total — counter, total SMTP connections accepted.

mailfrom_messages_total — counter, SMTP messages processed.

Labels:

LabelValuesDescription
actionaccept / reject / discard / quarantineFinal disposition
check_authpass / fail / skipSASL username domain vs MAIL FROM domain
check_datapass / fail / skipMAIL 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])

Environment variables

VariableDefaultDescription
LISTEN_ADDR0.0.0.0:10031TCP address for the milter socket
METRICS_ADDR0.0.0.0:8081TCP address for /healthz, /readyz, /metrics
MF_ACTIONrejectreject / discard / quarantine_header / accept
REJECT_CODE421SMTP reply code for reject action: 421 (temp) or 550 (perm)
MF_SENDER_ADDnoSet to yes to add X-MF-Envelope-From on accepted authenticated messages (reject and discard actions only)
LOG_LEVELSet to debug for verbose per-message logging

Stack

  • Go 1.26
  • 0kaba0hub/go-milter v0.5.0 — fork of emersion/go-milter with slog logging and sync.Pool write buffer (module renamed to github.com/0kaba0hub/go-milter)
  • 0kaba0hub/go-message v0.19.0 — fork of emersion/go-message (indirect dep of go-milter, module renamed to github.com/0kaba0hub/go-message)
  • Both forks have weekly upstream release monitors
  • Alpine 3.24 runtime image

Directory layout

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

Post-deploy smoke test

A Helm post-install/post-upgrade hook Job that verifies all four milter cases after every deployment.

CaseAuthMAIL FROMFrom: headerExpected
1noneown domainown domainaccept (skip)
2yesother domainreject MFC010001
3yesown domainother domainreject MFC010002
4yesown domainown domainaccept

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='[email protected]' \
  --from-literal=TEST_SASL_PASSWORD='<smtp-password>' \
  --from-literal=TEST_MAIL_FROM='[email protected]' \
  --from-literal=TEST_MAIL_TO='[email protected]' \
  -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>

Deploy

Kubernetes (ArgoCD)
kubectl apply -f argocd-app.yaml
Local
docker build -t mailfrom-milter:dev app/go/
docker run --rm -p 10031:10031 -e MF_ACTION=accept -e LOG_LEVEL=debug mailfrom-milter:dev

CI

Every push to main triggers lint → test → build:

TriggerImage tagsRelease
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.

Releasing a new version
  1. Bump appVersion in helm/Chart.yaml in your PR (e.g. "1.2.0")
  2. Merge to main
  3. CI automatically creates git tag v1.2.0, GitHub Release, and pushes the versioned image

Tag summary

Content type

Image

Digest

sha256:c70ac6633

Size

9.9 MB

Last updated

21 days ago

docker pull 0kaba0/mailfrom-milter