Skip to main content

MX Check module

The MX Check module verifies that the sending domains in a message actually have working mail infrastructure. For each candidate domain it resolves the MX records (falling back to A/AAAA per RFC 5321 §5.1 when no MX exists), classifies the resolved IP addresses, and — for routable, public addresses — opens a short TCP probe to port 25 to confirm that something is listening. The outcome is expressed through a rich set of symbols so that the spam filter can score legitimate senders, forged/parked domains, and transient failures differently.

All probe and resolution results are cached in Redis, so a domain that has already been seen is answered from cache without any new DNS query or TCP connection.

The behaviour described on this page — the three-layer cache, IP classification, multi-source checking, SMTP banner validation, trust/punishment maps and the expanded symbol set — is available starting from Rspamd 4.1.0. Earlier versions shipped a much simpler module that emitted only MX_GOOD, MX_INVALID, MX_MISSING and MX_WHITE. The legacy timeout and wait_for_greeting options are still accepted but deprecated — see Migration from pre-4.1.0 below.

To activate this module, set enabled = true in /etc/rspamd/local.d/mx_check.conf and configure Redis either globally or for this specific module. Redis is mandatory: without a configured Redis server the module disables itself.

How it works

For every message the module builds a deduplicated set of candidate domains from the message's sources (see Sources checked) and runs one lookup per unique domain. A lookup walks three cache layers and short-circuits as soon as it can produce a verdict:

  1. d:<domain> (domain layer). Caches the MX resolution result for the domain: the sorted MX list, an A/AAAA fallback list, or a terminal state (no records, null MX, broken, DNS failure). On a miss the module queries MX records.
  2. m:<mxhost> (MX-host layer). Caches the A/AAAA resolution for an individual MX hostname. On a miss the module resolves the host's addresses.
  3. i:<ip> (IP layer). Caches the probe verdict for an individual IP address (good, refused, timeout, invalid, or an SMTP reply code). On a miss the module performs a TCP probe.

IP classification

Before any probe runs, every resolved IP is classified into one of three classes:

  • PUBLIC — a routable address. Only public addresses are probed.
  • LOCAL — private ranges: RFC 1918 (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10, RFC 6598), and IPv6 unique-local (fc00::/7, RFC 4193).
  • BOGON — non-routable / reserved: loopback, link-local/APIPA, TEST-NET-1/2/3, 0.0.0.0/8, IPv4 multicast and reserved (Class E), 6to4 anycast, benchmarking, IPv6 unspecified/documentation/multicast, NAT64 and discard prefixes.

LOCAL and BOGON addresses are never probed but they do emit dedicated symbols (MX_LOCAL_*, MX_BOGON_*) so the shape of the resolution can be scored. The offending addresses are attached to the symbol as options. If a domain resolves to a mix of public and non-public addresses, only the public subset is probed and the *_MIX variant of the class symbol fires.

Probe shapes

There are two probe shapes, selected with verify_greeting:

  • Connect-only (verify_greeting = false, the default): open a TCP connection to port 25; success on connect, then close. Only connect_timeout applies. Fast and cheap.
  • Greeting validation (verify_greeting = true): open the connection, read the SMTP banner and validate the reply code. A 220 greeting is a working MX; 4xx/5xx is treated as a real (but probe-rejecting) SMTP server; anything that is not a valid SMTP banner yields an "invalid" verdict. With just verify_greeting, the verdict is decided on the first banner line. When send_quit = true the module additionally drains multi-line continuation banners and then issues a graceful QUIT after a successful greeting. The connect phase is bounded by connect_timeout and the banner-read phase by read_timeout; a connect timeout yields MX_TIMEOUT_CONNECT while a read timeout yields MX_TIMEOUT_READ.

A-fallback path

When a domain publishes no MX record but does have A/AAAA records, those addresses are used as an implicit MX (RFC 5321 §5.1). Probe outcomes on this path emit the MX_A_* family of symbols instead of the MX_* family. The A-fallback failure symbols carry slightly higher default scores than their MX-RR equivalents, because a domain with no published mail intent and no working listener is a strong forgery/parked-domain signal.

Concurrency and graceful degradation

When several Rspamd workers probe the same IP simultaneously, a Redis lock (SET NX at i:<ip>) ensures only one worker actually opens the connection. Other workers defer and emit MX_INFLIGHT; the winning worker's verdict is published to cache for everyone. If Redis is unreachable during the lock claim the probe is skipped and MX_REDIS_ERROR is emitted. The DNS cache layers (d:/m:) degrade gracefully — a Redis loss simply forces re-resolution rather than failing the check.

Sources checked

By default the module checks three sources and runs one probe and one symbol per unique domain. When the same domain appears in more than one source, the highest-priority source wins: envelope-from > Reply-To > MIME From. Each source carries its own symbol prefix so you can tell — and score — them apart:

SourceSymbol prefix
Envelope (SMTP) From — falls back to HELO when the SMTP From is empty(none)
MIME From: headerMIME_FROM_
Reply-To: headerREPLYTO_

Each source can be toggled independently (check_from / check_mime_from / check_reply_to); disabling all three disables the module at config load.

Authenticated traffic and locally-originated traffic (sender IP is local) are skipped by default. Opt back in with check_authorized / check_local.

Configuration

Here is a minimalistic example configuration in local.d/mx_check.conf:

enabled = true;

exclude_domains = [
"${CONFDIR}/local.d/maps.d/mx_check_exclude_domains.inc",
"https://maps.rspamd.com/rspamd/dmarc_whitelist_new.inc",
"https://maps.rspamd.com/rspamd/spf_dkim_whitelist.inc",
"https://maps.rspamd.com/rspamd/maillist.inc",
"https://maps.rspamd.com/freemail/free.txt.zst",
"https://maps.rspamd.com/freemail/disposable.txt.zst",
];

exclude_mxs = [
"${CONFDIR}/local.d/maps.d/mx_check_exclude_mxs.inc",
];

Options

All symbol names are configurable as well (symbol_good_mx, symbol_bad_mx, symbol_mx_none, …); their defaults are listed in the Symbols section.

TCP probe

OptionTypeDefaultDescription
connect_timeoutnumber2.0Seconds allowed to establish the TCP connection
read_timeoutnumber5.0Seconds allowed to read the SMTP banner (only used when verify_greeting = true)
verify_greetingbooleanfalseRead and validate the SMTP greeting reply code instead of just connecting
send_quitbooleanfalseDrain multi-line banners and issue a graceful QUIT after a good greeting (requires verify_greeting)
portnumber25Port to probe

Cache TTLs (seconds)

OptionTypeDefaultDescription
expirenumber86400Good verdicts, SMTP error codes and read timeouts (1 day)
expire_dnsnumber1800DNS results at the d:/m: layers (30 min). 0 disables DNS caching
expire_novalidnumber14400Hard failures: refused / invalid (4 hours)
expire_timeoutnumber7200Connect timeouts (2 hours)

Sources

OptionTypeDefaultDescription
check_frombooleantrueCheck the envelope (SMTP) From, falling back to HELO
check_mime_frombooleantrueCheck the MIME From: header
check_reply_tobooleantrueCheck the Reply-To: header
symbol_prefix_fromstring""Symbol prefix for the envelope-from source
symbol_prefix_mime_fromstring"MIME_FROM_"Symbol prefix for the MIME-From source
symbol_prefix_reply_tostring"REPLYTO_"Symbol prefix for the Reply-To source

Address family

OptionTypeDefaultDescription
probe_ipv4booleantrueResolve and probe IPv4 (A) addresses
probe_ipv6booleanfalseResolve and probe IPv6 (AAAA) addresses
prefer_ipv6booleantrueWhen both families are enabled, order IPv6 first in the probe list
max_mx_a_recordsnumber3Caps both the MX list length and the per-MX A/AAAA fan-out. Must be ≥ 1, and ≥ 2 when both families are enabled

Authenticated / local traffic

OptionTypeDefaultDescription
check_authorizedbooleanfalseAlso check authenticated traffic
check_localbooleanfalseAlso check locally-originated traffic

Rejection (Null MX)

OptionTypeDefaultDescription
reject_null_mxbooleanfalseForce-reject messages from a domain with an RFC 7505 Null MX
reject_null_mx_messagestring"Domain published RFC 7505 Null MX"Reject message
reject_authorizedbooleanfalseAllow rejecting authenticated traffic
reject_localbooleanfalseAllow rejecting locally-originated traffic

Greylisting advice (requires the greylist plugin)

OptionTypeDefaultDescription
greylist_invalidbooleantrueAdvise greylisting on MX_INVALID
greylist_nonebooleantrueAdvise greylisting on MX_NONE
greylist_brokenbooleantrueAdvise greylisting on MX_BROKEN
greylist_refusedbooleantrueAdvise greylisting on MX_REFUSED
greylist_nullbooleantrueAdvise greylisting on MX_NULL
greylist_timeout_connectbooleantrueAdvise greylisting on MX_TIMEOUT_CONNECT
greylist_timeout_readbooleantrueAdvise greylisting on MX_TIMEOUT_READ
greylist_authorizedbooleanfalseAllow greylisting authenticated traffic
greylist_localbooleanfalseAllow greylisting locally-originated traffic

Miscellaneous

OptionTypeDefaultDescription
key_prefixstring"rmx"Redis key prefix
test_modebooleanfalseTreat loopback as a probeable address. Never enable in production

Maps

The module supports five optional maps. Two of them are trust statements (a hit whitelists the domain/MX), two are punishment statements (a hit penalises it), and one is a probe-set filter. When an entry appears in both a trust map and a punishment map, punishment wins.

MapTypeEffect on a hit
exclude_domainsglobDomain is trusted → MX_WHITE, no resolution/probe
exclude_mxsglob (MX hostnames)MX hostname is trusted → MX_WHITE, short-circuit
exclude_ipsradix (IP/CIDR)Matched IPs are dropped from the probe set; if every routable IP is filtered → MX_SKIP
bad_mxsglob (MX hostnames)Punish → MX_BAD, short-circuit
bad_ipsradix (IP/CIDR)Punish → MX_IP_BAD, short-circuit

bad_mxs and bad_ips entries may carry an optional trailing numeric token that becomes a weight multiplier on top of the symbol's group score (default 1.0). For example:

# bad_mxs
trapmx.example.com 3
*.bad.example 0.5
# bad_ips
192.0.2.0/24 0.5
198.51.100.10 5

We highly recommend populating exclude_mxs with the large, shared MX pools that most of your mail legitimately comes from. A hit on exclude_mxs short-circuits the lookup with MX_WHITE before any A/AAAA resolution or TCP probe runs, so listing the big providers removes the vast majority of unnecessary TCP probes.

A good starting list (extend it with whatever MXs your users commonly receive mail from):

# exclude_mxs — well-known shared MX pools
# Microsoft 365 / Outlook / Exchange Online
*.olc.protection.outlook.com
*.mail.protection.outlook.com
# Google (consumer: gmail.com; business: Google Workspace via *.aspmx)
gmail-smtp-in.l.google.com
alt?.gmail-smtp-in.l.google.com
aspmx.l.google.com
alt?.aspmx.l.google.com
# Yahoo / AOL / Verizon Media. Two MX pool subzones: am0 (regional MTAs)
# and gm0 (gateway).
*.am0.yahoodns.net
*.gm0.yahoodns.net
mx?.mail.yahoo.co.jp

Because exclude_mxs is a glob map, * matches a label segment and ? matches a single character (so alt?.aspmx.l.google.com covers alt1alt4). Add the MX hostnames of any other high-volume senders relevant to your deployment — regional providers, large ESPs, etc.

DNS-only mode (no TCP probing)

If you want the DNS-level signal — missing MX, Null MX, unresolvable MX hosts, bogon/local MX addresses — but never want the module to open a single TCP connection, exclude the entire address space:

exclude_ips = ["0.0.0.0/0", "::/0"];

Every routable IP is then filtered out before the probe step, so no connection is ever attempted. The module still resolves MX/A/AAAA and emits all the DNS- and classification-level symbols (MX_NONE, MX_NULL, MX_BROKEN, MX_DNS_FAIL, MX_LOCAL_*, MX_BOGON_*), and a domain whose MXs would otherwise have been probed yields MX_SKIP instead of MX_GOOD/MX_INVALID/etc. This is useful where outbound port 25 is blocked, or where you only want MX existence/sanity checks without the cost and footprint of active probing.

The punishment maps still apply in this mode: bad_mxs and bad_ips are checked before their exclude_* counterparts, so a known-bad MX hostname still fires MX_BAD and a known-bad IP still fires MX_IP_BAD — even though the catch-all exclude_ips would otherwise have filtered that IP away. Punishment wins over the skip.

Symbols

Every symbol below is registered in three flavours — unprefixed (envelope-from), MIME_FROM_-prefixed and REPLYTO_-prefixed — sharing the same default score. All belong to the mx group and are one_shot.

SymbolScoreDescription
MX_GOOD-0.1Domain has a working MX
MX_WHITE-0.1Domain/MX is whitelisted from MX check
MX_INVALID3.0MX target accepted TCP but the listener does not speak SMTP
MX_REFUSED3.0MX target sent TCP RST (port 25 closed)
MX_TIMEOUT_CONNECT2.0MX target did not respond to the connect attempt
MX_TIMEOUT_READ0.1MX target accepted TCP but did not send a greeting
MX_ERROR0.0MX target greeted with 4xx/5xx (real SMTP, rejected the probe)
MX_NONE4.0From domain has no MX/A/AAAA records (covers NXDOMAIN and NOREC)
MX_NULL6.0Domain published an RFC 7505 Null MX
MX_BROKEN4.0All MX RRs point at hostnames that do not resolve
MX_DNS_FAIL0.0Transient DNS path failure (SERVFAIL/REFUSED/timeout); sender not at fault
MX_LOCAL_ONLY3.0All resolved MX IPs are in private ranges (RFC1918 / CGNAT / ULA); no probe run
MX_LOCAL_MIX3.0Some resolved MX IPs are in private ranges; public subset probed
MX_BOGON_ONLY8.0All resolved MX IPs are bogon / non-routable; no probe run
MX_BOGON_MIX5.0Some resolved MX IPs are bogon / non-routable; public subset probed
MX_SKIP0.0exclude_ips filtered every routable MX IP away; no probe run
MX_BAD6.0MX hostname listed in bad_mxs (operator-defined punishment glob)
MX_IP_BAD6.0Resolved MX IP listed in bad_ips (operator-defined punishment radix)
MX_INFLIGHT0.0Another worker holds the probe lock; the verdict will land via that worker
MX_REDIS_ERROR0.0Redis error during probe-lock claim; probe skipped (cache layer degraded)

A-fallback symbols (no MX RR; A/AAAA used as implicit MX)

SymbolScoreDescription
MX_A_GOOD0.0A-fallback target accepted SMTP (RFC 5321 §5.1 compliant)
MX_A_REFUSED3.0A-fallback target sent TCP RST (port 25 closed)
MX_A_TIMEOUT_CONNECT2.5A-fallback target did not respond to the connect attempt
MX_A_TIMEOUT_READ0.1A-fallback target accepted TCP but did not send an SMTP greeting
MX_A_ERROR0.0A-fallback target greeted with 4xx/5xx (real SMTP, rejected the probe)
MX_A_INVALID3.0A-fallback target accepted TCP but the listener does not speak SMTP

Default scores can be overridden per deployment through local.d/mx_group.conf or override.d/mx_group.conf.

Greylisting and rejection

When the greylist plugin is enabled, the recoverable failure outcomes can advise greylisting of the first message (controlled by the greylist_* options). Authenticated and locally-originated traffic is never greylisted unless greylist_authorized / greylist_local is set.

Only the Null MX outcome can force a pre-result reject, and only when reject_null_mx = true. As with greylisting, authenticated/local traffic is exempt unless reject_authorized / reject_local is enabled. When an outcome would be both rejected and greylisted, the reject takes precedence and greylisting is suppressed.

Caching

Cache values use short codes to minimise the Redis footprint and encode which address families were queried (so a partial cache written under IPv4-only is re-resolved when IPv6 is later enabled):

  • d:<domain>mx:host:prio,… | a:<v>:ip,… | no | null | bkn | df
  • m:<host><v>:ip,… | no | df
  • i:<ip>gd | rf | tc | tr | inv | err:<code> | l (probe in flight)

where <v> is v4, v6 or v64. Setting expire_dns = 0 disables the d:/m: DNS cache layers entirely (the i: probe cache always stays on, and its TTLs must be positive). This is useful on systems with a fast, reliable upstream resolver (for example a local caching resolver), where re-resolving MX/A/AAAA on every lookup is cheaper than the extra Redis round-trips the DNS cache would add.

The i: probe verdict is cached with a TTL chosen by the verdict, which is deliberately not uniform:

VerdictCached asTTL
gd (good), err:<code> (4xx/5xx)good/aliveexpire (1 day)
tr (read timeout)good/aliveexpire (1 day)
tc (connect timeout)timeoutexpire_timeout (2 hours)
rf (refused), inv (invalid)hard failureexpire_novalid (4 hours)

The key distinction is between the two timeouts. A read timeout (tr) means the TCP connection succeeded — the host is alive and listening, it just did not send a banner in time (Postfix postscreen, tarpitting, or big-provider rate-limiting are the usual causes). Because the host is demonstrably up, the verdict is cached at the long expire TTL like a good result; re-probing it sooner would gain nothing and, since the connection completes every time, repeated probes are exactly the pattern a target is likely to flag as abuse. A connect timeout (tc), by contrast, never completed a connection, so it is far more likely to be transient (a route flap, a host coming up) and is cached at the much shorter expire_timeout so the next message re-probes relatively soon.

Once the module has been running in a stable configuration for a while, we highly recommend raising expire to 1–3 weeks. Working MXs rarely change, so a longer TTL on the good/alive verdicts further cuts down on repeat probing without any practical loss of accuracy.

Migration from pre-4.1.0

The following legacy options are still accepted and mapped automatically, with a warning logged at startup:

Legacy optionReplacementNotes
timeoutconnect_timeoutMapped only if connect_timeout is not set
wait_for_greetingverify_greetingMapped only if verify_greeting is not set. The new flag additionally performs multi-line banner parsing and reply-code validation.

The old MX_MISSING symbol has been replaced by the MX_A_* family symbols. If you previously scored or referenced MX_MISSING, please review new symbols and their scores.

The symbol group was renamed from MX to mx. If you reference the group by name (for example in score overrides via local.d/groups.conf), use the lowercase mx or better move settings to mx_group.conf specific config file.