Catch-all or subaddressing only with a specific prefix

Your question

I’m really struggling with getting addresses of the form prefix.*@example.com (whereas * is any text) to be redirected to [email protected] . Not full catch-all of *@ , not subaddressing bar+*@bar@ , but just everything with a specific prefix to go to given mailbox. I tried:

  1. Settings → MTA → Session → RCTO TO Stage → Recipient rewriting:
IF matches('^prefix\\.(.+)@example\\.com$', rcpt)
THEN "[email protected]"
ELSE false
  1. Management → Domain → Catch-all → Subaddressing

Custom Subaddressing

// prefix.*@domain -> bar
IF matches('^prefix\\.(.+)$', rcpt)
THEN "bar"

// Keep normal subaddressing: foo+bar@domain -> foo
IF matches('^([^+]+)\\+([^+]+)$', rcpt)
THEN $1

ELSE rcpt
  1. System Sieve script:
require ["envelope", "redirect", "regex"];
if envelope :matches "to" ["prefix.*@example.com"] {
  redirect "[email protected]";
  stop;
}

(but the Sieve script probably runs after RCPT TO stage, so the mail is already rejected when the Sieve script runs.)

In all cases, Stalwart rejects [email protected] as “Mailbox does not exist”

I run Stalwart 0.16.5 and I made the changes using the admin WebUI. I [Save] on the relevant page. I tried reloading settings Actions | Server settings and Actions | Lookup stores. I even restarted the server. But whatever I do, the problem persists. I’m on this for hours, asked AIs dozens of times, but no help. Can anybody help?

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.

I have a clear picture now. Here’s what’s happening and how to fix it.


The root cause: rewriting runs before, but validation runs independently

The critical sequence is:

  1. The RCPT TO stage first checks whether the recipient exists in the directory (the “Mailbox does not exist” rejection).
  2. Address rewriting via the rewrite expression field also happens at RCPT stage, but the subaddressing/custom rule is what feeds the directory lookup for unknown addresses.
  3. A full-domain catch-all is only triggered after both of those.

Your approach #2 (Custom Subaddressing) is the correct mechanism, but the expression is being applied to the local part only (not the full rcpt including domain). The docs are explicit about this: customRule receives rcpt as the local part only, and the domain is appended back by the server. The issue is that your regex targets prefix.(.+) in the local part and returns "bar" – but bar must actually be a valid local account in your directory for delivery to succeed.


The correct approach: Custom Subaddressing

Under Management > Domains > [your domain] > Sub-Addressing, set it to Custom with a rule like this (JSON):

{
  "subAddressing": {
    "@type": "Custom",
    "customRule": {
      "match": [
        {"if": "matches('^prefix\\.(.+)$', rcpt)", "then": "'bar'"},
        {"if": "matches('^([^+]+)\\+.+$', rcpt)", "then": "$1"}
      ],
      "else": "rcpt"
    }
  }
}

Key points:

  • rcpt here is the local part only (e.g. prefix.anything, not [email protected]). So your regex should not include @example\.com.
  • The result 'bar' must be a literal string constant (quoted), not a variable – and bar must be an actual existing account in your directory. If [email protected] doesn’t exist as a real account, delivery will still fail.
  • Stalwart uses the returned local part to look up the mailbox; if it isn’t found, the catch-all kicks in (if configured), otherwise it’s rejected.

Why the RCPT TO rewrite (approach #1) didn’t work

The rewrite field in the RCPT stage rewrites the address for routing, but the rewritten address still gets validated against the directory. If [email protected] doesn’t exist as a recognized local account, rewriting to it doesn’t help – it will still be rejected as unknown.


Summary checklist

  1. [email protected] must exist as a real account (or alias) in your directory.
  2. Use Custom Subaddressing on the domain object (not RCPT rewrite).
  3. In customRule, match against the local part only (no @domain in the regex).
  4. Return a plain string 'bar' (with quotes), not "[email protected]".

Docs references:

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.

bot,
To your key points, none of them helped to resolve the issue:

  1. It’s already as you say. As you can see in my example, I am matching the local part only, not the domain.
  2. The result was `“bar”`, a literal, quoted string constant. I now changed it to `‘bar’` (single quotes instead of double quotes), but no change, still doesn’t work.
  3. The target mailbox bar exists. There is a user “bar” in domain “example.com” with email address [email protected] . It’s already as you said.

The problem persists.

Based on a tip from Matrix channel, I also tried to set up an alias for the account, with local part `prefix.*`, but that wasn’t accepted either, it gives error “Invalid email local part”:

A few things are tripping you up at once and they’re easy to mix up.

The alias attempt with the local part prefix.* was never going to work. Stalwart only accepts alphanumerics and ., +, -, _ in local parts (same restriction as RFC 5321 plus a sanity check), so * is rejected at validation time. There’s no wildcard alias mechanism, that’s not what aliases are for.

For the custom subaddressing route: the rule is configured on the domain (Domains > example.com > Sub-addressing > Custom), not on the directory, and the expression receives only the local part as the rcpt variable, not the full address. So if your regex was matching against [email protected] it never matched anything, it was only ever seeing prefix.foo. The expression also has to return a local part (just bar, not [email protected]), and that local part is then looked up against the domain, so the account [email protected] has to already exist as a real mailbox. The rule needs to handle both cases: when the local part starts with prefix. return bar, otherwise return the original local part unchanged so normal addresses keep resolving. Something along the lines of:

if_then(starts_with(rcpt, "prefix."), "bar", rcpt)

or with a regex:

if_then(matches("^prefix\\..+$", rcpt), "bar", rcpt)

The cleanest fit for what you’re describing is actually the RCPT rewrite at the session level, not subaddressing. That one runs before the directory lookup and replaces the envelope recipient, so the original [email protected] gets rewritten to [email protected] and then resolved normally. There the rcpt variable is the full address. One catch worth knowing about: the rewrite result is only applied if the string contains an @, otherwise it’s silently dropped and the original recipient is used, which would explain a “Mailbox does not exist” if your expression accidentally returned just the local part or an empty string on the non-matching branch. So write it as:

if_then(matches("^prefix\\..+@example\\.com$", rcpt), "[email protected]", rcpt)

under session.rcpt.rewrite, and make sure [email protected] is a real account.

The Sieve idea you tried at the end won’t help for this because, as you guessed, it runs after the recipient has already been accepted or rejected; the rejection you saw was happening earlier in RCPT TO.

If you do go with the subaddressing route and it still seems inert, check the logs at startup for an expression compile error: if the custom rule fails to compile, the domain silently falls back to having subaddressing effectively disabled rather than complaining loudly. The session.rcpt.rewrite path is logged too, you should see a RcptToRewritten event when it fires, which is a useful sanity check that your expression is actually matching.

It still seems when there is a subadressing, rcpt is accepted but there is no rewrite and delivery fails, resulting in DSN sending instead of rejecting. At (stalwart/crates/common/src/network/mta.rs at main · stalwartlabs/stalwart · GitHub) line 110, directory is verified against the subadressing address but if it suceed, there is no rewrite down. It seems it lacks some tests like on masked email and cache:

if local_part.as_ref() == local_part_orig {
Ok(RcptResolution::Accept)
} else {
Ok(RcptResolution::Rewrite(format!(
“{local_part}@{domain_part}”
)))
}

What do you think?

I can now confirm that the problem is with this regexp. I used exactly that regexp as you suggested (just replaced “prefix” with my prefix), and it does not work. There is no compile error at startup. But the message is rejected with “mailbox does not exist”.

If I use

starts_with(rcpt, "prefix.")

as condition, then it does work. Thanks for the tip for that function.

So, I can confirm that the problem is not with the subaddressing feature, but with the `matches()` function or the regexp. Given that you say it should work, and the docs say it should work, it appears to be a bug.

There’s another problem: If I use `bar` as THEN, the WebUI happily accepts it, but it fails to compile. It expects `“bar”` with quotes - that’s not obvious at all. Stalwart complains on startup in the log, but that’s burried underneath tons of other messages. Configuration errors should be caught in the UI when I configure, not on next startup in the log.

@mdecimus Hey Mauro, this is a confirmed bug in the regexp function or how it’s called from the feature. Do you need me to re-file this under Issues, or is the above sufficient?

I was facing the same issue and did some analysis with Claude. May someone help me providing a pull-request for this? Thanks.

Bug: matches() in expressions produces wrong regex due to double-escaping in parse_string

Summary

The parse_string function in crates/common/src/expr/tokenizer.rs incorrectly handles backslash escape sequences, causing the matches() function to compile the wrong regex pattern.

The backslash character is pushed to the output buffer before determining whether it starts an escape sequence, resulting in double backslashes in the compiled regex.

Root Cause

File: crates/common/src/expr/tokenizer.rs, function parse_string() (line ~266)

Current code:

fn parse_string(&mut self, stop_ch: u8) -> Result<CompactString, String> {
    let mut buf = Vec::with_capacity(16);
    let mut last_ch = 0;
    let mut found_end = false;

    for &ch in self.iter.by_ref() {
        if last_ch != b'\\' {
            if ch != stop_ch {
                buf.push(ch);        // BUG: pushes '\' to buf before knowing it's an escape
            } else {
                found_end = true;
                break;
            }
        } else {
            match ch {
                b'n' => buf.push(b'\n'),
                b'r' => buf.push(b'\r'),
                b't' => buf.push(b'\t'),
                _ => buf.push(ch),   // pushes the escaped char (second char after '\')
            }
        }
        last_ch = ch;
    }
    // ...
}

Trace for input \\. (backslash backslash dot)

Step ch last_ch Branch Action buf
1 \ 0 normal push \ [\]
2 \ \ escape push \ [\\]
3 . \ escape (BUG!) push . [\\.]

Result: 3 bytes \\. passed to Regex::new()

Regex interprets: \\ = literal backslash, . = any character

Expected: 2 bytes \.Regex::new interprets as literal dot

The bug in step 3: after \\, last_ch is still \ (set at end of step 2), so the . is treated as another escaped character. The root problem is step 1: the first \ should NOT be pushed, because it’s the start of an escape sequence.

Why starts_with() works but matches() doesn’t

starts_with(rcpt, "prefix.") passes "prefix." through parse_string with no backslashes, so no escaping issue occurs. It correctly returns "prefix." and the comparison works.

matches('^prefix\\..+$', rcpt) passes ^prefix\\..+$ through parse_string, producing regex ^prefix\\..+$ (literal backslash + any char) instead of ^prefix\..+$ (literal dot). The regex fails to match prefix.foo because there’s no backslash in the input.

Fix

Replace parse_string with a proper escape-state-machine:

fn parse_string(&mut self, stop_ch: u8) -> Result<CompactString, String> {
    let mut buf = Vec::with_capacity(16);
    let mut escape = false;
    let mut found_end = false;

    for &ch in self.iter.by_ref() {
        if escape {
            match ch {
                b'n' => buf.push(b'\n'),
                b'r' => buf.push(b'\r'),
                b't' => buf.push(b'\t'),
                _ => buf.push(ch), // handles \\, \', \", and unknown escapes
            }
            escape = false;
        } else if ch == b'\\' {
            escape = true; // don't push '\'; wait for next char
        } else if ch == stop_ch {
            found_end = true;
            break;
        } else {
            buf.push(ch);
        }
    }

    if found_end {
        CompactString::from_utf8(buf).map_err(|_| "Invalid UTF-8".into())
    } else {
        Err("Unterminated string".to_string())
    }
}

Trace of fix for \\.

Step ch escape Action buf
1 \ false set escape=true []
2 \ true push \, esc=false [\]
3 . false push . [\.]

Result: 2 bytes \.Regex::new interprets as literal dot ✓

Trace for single backslash \.

Step ch escape Action buf
1 \ false set escape=true []
2 . true push ., esc=false [.]

Result: 1 byte . → Regex interprets as “any char”

This means users must use \\. in expression strings to get \. in regex, which is standard behavior (same as Java, Python, JavaScript string literals).

Breaking Change Note

Users who currently write matches('\\.', rcpt) and get correct behavior (accidentally, due to the double-push bug) will continue to work correctly after the fix.

Users who write matches('\.', rcpt) currently get \. in the regex (literal dot) due to the bug. After the fix, they’ll get . (any char) — still matches but is more permissive. These users should update to matches('\\.', rcpt).

The fix aligns string escape handling with standard language conventions.

Affected Components

  • Custom sub-addressing with matches() (the reported issue)

  • ALL expression string literals that use backslash escapes

  • RCPT rewrite rules using matches()

  • Any other expression context using regex patterns

Test Cases

#[test]
fn test_parse_string_escapes() {
    // \\\\ in expression → \\ in output → regex literal backslash
    assert_parse_string("\\\\", "\\");

    // \\. in expression → \. in output → regex literal dot
    assert_parse_string("\\\\.", "\\.");

    // \\n in expression → newline in output
    assert_parse_string("\\n", "\n");

    // No escapes
    assert_parse_string("hello", "hello");

    // Mixed
    assert_parse_string("^prefix\\\\..+$", "^prefix\\..+$");
}

Workaround (for current v0.16.x without fix)

Use starts_with() or contains() instead of matches():

if_then(starts_with(rcpt, "prefix."), "bar", rcpt)

---
 crates/common/src/expr/tokenizer.rs | 29 ++++++++++++-----------
 1 file changed, 15 insertions(+), 14 deletions(-)
 
diff --git a/crates/common/src/expr/tokenizer.rs b/crates/common/src/expr/tokenizer.rs
--- a/crates/common/src/expr/tokenizer.rs
+++ b/crates/common/src/expr/tokenizer.rs
@@ -266,31 +266,32 @@
     fn parse_string(&mut self, stop_ch: u8) -> Result<CompactString, String> {
         let mut buf = Vec::with_capacity(16);
-        let mut last_ch = 0;
+        let mut escape = false;
         let mut found_end = false;
 
         for &ch in self.iter.by_ref() {
-            if last_ch != b'\\' {
-                if ch != stop_ch {
-                    buf.push(ch);
-                } else {
-                    found_end = true;
-                    break;
-                }
-            } else {
+            if escape {
                 match ch {
                     b'n' => {
                         buf.push(b'\n');
                     }
                     b'r' => {
                         buf.push(b'\r');
                     }
                     b't' => {
                         buf.push(b'\t');
                     }
                     _ => {
                         buf.push(ch);
                     }
                 }
+                escape = false;
+            } else if ch == b'\\' {
+                escape = true;
+            } else if ch == stop_ch {
+                found_end = true;
+                break;
+            } else {
+                buf.push(ch);
             }
-
-            last_ch = ch;
         }
 
         if found_end {
 

ok, I cannot create a pull-request (An owner of this repository has limited the ability to open a pull request to users that are collaborators on this repository.). Please check github repo 32u-nd/stalwart@da0cf37 and cherry-pick. Thanks.

cargo test -p common
# test ok
# test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s