Mailing lists fail SPF/DKIM

Issue Description

The built-in MailingList object only fans a message out to its recipients. The original envelope sender (MAIL FROM) and the From: header are kept as they were. As a result, list mail to external members fails authentication at strict receivers, most notably Gmail, which makes the integrated mailing lists unusable for lists with Gmail members.

Expected Behavior

RFC 5321 section 3.9.2 says that when a list is expanded, the return address in the envelope should be changed so that errors go to the list administrator rather than the originator. Established list software (Mailman, Sympa, etc.) also generally does the following when delivering to strict receivers:

  1. Envelope sender: use a list-owned address on the list’s domain (e.g. list-bounces@domain, ideally VERP), so SPF is evaluated against a domain the server controls and bounces reach the list owner.
  2. Optional From rewrite (for DMARC alignment): From: “Original Sender via listname” list@domain, with the original author in Reply-To.
  3. DKIM-sign list mail with the list domain’s key, so DKIM alignment is possible.

Actual Behavior

Delivery works for some recipients, but Gmail rate-limits the message:

421 Your email has been rate limited because it is unauthenticated.
Gmail requires all senders to authenticate with either SPF or DKIM.
Authentication results: DKIM = did not pass
SPF [<sender's address>] with ip: [<my server IP>] = did not pass

This is expected given how the list currently works. The list server’s IP is not authorized in the original sender’s SPF record, and the sender’s DKIM signature either doesn’t survive or isn’t aligned. Since the envelope sender is the original author’s address, SPF is evaluated against a domain the list server has no control over. Messages from authors whose domains publish p=quarantine or p=reject also fail DMARC.

Reproduction Steps

  1. Create mailing list with external addresses, one of which is a gmail address.
  2. Send mail to list from an external address

Stalwart Version

v0.16.x

Installation Method

NixOS

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

OIDC

Additional Context

I tried rewriting the envelope with a sieve script, but I couldn’t get it to work.

There is a reply to a different issue that suggests that the envelope is in fact rewritten, but that is not what I experience on my setup:

A MailingList object holds bare addresses with no link back to an account, and it only rewrites the recipient side

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

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

This confirms there’s no documented option for envelope sender rewriting on the MailingList object itself; it has no such field. This confirms the user’s issue is a real gap in the docs/product, not something addressed. The answer should be the fallback or a confirmation with guidance to sieve-based workaround, but user already tried sieve and failed. Let me check sieve capabilities for expanding lists / envelope rewrite to see if there’s a documented way.

This is not that productive—mirror of the repo is an old match. Let me search for actual delivery/expansion logic regarding mailing lists, to confirm the fan-out behavior stated by the user and whether there’s a rewrite option.

Confirmed: this is current behavior, not a misconfiguration on your end. Stalwart’s built-in MailingList directory object is a pure recipient-expansion mechanism. When a message is accepted, the envelope sender (MAIL FROM) has already been fixed by the time recipient resolution/expansion happens (expansion occurs during queue spooling, after DATA), and the list object itself has no fields for a bounce/return-path address, VERP, From-header rewriting, or per-list DKIM signing. Looking at the source, rcpt_resolve/expand_nested_lists only produce an expanded recipient set; the original envelope-from and message headers pass through untouched to every expanded recipient.

There is no supported, built-in “mailing list mode” that does RFC 5321 §3.9.2-style return-path rewriting, From-header munging, or list-domain DKIM re-signing for messages distributed through a MailingList object today. The address-rewriting hooks that do exist (MtaStageMail/MtaStageRcpt Sieve or expression-based rewrite) act on the raw envelope before and during the RCPT/MAIL transaction, not conditioned on “this message is bound for list X,” which is why your Sieve attempt likely couldn’t cleanly key off list membership. DKIM signing domain selection (dkimSignDomain on SenderAuth) is based on sender_domain/authentication, again not list membership.

Practically, the current docs describe only the pieces for manual envelope/header rewriting (not list-aware) and DKIM signing by sender domain, with no mailing-list-specific bounce/alignment handling:

So the Gmail rate-limiting you’re seeing is expected given the current design; there’s no supported configuration path to get SPF/DKIM alignment for externally-authored list mail distributed through the built-in MailingList object.

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

The built-in MailingList is expanded at RCPT time like an alias: the recipients are replaced and nothing else changes, so in RFC 5321 terms it’s a 3.9.1 alias; the return-path change you quote from 3.9.2 is exactly what separates a list from an alias. It doesn’t rewrite From or add a list DKIM signature either, and 1.0 keeps the same behaviour. The sentence you quoted meant the recipient list is rewritten; it says nothing about the envelope sender. The docs should spell this out, and I’ll fix that.

You can get the SPF part with a system Sieve script on the DATA stage (MTA stage scripts can change the envelope; user scripts can’t, which is likely why your attempt failed): match the list address in To/Cc and set "envelope.from" "list-bounces@yourdomain". Bounces then go to you and SPF is checked against your domain. It changes the sender for the whole transaction, local recipients included.

For DMARC you would also have to rewrite From and sign those messages, and the default DKIM rule deliberately signs only authenticated senders. At that point you’re building a list manager in Sieve, so for lists with Gmail members I’d run Mailman or Sympa in front of Stalwart. I’ve noted the request for proper list handling.

Thanks for your reply! Proper 3.9.2 list handling would be great to have!

Before going the mailman route (which is a bit overkill because it’s just a single small list), I’d like to try sieve again, but I still can’t get it to work.

For debugging, I created this as a system script “my-script”:

require ["variables", "envelope", "editheader"];
addheader :last "X-Sieve-Debug" "ran; env-from=${envelope.from};";

And then I added 'my-script' at MTA → Session → DATA Stage → Run Script.

Sievepad shows that the script adds the header to every mail, but none of the mails I send actually get this header. What am I doing wrong?

I got a step further by creating a User instead of a MailingList and then add the following script to turn it into a poor man’s mailing list:

require ["variables", "envelope", "editheader", "regex"];

set "list_addr"   "[email protected]";
set "list_name"   "My List";
set "bounce_addr" "[email protected]";


set "orig_subject" "";
set "orig_sender"  "";
if header :matches "subject" "*"      { set "orig_subject" "${1}"; }
if address :all :matches "from" "*"   { set "orig_sender"  "${1}"; }
# Display name, falling back to the address if there is no name
set "orig_name" "${orig_sender}";
if header :regex "from" "^\"?([^\"<]*[^\"< ])\"?[ ]*<[^>]*>[ ]*$" {
    set "orig_name" "${1}";
}

if not header :contains "subject" "[${list_name}]" {
    deleteheader "Subject";
    addheader "Subject" "[${list_name}] ${orig_subject}";
}

deleteheader "From";
addheader "From" "\"${orig_name} via ${list_name}\" <${list_addr}>";
addheader "Reply-To" "${orig_sender}";

set "envelope.from" "${bounce_addr}";


redirect "[email protected]";
redirect "[email protected]";
redirect "[email protected]";

keep;

I could confirm that the script in fact does the proper rewriting and the resulting mail is accepted by gmail (yay!). However, only the first redirect is done, Alice and Bob don’t get their messages. I tried to set Redirects and Outgoing Messages in [Sieve → User Interpreter] to a higher value but that didn’t fix it.

So now there are two separate things that would resolve my issue:

  1. Getting the system script to run (preferred)
  2. Getting Stalwart to perform multiple redirects from a user script

Make sure the system script has Active ticked (isActive). System scripts are created inactive, and the DATA stage skips an inactive one, leaving only a sieve.script-not-found entry in the log. Reload the settings after ticking it and your debug header should show up.

The one-redirect cutoff in the user script is the default: Redirects on the User Interpreter is 1 out of the box (maxRedirects). Raising it together with Outgoing Messages is the right change, but it needs a reload too; with it applied you should see one sieve.send-message event per recipient. User scripts ignore set "envelope.from", so those redirects go out with the account’s own address as envelope sender, which is your domain and why Gmail was happy.

Once the system script runs, I’d move your From and Reply-To rewrite into it as well. I said in my earlier post that you’d also need to sign those messages for DMARC, and that was wrong: with From on the list address and the envelope sender on [email protected], SPF is aligned with your domain and DMARC passes on that alone.

Thank you, that helped. It works now! The main thing I was missing was reloading the settings :grimacing:

For future reference, here is my final system script:

require ["variables", "envelope", "editheader", "regex", "reject"];

set "list_addr"   "[email protected]";
set "list_name"   "My List";
set "bounce_addr" "[email protected]";

if anyof (
    envelope :is "to" "${list_addr}",
    header :contains ["to", "cc"] "${list_addr}"
) {

    if not address :is "from" [
	    # has to be kept in sync with the MailingList object, so I generate both from templates
		"[email protected]", "[email protected]"
    ] {
        reject "You are not allowed to post to this list.";
        stop;
    }

    if not header :contains "subject" "[${list_name}]" {
        set "orig_subject" "";
        if header :matches "subject" "*"      { set "orig_subject" "${1}"; }
        deleteheader "Subject";
        addheader "Subject" "[${list_name}] ${orig_subject}";
    }

    set "orig_sender"  "";
    if address :all :matches "from" "*"   { set "orig_sender"  "${1}"; }
    # Display name, falling back to the address if there is no name
    set "orig_name" "${orig_sender}";
    if header :regex "from" "^\"?([^\"<]*[^\"< ])\"?[ ]*<[^>]*>[ ]*$" {
        set "orig_name" "${1}";
    }

    deleteheader "From";
    addheader "From" "\"${orig_name} via ${list_name}\" <${list_addr}>";
    if not exists "reply-to" {
      addheader "Reply-To" "${orig_sender}";
    }

    set "envelope.from" "${bounce_addr}";

    addheader "List-Id" "${list_name} <${list_addr}>";
    addheader "List-Post" "<mailto:${list_addr}>";
    addheader "Precedence" "list";
}