Your question
Hi,
I am configuring a VPS where local system utilities (like cron etc.) to send system alerts.
Which of these two approaches is recommended by design in v0.16?
1 - Dedicated Local Listener: Create a SMTP listener (e.g., 127.0.0.1:10025), and then use the listener == ‘name_local’, Allow Relaying, and MTA Stages (SPF/DKIM/DMARC).
2 - Global IP Whitelisting: Keep using the default port 25 listener but prepend a remote_ip == ‘127.0.0.1’ exception rule at the very top of all stages.
Thanks.
Good - I now have enough information to give a thorough, doc-grounded answer. Both listener and remote_ip are available as expression variables across all MTA stages (connection, EHLO, MAIL FROM, RCPT TO, DATA), which is the key fact needed to compare the two approaches.
Both approaches are technically viable since every MTA stage expression has access to both the listener and remote_ip variables. However, Option 1 (dedicated local listener) is the cleaner, safer design.
Here’s why, based on the docs:
Option 1 - Dedicated listener on 127.0.0.1 (recommended)
- You create a second
NetworkListener bound to 127.0.0.1:10025 (or any local port) with its own name, e.g. "local".
- In
MtaStageRcpt, the allowRelaying expression can key off listener == 'local' to permit unauthenticated relay exclusively for that listener.
- All other MTA stage expressions (SPF/DKIM/DMARC checks, greylisting, etc.) can similarly be gated:
if listener == 'local' then false to skip those checks for local submissions.
- Because the listener is bound only to the loopback interface, external IPs can never reach it at the OS level - your security does not depend solely on an application-layer expression rule.
Option 2 - remote_ip == '127.0.0.1' prefix on port 25
- This also works because
remote_ip is available in all MTA stage expression contexts (MtaConnectionVariable, MtaRcptToVariable, etc.).
- The risk is that every single stage where you want to grant an exception must have the rule prepended correctly. A missed stage (e.g., a greylisting Sieve script at RCPT) means local mail can still be blocked or treated as suspicious. It is easier to make a mistake in one stage and leave a gap.
- Port 25 bound to
[::] is publicly reachable; the only thing preventing abuse is the expression rule, not the network stack.
The practical setup for Option 1:
- Add a
NetworkListener bound to 127.0.0.1:10025, name it (e.g. "local"), protocol smtp.
- In
MtaStageRcpt > allowRelaying, add: {"if": "listener == 'local'", "then": "true"} before the default rule.
- In each data/auth stage where you run SPF, DKIM, DMARC, or greylisting checks, add a short-circuit:
{"if": "listener == 'local'", "then": "false"} (or "true" where the field means “pass”) at the top of the expression.
- Configure your cron/sendmail to use
127.0.0.1:10025.
This gives you network-level containment plus clean, readable per-stage expressions, rather than relying on a remote_ip allowlist scattered across every stage.
Relevant documentation:
This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.
I’d go with option 1: a dedicated listener on 127.0.0.1:10025 with its own listener name (say, local), and allowRelaying on the rcpt stage gated on listener_name == "local". That keeps your port-25 policy unchanged and isolates the loopback relax. Branch your SPF/DKIM/DMARC verification at MAIL stage off the same listener_name == "local" condition so locally generated mail doesn’t get scored against SPF.
Option 2 (allowlisting remote_ip == 127.0.0.1 on the public stage) works, but it couples loopback policy with the public chain and is easier to break with a future config edit, so it’s the riskier choice.