uCheckeruChecker
14 min read

Email validation at the infrastructure level: milter, Postfix and inbound filtering

Most email validation guides focus on the application side: a registration form, an API call, a cron job over a list. There is another approach: intercept and filter addresses directly at the mail server, before a message ever enters the delivery queue. Postfix, the milter protocol, and policy services make this possible.


Why filter at the MTA level

Postfix processes the SMTP session before the message reaches your application. Reject at RCPT TO and the sending server gets a 5xx, generates its own bounce, and you never receive the body or write it to disk. For outbound mail the logic is the same: a milter can reject an invalid address before it is sent, keeping bounce rate flat and the queue clean.

Three tools handle this: smtpd_recipient_restrictions (built-in Postfix checks), a policy server (external daemon queried over a text request-response protocol), and a milter (mail filter, acting at any SMTP stage). Each covers different ground.

Postfix built-in restriction classes

Out of the box, Postfix can reject messages on several criteria. A minimal configuration that cuts out a significant fraction of noise:

# /etc/postfix/main.cf

smtpd_recipient_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_unauth_destination,
    reject_invalid_helo_hostname,
    reject_non_fqdn_sender,
    reject_non_fqdn_recipient,
    reject_unknown_sender_domain,
    reject_unknown_recipient_domain,
    reject_rbl_client zen.spamhaus.org,
    permit

What each rule does:

  • reject_invalid_helo_hostname — drops invalid HELO/EHLO hostnames. Cuts botnets that skip a proper greeting.
  • reject_non_fqdn_sender / reject_non_fqdn_recipient — require a fully qualified domain in both addresses.
  • reject_unknown_sender_domain — sender domain must have an MX or A record.
  • reject_rbl_client zen.spamhaus.org — checks sender IP against Spamhaus ZEN (combines SBL, XBL, PBL, CSS).

These rules fire at the SMTP envelope stage, before accepting the body. Fast and cheap. The ceiling: restriction classes only see SMTP session data (IP, HELO, MAIL FROM, RCPT TO) — no headers, no body, no external API calls.

Postfix policy server: external check logic

A policy server is a separate process that Postfix contacts over a Unix socket or TCP connection. The protocol is plain text: Postfix sends a set of attributes (sender, recipient, client_address, helo_name, etc.) and the policy server responds with one of: DUNNO (pass to the next rule), OK (accept), REJECT (deny), or DEFER_IF_PERMIT (defer).

Wiring it up in main.cf:

# /etc/postfix/main.cf

smtpd_recipient_restrictions =
    ...
    check_policy_service unix:private/policy-spf,
    check_policy_service inet:127.0.0.1:10040,
    ...

The first line hooks in SPF checking via Unix socket (e.g., pypolicyd-spf). The second is any policy server on TCP 10040 — your own daemon that checks recipient addresses against an external database or API. A minimal Python example that rejects disposable addresses:

#!/usr/bin/env python3
"""Postfix policy server: rejects disposable domains."""
import socketserver

DISPOSABLE_DOMAINS = {"tempmail.com", "guerrillamail.com",
    "throwaway.email", "mailinator.com"}  # load from file or DB

class PolicyHandler(socketserver.StreamRequestHandler):
    def handle(self):
        attrs = {}
        for line in iter(self.rfile.readline, b"\n"):
            line = line.decode().strip()
            if "=" in line:
                key, value = line.split("=", 1)
                attrs[key] = value
        recipient = attrs.get("recipient", "")
        domain = recipient.rsplit("@", 1)[-1].lower() if "@" in recipient else ""
        if domain in DISPOSABLE_DOMAINS:
            action = f"REJECT disposable address rejected: {domain}"
        else:
            action = "DUNNO"
        self.wfile.write(f"action={action}\n\n".encode())

if __name__ == "__main__":
    socketserver.ThreadingTCPServer(
        ("127.0.0.1", 10040), PolicyHandler).serve_forever()

The policy server is called for every RCPT TO — ten recipients means ten requests. If it hits an external API, cache the results. Without a cache, a traffic spike makes the policy server the bottleneck and Postfix starts returning 4xx on all inbound connections. Note that a policy server only sees SMTP envelope data: no headers, no body. For content-based filtering you need a milter.

Milter: full control over the message

Milter (mail filter) was originally developed for Sendmail and has been supported by Postfix since version 2.3. Unlike a policy server, a milter receives callbacks at every SMTP stage: CONNECT, HELO, MAIL FROM, RCPT TO, headers, and body. It can modify headers, change recipients, rewrite the body, or reject the message outright. OpenDKIM, OpenDMARC, SpamAssassin (via spamass-milter), and ClamAV (via clamav-milter) are all milter applications.

Connecting a milter in Postfix

# /etc/postfix/main.cf

# Milters for inbound mail (smtpd)
smtpd_milters =
    unix:opendkim/opendkim.sock,
    unix:opendmarc/opendmarc.sock,
    inet:127.0.0.1:10050

# Milters for outbound mail (non-SMTP, e.g. sendmail -t)
non_smtpd_milters =
    unix:opendkim/opendkim.sock,
    inet:127.0.0.1:10050

# What to do if a milter is unavailable
milter_default_action = tempfail

# Protocol version (6 is current)
milter_protocol = 6

milter_default_action is critical. With tempfail, a crashed milter returns 4xx and the sender retries. Setting it to accept means all mail flows unfiltered when the milter is down — unacceptable on production.

A milter in Python (pymilter)

The pymilter library wraps libmilter for Python. The example below checks RCPT TO against an HTTP API and rejects invalid addresses.

#!/usr/bin/env python3
"""Milter: validate recipients via external API."""
import Milter, requests
from functools import lru_cache

@lru_cache(maxsize=10000)
def check_recipient(email: str) -> bool:
    try:
        data = requests.post(
            "https://api.uchecker.net/api/v1/validate/single",
            json={"email": email},
            headers={"x-api-key": "YOUR_API_KEY"},
            timeout=2,
        ).json()
        return data.get("result") != "invalid" and data.get("risk_score", 0) <= 0.8
    except Exception:
        return True  # fail open on API error

class ValidateRcptMilter(Milter.Base):
    def __init__(self): self.id = Milter.uniqueID()
    def envrcpt(self, to, *params):
        recipient = to.strip("<>")
        if not check_recipient(recipient):
            self.setreply("550", "5.1.1", f"{recipient}: recipient rejected")
            return Milter.REJECT
        return Milter.CONTINUE

if __name__ == "__main__":
    Milter.factory = ValidateRcptMilter
    Milter.runmilter("validate-rcpt", "inet:127.0.0.1:10050", timeout=10)

A few details: the lru_cache (10,000 entries) is non-optional — without it every RCPT TO fires an HTTP request, hitting tens of thousands per minute under load; use Redis or memcached for distributed deployments. Keep the API timeout at 2 seconds: if the milter does not respond within milter_connect_timeout (default 30s) the connection drops, so failing open is safer than stalling the SMTP session. Milter.REJECT returns 5xx (no retry); Milter.TEMPFAIL returns 4xx (retry later) — use REJECT for confirmed invalid addresses, TEMPFAIL or CONTINUE when the API is unavailable.

Architecture: how the pieces fit together

In practice, milters and policy servers run together — each handling a different job. A typical inbound chain:

  1. smtpd_recipient_restrictions — cuts junk (nonexistent domains, invalid syntax, blocklisted IPs) before the message body is received.
  2. Policy server (SPF) — checks whether the sending IP is authorized for the claimed domain (pypolicyd-spf or postfix-policyd-spf-python).
  3. Milter: OpenDKIM — verifies the DKIM signature and adds an Authentication-Results header.
  4. Milter: OpenDMARC — evaluates DMARC policy using SPF and DKIM results.
  5. Milter: recipient validation — your custom milter checking the recipient against an external service or local database.
  6. Milter: anti-spam — SpamAssassin or rspamd for content scoring.

For outbound mail the chain is shorter, but the principle is the same:

# Submission (port 587) — separate milter set
# /etc/postfix/master.cf

submission inet n       -       y       -       -       smtpd
  -o smtpd_milters=unix:opendkim/opendkim.sock,inet:127.0.0.1:10050
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

The milter on port 10050 validates outbound recipients before sending. An invalid address gets a 550 back to the application, which can flag the contact, notify the user, or write to a log.

Access maps: static blocklists

When dynamic lookups are not needed, Postfix supports static hash tables:

# /etc/postfix/main.cf
smtpd_recipient_restrictions =
    ...
    check_recipient_access hash:/etc/postfix/recipient_reject,
    ...

# /etc/postfix/recipient_reject
tempmail.com          REJECT disposable domain
guerrillamail.com     REJECT disposable domain
mailinator.com        REJECT disposable domain
throwaway.email       REJECT disposable domain

# After editing:
# postmap /etc/postfix/recipient_reject && postfix reload

Access maps are a hash table in memory — lookups are instant. Good for disposable domain blocklists, specific address bans, and VIP sender allowlists. The catch: the list requires manual maintenance (or a cron script), and it cannot distinguish a live address from a dead one within a legitimate domain.

Rate limiting via policy server

When a server handles mail for multiple applications, one of them can start blasting a dirty list and damage the IP reputation for everyone else. postfwd2 (Postfix Firewall Daemon) is a policy server that limits send rate per SASL user, IP, and recipient domain:

# /etc/postfwd/postfwd.cf
# Max 200 msgs/hour per SASL user
id=RATE_LIMIT_USER; sasl_username=~^.+$;
    action=rate(sasl_username/200/3600/REJECT rate limit exceeded)
# Max 50 msgs/hour per recipient domain
id=RATE_LIMIT_DOMAIN; recipient_domain=~^.+$;
    action=rate(recipient_domain/50/3600/DEFER_IF_PERMIT slow down)

Rate limiting does not replace address validation. It just ensures a single misbehaving process cannot send ten thousand messages in a minute even if an invalid address slips through.

Monitoring and logging

Infrastructure validation is useless without observability. Postfix writes to syslog (typically /var/log/mail.log). Four metrics matter: REJECT count per restriction class (a tenfold spike in reject_unknown_sender_domain means either an attack or a broken DNS resolver); milter and policy server response time (over 1s is a problem, over 5s SMTP sessions start stalling); milter tempfail count (if milter_default_action=tempfail fires regularly, the milter is crashing — fix it, do not switch to accept); and outbound bounce rate (if it does not drop after adding validation, something is misconfigured).

grep "reject:" /var/log/mail.log \
  | awk -F'reject: ' '{print $2}' | cut -d';' -f1 \
  | sort | uniq -c | sort -rn | head -20

For production: pflogsumm, Prometheus with postfix_exporter, or Graylog/Loki.

Where MTA validation stops

MTA-level checks handle infrastructure problems: reject a nonexistent domain, block a disposable address, verify sender authentication, cap rate. That is as far as they go.

The MTA does not know whether a specific mailbox on a catch-all domain is active. It will not spot a spam trap if the domain and MX records look clean. It cannot score bounce probability from behavioral patterns. Those require a dedicated validation service that aggregates signals across many sources and applies scoring models. Two tiers work together: Postfix cuts obvious bad addresses at the connection layer with no added latency; before a bulk send, the full list goes through a validator for deep checks (SMTP handshake, catch-all detection, risk scoring). One does not replace the other.

The MTA filters traffic. The validator cleans the list.

Postfix, milters, and policy servers handle the connection layer in real time. Before sending to a list of ten thousand addresses, you still need an upfront check: SMTP handshake, catch-all detection, disposable filtering, risk scoring. Upload your list to uChecker — results in minutes. Invalid addresses, spam traps, risky contacts: all in one report.

Postfix milteremail validation MTASMTP filteringPostfix policy serveremail gateway validation