Catchall ignored for local bounces

Issue Description

When a message bounces during the outbound SMTP connection, the returned message is discarded if the recipient is not found (VERP), even if there is a catchall set for the sending domain.

Expected Behavior

If there is a catchall set, it should be honoured on both regular and bounce paths.

Actual Behavior

There are two separate recipient-resolution paths and only one of them honors catch-all.
Inbound SMTP RCPT path — crates/common/src/network/mta.rs, rcpt_resolve(). Catch-all is applied unconditionally for an unresolved recipient:
rust// Catch-all resolution
if let Some(catch_all) = &domain.catch_all {
return Ok(RcptResolution::Rewrite(catch_all.to_string()));
}
The RCPT handler (crates/smtp/src/inbound/rcpt.rs) then rewrites the envelope recipient to the catch-all address before the message is queued. That’s why ordinary inbound mail to unknown recipients works.
Local delivery path — crates/email/src/message/delivery.rs:122:
rustlet account_id = match self.account_id_from_email(&rcpt.address, false).await {
Ok(Some(account_id)) => account_id,
Ok(None) => {
result.status.push(LocalDeliveryStatus::PermanentFailure {
code: [5, 5, 0], reason: “Mailbox not found.”.into(),
});
continue;
}

Reproduction Steps

Any mailing list software operating behind Stalwart would use VERP, I am using mlmmj. Reproducible by sending a message with MAIL FROM set to a VERP address.

Relevant Log Output

queueId = 308881299387452928, queueName = “local”, from = “<>”, to = [“test-94q8e5k+bounces-9-{REMOVED}”], size = 3300, total = 1, to = [“<test-94q8e5k+bounces-9-{REMOVED} (host ‘localhost’ rejected command ‘RCPT TO:<test-94q8e5k+bounces-9-{REMOVED}>’ with code 550 (5.5.0) ‘Mailbox not found.’)\r\n”]

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

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

The report is accurate and well-researched. Here’s a confirmation of what the code shows:

The user has correctly identified a genuine bug. In crates/common/src/cache/principals.rs, account_id_from_email() takes a resolve boolean parameter that gates catch-all (and sub-addressing) lookup:

pub async fn account_id_from_email(
    &self,
    address: &str,
    resolve: bool,       // <-- controls catch-all
) -> trc::Result<Option<u32>> {
    ...
    if resolve && result.is_none() && let Some(catch_all) = &domain.catch_all {
        result = self.rcpt_id_from_email(catch_all).await?;
    }

In crates/email/src/message/delivery.rs line 122, the local delivery path calls this with false:

match self.account_id_from_email(&rcpt.address, false).await {

…which skips catch-all resolution entirely. The inbound SMTP RCPT path (rcpt.rs) uses a different code path (rcpt_resolve() in mta.rs) that does apply catch-all before the message is even queued, so inbound mail works correctly. But bounce messages delivered locally (e.g. VERP returns from a mailing list) bypass that SMTP RCPT path and go directly to local delivery, where resolve: false means catch-all is never consulted.

This is a confirmed code-level bug: the fix would be to pass true instead of false in the local delivery call to account_id_from_email, so that catch-all resolution is applied consistently on the local delivery path. This is not a configuration issue; there is no workaround available through Stalwart’s settings.

A human/developer will need to address this in the codebase.

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.

This will be fixed in v0.16.7.