This is the key. The source code tells us exactly what happens. Let me look at the split logic more carefully:
In the code at line 65-66:
} else if let Some((new_local_part, _)) = rcpt.split_once('+') {
local_part = Cow::Borrowed(new_local_part);
Note it uses rcpt.split_once('+'), not local_part.split_once('+'). rcpt is the full [email protected] string. So new_local_part would be just user (not [email protected]). Then the address passed to the directory is constructed as:
Cow::Owned(format!("{local_part}@{domain_part}"))
// = "[email protected]"
So the stripping does happen server-side before the SQL query. The $1 passed to your queryRecipient should be [email protected] (without the +test part) when subAddressing is Enabled.
This means your SQL query is still the problem. Let’s look at exactly what you’re querying:
This is very clear from the source code. The key lines are:
} else if let Some((new_local_part, _)) = rcpt.split_once('+') {
local_part = Cow::Borrowed(new_local_part);
}
Note that it splits on '+' from the full rcpt string (which is just the local part at that point – no @domain). Then, when passing to an external directory (SQL), it reconstructs the address:
let address = if local_part.as_ref() == local_part_orig {
Cow::Borrowed(rcpt)
} else {
Cow::Owned(format!("{local_part}@{domain_part}"))
};
match directory.recipient(address.as_ref()).await? {
So the server does strip the +tag before querying the SQL directory and passes [email protected] (not [email protected]) as $1. This confirms the stripping works correctly when subAddressing: Enabled.
The problem must therefore be in the SQL query itself. Let’s look more carefully at what the user has:
SELECT email FROM stal_emails WHERE $1 like (emails)
The column being selected is email (singular), but the WHERE clause references emails (plural). These may be different columns, or emails may not exist. Also note the previous bot reply already pointed out the reversed LIKE – but the user says basic delivery to [email protected] works now, meaning the query returns something for direct addresses. The +tag stripped address [email protected] should also match… unless there’s a caching issue or the domain the query is run against is different.
Actually, re-reading carefully: the user says [email protected] works but [email protected] still fails. Since the source confirms stripping happens before the SQL query, and the base address works, there could be a negative cache issue – the server may have cached a negative result for [email protected] before subaddressing was fully set up.
Now I have a clear picture of what the server actually does. Let me look at the exact split logic:
From the source code at line 66-68 of crates/common/src/network/mta.rs:
} else if let Some((new_local_part, _)) = rcpt.split_once('+') {
local_part = Cow::Borrowed(new_local_part);
}
This is the key. When subAddressing is Enabled, the server calls rcpt.split_once('+') on the full recipient string ([email protected]), not on the local part. split_once('+') on [email protected] gives new_local_part = "user" and the remainder "[email protected]" is discarded.
Then at lines 108-113:
let address = if local_part.as_ref() == local_part_orig {
Cow::Borrowed(rcpt)
} else {
Cow::Owned(format!("{local_part}@{domain_part}"))
};
match directory.recipient(address.as_ref()).await? {
So when a SQL directory is configured, it passes [email protected] (the base address, with +test stripped) as $1 to your queryRecipient. The sub-address stripping does happen before the SQL query.
This means the problem is definitely still in your SQL query. Your current query:
SELECT email FROM stal_emails WHERE emails = $1
…needs to actually return a row for [email protected]. Please verify directly in your database that this query returns a result:
SELECT email FROM stal_emails WHERE emails = '[email protected]';
If that returns a row but [email protected] still fails, it could be a caching issue – the server caches domain and recipient lookups, and a stale negative cache entry for [email protected] (from before the fix) might still be live. Restarting the Stalwart server would clear the in-memory cache.
Also double-check that the column name in your WHERE clause is correct – you have emails as both the table name (stal_emails) and seemingly also as the column name you’re filtering on (WHERE emails = $1). Make sure the column name is what you actually have in your schema.
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.