V0.16: Best practice for spam handling when Stalwart runs behind a spam/mail gateway

Your question

Hi everyone,

In our setup Stalwart isn’t directly exposed to the internet for inbound mail. Instead, an upstream spam & mail gateway sits in front of it and forwards messages to Stalwart - in our regular case Google Workspace receives the mail, does its own spam filtering, and then relays it on to Stalwart. However, there are also cases where there is simply a mail gateway in front of it.

I’d like to understand the recommended way to configure Stalwart for this scenario. A few specific questions:

  • Should Stalwart’s own spam filtering be disabled or reduced, to avoid double-filtering, or is it better to keep it as a second layer?
  • How should I handle SPF/DKIM/DMARC checks? Since the connecting host is always the gateway (Google), SPF on the original sender would effectively be broken - what’s the correct approach here?
  • Is there a way to honor the spam verdict the upstream gateway already added (e.g. via headers), instead of re-evaluating from scratch?

What’s the best-practice configuration for this kind of “gateway in front of Stalwart” topology? Any pointers to the relevant config keys would be much appreciated.

Thanks in advance!

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Here is a comprehensive, doc-grounded answer to your three questions:


Spam filtering when Stalwart sits behind a mail gateway

1. Should you disable Stalwart’s own spam filter?

You have a clean switch for this. enableSpamFilter on the MtaStageData object is an expression – you can set it to false for connections coming from your gateway IP(s) while keeping it active for everything else:

{
  "enableSpamFilter": {
    "match": [
      {
        "if": "is_ip_in_cidr(remote_ip, '203.0.113.0/24')",
        "then": "false"
      }
    ],
    "else": "true"
  }
}

Replace 203.0.113.0/24 with your gateway’s IP range. This disables double-filtering for relayed traffic while keeping Stalwart’s filter active for any direct connections.

Alternatively, keep it enabled as a second layer – that’s a valid and conservative choice.

2. SPF/DKIM/DMARC when the connecting host is always the gateway

SPF is inherently broken in this topology (Stalwart checks the IP of the connecting host, which is Google/your gateway, not the original sender). The correct approach per the docs is:

  • SPF: In the SenderAuth settings, spfEhloVerify and spfFromVerify both accept expressions. You can set them to disable when the remote_ip matches your gateway, so SPF failures from the gateway don’t affect delivery:
{
  "spfFromVerify": {
    "match": [
      {
        "if": "is_ip_in_cidr(remote_ip, '203.0.113.0/24')",
        "then": "disable"
      }
    ],
    "else": "relaxed"
  }
}
  • DKIM: DKIM is a message-level signature tied to the original sender’s domain, not the connecting IP. It survives forwarding as long as the gateway doesn’t alter the message body or signed headers, so DKIM verification should generally still work and can remain enabled.

  • DMARC: DMARC relies on either DKIM or SPF alignment. With SPF disabled for gateway connections, DMARC can still pass via DKIM alignment. You can also enable ARC verification (arcVerify on SenderAuth) in relaxed mode – if Google/your gateway adds ARC seals, Stalwart will verify the chain and report results:

{
  "arcVerify": {"else": "relaxed"}
}

Note: Stalwart verifies ARC chains but does not produce new ARC seals itself.

3. Honoring the upstream gateway’s spam verdict

There is no built-in setting to automatically trust an upstream X-Spam-* header and skip re-evaluation. However, you have two tools:

  • Disable the spam filter for gateway IPs (see point 1 above) and rely entirely on the gateway’s verdict.
  • Write a Sieve script at the DATA stage (via the script field on MtaStageData) to read the gateway’s existing spam header (e.g. X-Spam-Status: Yes) and file the message into the Junk folder or discard it accordingly. End users can also do this with personal Sieve filters built on top of X-Spam-Status.

Summary of recommended settings for your topology

Setting Recommended value for gateway connections
enableSpamFilter false for gateway IPs, true otherwise
spfEhloVerify / spfFromVerify disable for gateway IPs, relaxed otherwise
arcVerify relaxed globally
DKIM verify keep enabled – survives forwarding

All of these are expression-based and configurable via the WebUI under Settings › MTA › Inbound.

Relevant docs:

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

The bot is correct, it is recommended to disable the spam filter (either dynamically or completely) when you have a filter in front of it. Also make sure to disable SPF as it will fail behind a gateway.

Regarding spam filter headers, you would need a per user Sieve script to redirect the messages to the Junk folder when the spam header is set to Yes.

Thank you for your feedback. Since we aren’t always satisfied with Google Workspace’s spam detection, we’re currently evaluating whether to reverse the process - that is, to tell Google Workspace to forward the spam as well and let Stalwart make the final decision.

What are your thoughts on this? Do you advise against it, and if not, what factors should we consider? It would certainly differ from the bot’s response.

If I decide to stick with Google Workspace, how do I handle the IP addresses and DNS settings with Google? After all, I don’t just have one gateway IP address to enter here. The best approach would probably be to check if “*.google.com” appears anywhere in the reverse DNS for the IP address - right? And if so, how do I do that?

On the Google IPs question, I wouldn’t try to enumerate them. Gate the checks on the connecting host’s reverse DNS instead, using the remote_ip.ptr variable in the verify expressions. For SPF, set spfFromVerify (and spfEhloVerify) to disable when the PTR is Google, else keep it relaxed:

{
"spfFromVerify": {
"match": [{"if": "contains(remote_ip.ptr, 'google.com')", "then": "disable"}],
"else": "relaxed"
}
}

The same remote_ip.ptr gating works for the iprev (reverseIpVerify) check. One important note though, PTR-based trust is only as good as the reverse zone, so it’s a bit weaker than an explicit IP allowlist. If you’d rather be strict, Google publishes its ranges via _spf.google.com.

On reversing it so Stalwart makes the final call, it is workable, but mind the alignment problem. Once Google forwards, the connecting IP is Google’s, so SPF on the original sender fails (that’s why we disable it in this topology);

DKIM usually survives if the message is unchanged, but DMARC will often fail on the SPF leg. Google does add ARC headers; Stalwart can verify ARC (arcVerify defaults to disable, so you’d enable it) but doesn’t seal, and ARC is being wound down, so I wouldn’t lean on it long-term. The spam filter’s enable flag is a global boolean, not per-path, so for the reverse approach you’d just keep Stalwart’s filter on and let it score everything, set Google to relay spam rather than quarantine/reject it, and loosen your DMARC/SPF reject policy for the Google hop so legitimate forwarded mail isn’t bounced. Check the docs at stalw.art/docs/mta/authentication/spf and the configuration variables page for remote_ip.ptr.

@stalwart Following up on this - I tried your suggested expression but the server rejects it. Here’s exactly what I applied to the SenderAuth singleton (v0.16.9):

"spfFromVerify": {
  "match": [{ "if": "contains(remote_ip.ptr, 'google.com')", "then": "disable" }],
  "else": "relaxed"
}

And the error on apply:

spfFromVerify: invalid value "Error parsing 'if' expression in condition #1: Variable "remote_ip.ptr" not allowed in this context"

So remote_ip.ptr isn’t a valid variable in this context. I also tried helo_domain, same result (“not allowed in this context”). The only connection variables that seem to be accepted here are remote_ip, remote_port, local_ip, local_port, listener, is_tls, asn and country.

dns_query(remote_ip, 'ptr') is accepted, but contains() on its result uses exact element equality, so it never matches a subdomain PTR like mail-x.google.com.

What’s the correct way in v0.16 to gate spfFromVerify / reverseIpVerify on the connecting client’s reverse DNS (or to otherwise identify mail relayed via Google)?

And should the rule local_port == 25 => `relaxed` be kept?

Apologies, remote_ip.ptr is not available in that context. Your options are matching Google by asn or IP ranges using the is_ip_in_cidr function.

Regarding dns_query, it returns an array so you have to pass dns_query(remote_ip, 'ptr')[0] to contains. However, to avoid running a reverse PTR lookup per message (even though they are cached), I suggest using asn instead.