Subaddress and sql directory

Issue Description

When trying to sens an email to [email protected], it says that mailbox doesn’t exist bud subaddressind is enabled for mydomain.com

Expected Behavior

The email should be accept and delivered

Actual Behavior

The email is refused with code 550 (5.5.0) ‘Mailbox not found.’)

Reproduction Steps

Configure stalwart with SQL directory and the following queries :
Login Query* :
select name, email,secret, type, description from stalwart_accounts where name =$1 or email = $1

Recipient Query* :
SELECT email FROM stal_emails WHERE $1 like (emails)

Member Of Query(optional) :
SELECT member_of FROM stalwart_groupes WHERE name = $1 or email = $1

E-mail Aliases Query(optional) :
SELECT emails FROM stal_emails WHERE email = $1

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

SQL

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

There are two separate issues at play here, and fixing both is required for subaddress delivery to work.


1. The subAddressing setting on your Domain must be enabled.

Subaddressing is not controlled by your SQL queries – it is configured per-domain on the Domain object via the subAddressing field. When set to Enabled (the default), the server strips the +tag part from the envelope recipient and looks up only the base address ([email protected]). If this is set to Disabled, the full address [email protected] is passed to the directory lookup as-is, and since no such exact address exists, it returns nothing and the server rejects with 550.

Check in the WebUI under Management > Domains > Domains, open your domain, and confirm subAddressing is set to Enabled.


2. Your queryRecipient SQL may not be stripping the subaddress tag either.

Even if subaddressing is enabled at the domain level (so the server canonicalises the address before the query), your current Recipient Query:

SELECT email FROM stal_emails WHERE $1 like (emails)

…is comparing the full input string using LIKE against your emails column. If the column stores [email protected] but the incoming value (after sub-address stripping by the domain setting) is [email protected], this should match – but if subaddressing is Disabled at the domain level and the raw [email protected] lands in $1, the LIKE comparison will fail unless your stored values include wildcards.

The recommended fix is to ensure subAddressing is Enabled on the domain so the server does the stripping before passing $1 to SQL, and then your query simply needs to match [email protected] exactly.


In summary:

  • Go to Management > Domains > Domains, edit your domain, and make sure subAddressing is Enabled.
  • Once enabled, the server canonicalises [email protected] to [email protected] before running the recipient query, so your SQL just needs to match the base address.

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. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

Thanks for that explanation, but subaddressing is enabled for the domain and problem is still there.

Let me look more carefully at how subaddressing interacts with the SQL recipient query.

The core problem is almost certainly your queryRecipient SQL. Let’s look carefully at what you have:

SELECT email FROM stal_emails WHERE $1 like (emails)

This has the operands backwards. SQL LIKE syntax is column LIKE pattern, but here you have written $1 LIKE (emails) – meaning you are asking “does the input address match the pattern stored in the emails column?” That only works if your stored values contain SQL wildcard characters like %. If your emails column stores plain addresses like [email protected], this comparison will never match because plain addresses are not valid LIKE patterns (they have no wildcards).

The correct form is:

SELECT email FROM stal_emails WHERE emails = $1

or if you genuinely want a LIKE comparison (e.g. your column stores patterns):

SELECT email FROM stal_emails WHERE email LIKE $1

Also, note that the docs’ reference schema and example queries expect queryRecipient to return the account name (the value in columnEmail/name), not just an email address. The default schema query looks like:

SELECT name, secret, description, type FROM accounts WHERE name = $1 AND active = true

When subAddressing is Enabled, the server strips the +tag part and passes only [email protected] as $1 to this query. So with subaddressing enabled and the query fixed to do a straight equality match on the base address, it should work.

Steps to verify:

  1. Confirm subAddressing: Enabled is set on the domain (you said it is – good).
  2. Fix your queryRecipient to use = instead of the reversed LIKE:
    SELECT email FROM stal_emails WHERE emails = $1
    
  3. Optionally test the query directly in your database with $1 = '[email protected]' (the base address, no +tag) to confirm it returns a row.

Docs reference: SQL Directory - queryRecipient

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.

I carefully followed your instructions. Mails sent to [email protected] are accepted, but same error persist when sent to [email protected]

Are you sure that the pattern +test is not present in $1 for the SQL query ?

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.

So I checked my sql queries on the database server and everything works fine there.

I also restarted stalwart server and invalidate all caches in webui to be sure.

I also make check trough the webui with Delivery tests. If I test address [email protected], it says “Recipient accepted”. If I test an address that doesn’t exists, it says that “Mailbox not found”. I also see the query in my database server log.

But when I send an email to [email protected], stalwart server keep on saying that “Mailbox not found”. I can also see the statement in my database server log.

To me seems that there is a bug and that Delevery tests are not done the same way as true delivery when inbound mail arrive.

Please check your SQL server logs to see which queries are being done when an email is received. Alternatively try using the internal directory to test subaddressing and once you have it running enable your SQL directory again.

This is very revealing. I can now see what’s happening clearly.

The source code at get_directory_for_cached_domain shows a critical difference between Community Edition and Enterprise Edition:

  • Enterprise Edition: uses the directoryId linked to the specific domain for recipient lookups.
  • Community Edition: always uses get_default_directory() – the default directory, regardless of what directory is set on the domain.

This is likely the root issue. Here’s the flow when a real SMTP delivery comes in for [email protected]:

  1. The server strips +test correctly, leaving [email protected] as $1.
  2. It calls get_directory_for_cached_domain() to find which SQL directory to query.
  3. On Community Edition, this ignores the domain’s directoryId and uses the default directory instead.
  4. If your SQL directory is not set as the default directory, the lookup falls through to the internal registry (which has no record for that user), and returns “Mailbox not found”.

The WebUI “Delivery test” tool may exercise a different code path that does respect the domain’s configured directory, which would explain why it says “Recipient accepted” while real SMTP delivery fails.

What to check/fix:

Go to Settings > Authentication > Directories in the WebUI and verify which directory is set as the default. Your SQL directory needs to be the default directory for recipient lookups to reach it on Community Edition. If you have the internal directory set as default, switch your SQL directory to be the default.

This isn’t covered explicitly in the current docs, but the behavior is confirmed by the server source code. A human staff member may be able to confirm whether this is the expected Community Edition limitation or a bug, and whether there is a supported workaround.

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.

I got an Enterprise Edition so your answer is not relevant here. Or the problem is the same with both Editions.

Looking at the Enterprise Edition path in get_directory_for_cached_domain:

return domain
    .id_directory
    .and_then(|domain_id| self.core.storage.directories.get(&domain_id))
    .or_else(|| self.get_default_directory());

For Enterprise Edition, it uses the directory linked to the domain via directoryId. If no directoryId is set on the domain, it falls back to the default directory. So the question is the same either way: which directory is actually being used for the lookup?

The most direct way to confirm what query is being sent to your SQL server is to check your database query logs at the moment a real SMTP delivery attempt arrives for [email protected]. Specifically, look at:

  1. What address is passed as $1 – is it [email protected] (stripping failed) or [email protected] (stripping worked)?
  2. Which database/connection the query comes from – this confirms whether the right SQL directory is being hit.

Based on the source code, the sub-address stripping (split_once('+') on the full rcpt string) happens before the directory lookup, so $1 should be [email protected]. If your DB logs show [email protected] being queried, that would confirm a bug in the stripping logic for your specific setup.

Stalwart staff have already suggested checking your SQL server logs for the exact queries during real delivery – that will definitively answer whether this is a stripping issue, a wrong-directory issue, or a query mismatch.

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.

I checked and directoyId is set in my domain to the database server.

In database server log I can see that the stripping worked.

Looking at the source code flow again, after the SQL directory returns a result, there’s an important detail: the query must return the right columns that map to the columnEmail, columnSecret, columnClass, and columnDescription fields of your directory configuration.

Your queryRecipient is:

SELECT email FROM stal_emails WHERE emails = $1

This only returns one column (email). However, the server expects the result to contain at minimum the account name (mapped via columnEmail, which defaults to "name"). Looking at the default schema, queryRecipient is expected to return account details – name, secret, description, type – not just an email address.

The column the server reads first from queryRecipient results is columnEmail (default: "name"). If your query returns a column called email but the directory’s columnEmail is set to "name", the server won’t find the account name it needs and may treat the result as invalid.

What to check:

  1. Does your SQL directory’s columnEmail setting match the column name returned by your queryRecipient? If your query returns a column called email, set columnEmail to "email" in your directory configuration.

  2. More importantly, compare your query to the reference schema. The default queryRecipient returns name, secret, description, type. Your query returns only email. Try rewriting it to return the account identifier (the name field in the reference schema – the login name, not an email address):

SELECT a.name, a.secret, a.description, a.type 
FROM stalwart_accounts a
JOIN stal_emails e ON a.name = e.name
WHERE e.emails = $1

This is the pattern shown in the SQL directory docs.

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.

I’ve modified the queryRecipient as suggest and got an error. the correct query seems to be

SELECT a.name, a.email, a.secret, a.description, a.type 
FROM stalwart_accounts a
JOIN stal_emails e ON a.name = e.name
WHERE e.emails = $1

I also checked that columnEmail is set to email.

The tests with Delivery tests and true inbound mail give same results as before.

Let me look at the source code more carefully to understand what happens after the SQL directory returns a result.

Now I have a very clear picture. The row_to_account function reads the columnEmail column via sanitize_email(). Let me check what sanitize_email does with a plain username vs. an email address.

This is very revealing. I found the actual root cause.

The row_to_account function in crates/directory/src/backend/sql/lookup.rs processes the columnEmail column by calling sanitize_email() on the value. Looking at sanitize_email() in crates/utils/src/lib.rs (lines 209-283), it:

  1. Accepts + as a valid character in the local part of an email address
  2. Returns None (i.e. rejects the value) if any character is invalid

But crucially – sanitize_email is used to populate account.email. When account.email ends up empty (because sanitize_email returned None for some reason, or the column value was empty/null), the result is still treated as a valid Recipient::Account but with an empty email, which then causes the accept/sync to fail.

However, there is a more direct issue to investigate. Your queryRecipient returns a column called email from stalwart_accounts, not the login name (name). The row_to_account function sets account.email from whichever column matches columnEmail. Since you set columnEmail = "email", it will read the email column value and call sanitize_email() on it.

The key question is: what value does your email column in stalwart_accounts contain? If it stores [email protected] (a valid email), sanitize_email should accept it. But if it stores anything else (a username, or a value with unusual characters), it will return None and account.email will remain empty – causing the lookup to silently fail even though a row was returned.

The most useful next diagnostic step is exactly what Stalwart staff suggested: check your SQL server logs during a real inbound delivery attempt and confirm:

  1. What value is passed as $1 to queryRecipient (should be [email protected], not [email protected])
  2. What row(s) are returned – specifically the value of the email column

If $1 is already the stripped address and a row is returned with a valid email value, but delivery still fails, then please share the exact values so a staff member can confirm whether this is a bug in the synchronize_account step or something else. This is now beyond what the docs cover and a human staff member should follow up.

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.

So I checked my logs in databse server. Below, in blockquote, stalwart mail server log and in italic, database server logs.

I precise that inbound email address to [email protected] or other aliases are correctly delivered.

Log entry: 2026-05-19T16:45:51Z

Log Entry

Timestamp

19 mai 2026, 18:45

Level

Info

Event

SMTP RCPT TO command

Details

listenerId = "smtp", localPort = 25, remoteIp = 83.166.143.169, remotePort = 44895, to = "[email protected]"

2026-05-19 16:45:51.321 UTC [358787] LOG: statement:
2026-05-19 16:45:51.322 UTC [358787] LOG: execute s19: SELECT a.name, a.email, a.secret, a.description, a.type FROM stalwart_accounts a JOIN stal_emails e ON a.name = e.name WHERE e.emails = $1
2026-05-19 16:45:51.322 UTC [358787] DETAIL: parameters: $1 = ‘[email protected]
2026-05-19 16:45:51.322 UTC [358787] LOG: statement:
2026-05-19 16:45:51.323 UTC [358787] LOG: execute s20: SELECT member_of FROM stalwart_groupes WHERE name = $1 or email = $1
2026-05-19 16:45:51.323 UTC [358787] DETAIL: parameters: $1 = ‘[email protected]
2026-05-19 16:45:51.323 UTC [358787] LOG: statement:
2026-05-19 16:45:51.323 UTC [358787] LOG: execute s21: SELECT emails FROM stal_emails WHERE email = $1 or name = $1
2026-05-19 16:45:51.323 UTC [358787] DETAIL: parameters: $1 = ‘[email protected]

Hi, for memory, I just post the link to my last discoveries with trace log enable. Seems that issue is general with external directories.

This has been fixed on the main branch. The fix will be included in v0.16.8.