Reproducing 0.15 RCPT-stage recipient rewriting (conditional catch-all + plus-addressing) on 0.16

Your question

We’re moving a setup from 0.15.5 to 0.16 and I’m trying to work out how to rebuild our recipient handling, now that catch-all and sub-addressing have moved into the Domain object.
The setup: Google Workspace is our primary MX. Google delivers anything it knows about, and anything it doesn’t recognise gets routed on to Stalwart. Stalwart hosts a subset of the mailboxes for the same domain — so it’s a split-delivery arrangement.
What we needed Stalwart to do:

Deliver locally when the recipient is a real Stalwart account.
Strip plus-addressing for our local domains.
Only fall back to the catch-all for unauthenticated, externally-originated mail to a local domain when the address isn’t a real account — and not let the catch-all swallow everything Google forwards in. The catch-all was only ever meant for the external path, never for the internal/forwarded flow.

On 0.15.5 we did all of this with three RCPT-stage rewrite rules:

# 1) real local recipient -> leave untouched
if:   is_local_address('', rcpt)
then: rcpt

# 2) plus-addressing on a local domain -> strip the tag
if:   contains(rcpt, '+') && is_local_domain('', rcpt_domain)
then: split(rcpt, '+')[0] + '@' + split(rcpt, '@')[1]

# 3) unauthenticated mail to a local domain, address is not a real account -> catch-all
if:   is_empty(authenticated_as) && !is_local_address('', rcpt) && is_local_domain('', rcpt_domain)
then: '@' + rcpt_domain

On 0.16, catchAllAddress and subAddressing are now per-domain fields on the Domain object, and there’s an allowRelaying flag described as being for split delivery. So the expression-driven logic we had doesn’t map 1:1 anymore.

What I’m trying to pin down:

Are RCPT-stage expression rewrites still in play on 0.16 (MtaStageRcpt), or is recipient handling now fully driven by the Domain object?
If I leave catchAllAddress empty on the domain, do I lose the catch-all completely, or can it still be triggered conditionally the way rule 3 did?
The important part is the conditional behaviour: catch-all for external/unauthenticated unknown recipients, but ignored for the mail Google forwards in. Is that still expressible, and if so where — on the Domain (a Custom sub-addressing rule?), or still at the RCPT stage?
Is allowRelaying the intended mechanism for the “Google in front, Stalwart as a partial backend” case?

Has anyone rebuilt this kind of split-delivery + conditional catch-all on 0.16 and can point me at the right place to configure it? CLI/JMAP examples very welcome.
Thanks.

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

Let me research this thoroughly across the docs and source.

Now I have a thorough picture. Let me compose the answer.

Here is a precise breakdown of how your three rules map to the current architecture, based on the current docs.


What changed and what didn’t

RCPT-stage rewrite expressions still exist. MtaStageRcpt still has a rewrite field that accepts a full expression with authenticated_as, rcpt, rcpt_domain, is_local_address(), is_local_domain(), etc. - exactly the variables your 0.15 rules used. That surface has not gone away.

However, the Domain object now also handles two of your three concerns natively:

0.15 rule 0.16 mechanism
Rule 1 - real local recipient, leave untouched No rule needed; default directory lookup does this
Rule 2 - strip plus-addressing for local domains subAddressing: Enabled (or Custom) on the Domain object
Rule 3 - conditional catch-all for unauthenticated external mail Partially covered by catchAllAddress on the Domain object, but it is a static field - not conditional

The conditional catch-all problem

catchAllAddress on Domain is a plain EmailAddress? field, not an expression - it is either set or it isn’t. There is no native “apply catch-all only when is_empty(authenticated_as)” toggle on the Domain object itself.

The right place to implement conditional catch-all behaviour is the RCPT-stage rewrite expression on MtaStageRcpt. The rewrite field runs before the domain-level catch-all lookup and has access to authenticated_as. So your rule 3 logic can live there. For example:

{
  "rewrite": {
    "match": [
      {
        "if": "is_empty(authenticated_as) && !is_local_address('', rcpt) && is_local_domain('', rcpt_domain)",
        "then": "'@' + rcpt_domain"
      }
    ],
    "else": "false"
  }
}

This rewrites the recipient to the catch-all form @domain only for unauthenticated sessions where the address is not a real account - your rule 3 exactly. Leave catchAllAddress unset on the Domain object so it cannot fire unconditionally; the rewrite expression becomes your sole catch-all gate.


Plus-addressing (rule 2)

Set subAddressing: Enabled on the Domain object. This is the standard + stripping behaviour. If you need more control (e.g. strip only when the stripped address resolves), use subAddressing: Custom with a customRule expression - but note that customRule only receives rcpt (the local part only, without the domain), so you cannot check authenticated_as there. For an auth-conditional plus-strip you’d fold it into the RCPT-stage rewrite instead, using the same authenticated_as guard.


Split delivery / allowRelaying (your question 4)

Yes, allowRelaying: true on the Domain object is the intended mechanism for the “Google in front, Stalwart as partial backend” arrangement. With it set, recipients that fail local directory lookup are accepted and relayed rather than rejected. The MtaStageRcpt-level allowRelaying expression controls relaying for entirely foreign domains; the domain-level flag controls unknown recipients within a domain Stalwart otherwise owns.


Recommended configuration sketch

  • Domain object: subAddressing: Enabled, catchAllAddress left unset, allowRelaying: false (you don’t want Stalwart to relay unknowns outward - Google is upstream of Stalwart here, not downstream).
  • MtaStageRcpt rewrite: conditional catch-all expression as shown above - only fires for unauthenticated, external, unknown-to-directory addresses.

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.

@helpbot If I disable catch-all at the domain level, how do I resolve this? In version 0.15.5, a catch-all was resolved using an alias like “@domain.tlddomain.tld.” After all, we still want to be able to receive catch-all emails. It just can’t go to the catch-all when sending.

Or could it be that recipient rewriting during the SMTP RCPT TO stage doesn’t work at all if the “Allow Relaying” mode is enabled for a domain?

As a workaround, I’ve now removed the domain catch-all, created an alias [email protected] for every domain, and defined the following rule:

if:

is_empty(authenticated_as) && !is_local_address(rcpt) && is_local_domain(rcpt_domain)

then:

'catch-all@' + rcpt_domain

I’d appreciate any feedback on the correct approach.