SubAdressing no longer seems to work after upgrade to 0.16

Issue Description

Sub-Adressing should work when enabled for the domain, but since 0.16.x (after upgrade from 0.15) sub-adressing doesn’t seem to work anymore

Expected Behavior

mark+xxx@domain should be delivered to the existing account mark@domain

Actual Behavior

5.5.0, Message: Mailbox not found.

Relevant Log Output

2026-05-26T08:32:55Z INFO Delivery attempt started (delivery.attempt-start) queueId = 309339095878992896, queueName = “remote”, from = “<>”, to = [“SENDER”], size = 6493, total = 1
2026-05-26T08:32:55Z INFO Delivery attempt ended (delivery.attempt-end) queueId = 309339082637574144, queueName = “local”, from = “SENDER”, to = [“mark+netflix@DOMAIN”], size = 44793, total = 1, elapsed = 4ms
2026-05-26T08:32:55Z INFO Delivery completed (delivery.completed) queueId = 309339082637574144, queueName = “local”, from = “SENDER”, to = [“mark+netflix@DOMAIN”], size = 44793, total = 1, elapsed = 0ms
2026-05-26T08:32:55Z INFO Queued DSN for delivery (queue.dsn-queued) queueId = 309339082637574144, queueName = “local”, from = “SENDER”, to = [“mark+netflix@DOMAIN”], size = 44793, total = 1, queueId = 309339095878992896, from = “<>”, to = [“SENDER”], size = 6493, nextRetry = 2026-05-26T08:32:55Z, nextDsn = 2082-10-19T17:05:50Z, expires = 2026-05-29T08:32:55Z
2026-05-26T08:32:55Z INFO DSN permanent failure notification (delivery.dsn-perm-fail) queueId = 309339082637574144, queueName = “local”, from = “SENDER”, to = [“mark+netflix@DOMAIN”], size = 44793, total = 1, to = “mark+netflix@DOMAIN”, hostname = “localhost”, details = “Unexpected response for RCPT TO:mark+netflix@DOMAIN: Code: 550, Enhanced code: 5.5.0, Message: Mailbox not found.”, total = 0

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

LDAP

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

Let me research the subaddressing configuration for Stalwart, particularly any changes between 0.15 and 0.16.

Based on what I found, here is a focused answer.

In the current architecture, subaddressing is configured per Domain object via the subAddressing field. After upgrading from 0.15, this field on your domain may have defaulted to Disabled rather than Enabled, which would explain the “Mailbox not found” rejections even though the feature was previously working.

What to check and fix:

  1. Open the WebUI and navigate to Management › Domains › Domains, then edit your domain.
  2. Find the subAddressing field and make sure it is set to Enabled (not Disabled or missing).
  3. Save the domain object.

The three variants the field accepts are:

  • Enabled – standard + subaddressing, delivers user+tag@domain to user@domain.
  • Disabled – turns the feature off entirely.
  • Custom – uses a custom expression to canonicalize the local part.

Additional consideration for LDAP backends: Your setup uses an LDAP directory. The subaddressing stripping happens at the domain level before the LDAP filterMailbox lookup, so as long as the domain setting is Enabled, the + tag is stripped and only the base local part is passed to LDAP for resolution. If after enabling it the issue persists, double-check that your LDAP filterMailbox filter actually matches on mail=mark@domain (the canonical address without the tag).

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.

I already checked this, it is set to “Enabled”.

Normal e-mail receiving works, and also I can login as mark@domain. So I assume that LDAP is correctly setup.

I added mark+XXX@domain as a temporary test as an alias for mark@domain - that works, but that isn’t a solution.

The plus-strip happens upstream of the directory; LDAP itself never sees the
“+” because Stalwart strips it before the recipient lookup. That strip is
gated on a per-domain flag built from the registry’s SubAddressing field at
domain-cache build time, and the SubAddressing enum picked up @type tagging
in 0.16, so if the cache has a stale view from the migration it can show
Enabled in the UI but evaluate as Disabled internally.

Two things to try: hit the Actions > Reload caches button (or restart) to
make sure the in-memory cache matches the persisted object, then verify by
GETting the Domain object from the API (or stalwart-cli) and confirming the
raw subAddressing.@type is “Enabled”, not just the WebUI label. If the raw
object shows “Enabled” and behaviour still doesn’t change after the reload,
a trace log on a failing delivery will tell us whether local_part is being
passed to LDAP as “mark+netflix” or “mark”, and that pins it down.

Thanks for getting back to me!

I did:

  • Upgrade to 0.16.7 (that is also a server restart I guess :smiley: )
  • Check with CLI:
{"name":"DOMAIN","aliases":{},"isEnabled":true,"createdAt":"2026-05-11T08:02:46Z","description":"DOMAIN","logo":null,"certificateManagement":{"@type":"Manual"},"dkimManagement":{"@type":"Manual"},"dnsManagement":{"@type":"Manual"},"memberTenantId":null,"directoryId":null,"catchAllAddress":"mark@DOMAIN","subAddressing":{"@type":"Enabled"},"allowRelaying":false,"reportAddressUri":"mailto:postmaster","dnsZoneFile":"_443._tcp.autodiscover.DOMAIN. IN TLSA 3 1 1 REDACTED\n_443._tcp.autodiscover.DOMAIN. IN TLSA 2 1 1 REDACTED\n_443._tcp.mta-sts.DOMAIN. IN TLSA 3 1 1 REDACTED\n_443._tcp.mta-sts.DOMAIN. IN TLSA 2 1 1 REDACTED\n_443._tcp.autoconfig.DOMAIN. IN TLSA 3 1 1 REDACTED\n_443._tcp.autoconfig.DOMAIN. IN TLSA 2 1 1 REDACTED\n_443._tcp.ua-auto-config.DOMAIN. IN TLSA 3 1 1 REDACTED\n_443._tcp.ua-auto-config.DOMAIN. IN TLSA 2 1 1 REDACTED\n_25._tcp.mail.DOMAIN. IN TLSA 3 1 1 REDACTED\n_993._tcp.mail.DOMAIN. IN TLSA 3 1 1 REDACTED\n_465._tcp.mail.DOMAIN. IN TLSA 3 1 1 REDACTED\n_443._tcp.mail.DOMAIN. IN TLSA 3 1 1 REDACTED\n_25._tcp.mail.DOMAIN. IN TLSA 2 1 1 REDACTED\n_993._tcp.mail.DOMAIN. IN TLSA 2 1 1 REDACTED\n_465._tcp.mail.DOMAIN. IN TLSA 2 1 1 REDACTED\n_443._tcp.mail.DOMAIN. IN TLSA 2 1 1 REDACTED\nmail.DOMAIN. IN TXT \"v=spf1 a -all\"\nDOMAIN. IN TXT \"v=spf1 mx -all\"\nDOMAIN. IN MX 10 mail.DOMAIN.\n_dmarc.DOMAIN. IN TXT \"v=DMARC1; p=reject; rua=mailto:postmaster@DOMAIN\"\n_imaps._tcp.DOMAIN. IN SRV 0 1 993 mail.DOMAIN.\n_jmap._tcp.DOMAIN. IN SRV 0 1 443 mail.DOMAIN.\n_submissions._tcp.DOMAIN. IN SRV 0 1 465 mail.DOMAIN.\nmta-sts.DOMAIN. IN CNAME mail.DOMAIN.\n_mta-sts.DOMAIN. IN TXT \"v=STSv1; id=18290007539592222054\"\n_smtp._tls.DOMAIN. IN TXT \"v=TLSRPTv1; rua=mailto:postmaster@DOMAIN\"\nua-auto-config.DOMAIN. IN CNAME mail.DOMAIN.\n_ua-auto-config.DOMAIN. IN TXT \"v=UAAC1; a=sha256; d=LpIzGYOcvNR5hvOnn8GNsY7aCQQp+Z9bGaF2DUKKWm8=\"\nautoconfig.DOMAIN. IN CNAME mail.DOMAIN.\nautodiscover.DOMAIN. IN CNAME mail.DOMAIN.\n","id":"c"}


  • Reloaded the caches (does not hurt)
  • Removed the catch-all for testing (cli reported catchAllAddress:null)
  • Tested with a new subaddress (mark+unbekannt@domain from another non-stalwart e-mail)
  • Got a bound back from stalwart: “550 5.1.1 The email account that you tried to reach does not exist. Please try double-checking the recipient’s email address for typos or unnecessary spaces.”

So now I switched the log-level to TRACE and tried again. So much logs :wink:

But I see that the + part is correctly stripped out when querying LDAP. I don’t want to share the 200KB of logs openly, but I can do privately if that helps. Here a few snips:



1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z TRACE Expression evaluation result (eval.result) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1, id = "ReportSettings with id singleton", key = "outboundReportSubmitter", result = "String(Borrowed("mail.DOMAIN"))"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z TRACE Expression evaluation result (eval.result) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1, id = "DsnReportSettings with id singleton", key = "fromAddress", result = "String(Owned("MAILER-DAEMON@DOMAIN"))"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z TRACE Expression evaluation result (eval.result) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1, id = "DsnReportSettings with id singleton", key = "fromName", result = "String(Borrowed("Mail Delivery Subsystem"))"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z INFO DSN permanent failure notification (delivery.dsn-perm-fail) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1, to = "mark+test1234@DOMAIN", hostname = "localhost", details = "Unexpected response for RCPT TO:<mark+test1234@DOMAIN>: Code: 550, Enhanced code: 5.5.0, Message: Mailbox not found.", total = 0
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z DEBUG Cache miss (store.cache-miss) key = "mark+test1234", domain = 2, collection = "email"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z DEBUG Cache hit (store.cache-hit) key = 2, collection = "domainId"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z DEBUG Cache hit (store.cache-hit) key = "DOMAIN", collection = "domainName"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z TRACE Blob read operation (store.blob-read) key = base64:CY1qsM4YMIgkXrWIWx72SgPMQEW+tUTqsVRy+FEASpk=, elapsed = 0ms, size = 4243
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z INFO New delivery attempt for domain (delivery.domain-delivery-start) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1, domain = "DOMAIN"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z TRACE Expression evaluation result (eval.result) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1, id = "MtaOutboundStrategy with id singleton", key = "route", result = "String(Borrowed("local"))"
1780148219593	2026-05-30T13:36:59.593Z	2026-05-30T13:36:59Z INFO Delivery attempt started (delivery.attempt-start) queueId = 310102126130888705, queueName = "local", from = "SENDER", to = ["mark+test1234@DOMAIN"], size = 6111, total = 1
1780148218505	2026-05-30T13:36:58.505Z	2026-05-30T13:36:58Z TRACE Raw SMTP input received (smtp.raw-input) listenerId = "submissions", localPort = 465, remoteIp = 130.185.0.94, remotePort = 9810, size = 18, contents = "EHLO [127.0.0.1]\r\n"
1780148218474	2026-05-30T13:36:58.474Z	2026-05-30T13:36:58Z TRACE Raw SMTP output sent (smtp.raw-output) listenerId = "submissions", localPort = 465, remoteIp = 130.185.0.94, remotePort = 9810, size = 50, contents = "220 mail.DOMAIN Stalwart ESMTP at your service\r\n"
1780148216574	2026-05-30T13:36:56.574Z	2026-05-30T13:36:56Z TRACE Raw SMTP output sent (smtp.raw-output) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, size = 14, contents = "250 2.1.5 OK\r\n"
1780148216574	2026-05-30T13:36:56.574Z	2026-05-30T13:36:56Z INFO SMTP RCPT TO command (smtp.rcpt-to) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, to = "mark+test1234@DOMAIN"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z TRACE Expression evaluation result (eval.result) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, id = "MtaInboundThrottle with id iqks1eqsabqb", key = "match", result = "Integer(1)"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "info", domain = 5, collection = "email"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = 5, collection = "domainId"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = "OTHERDOMAIN", collection = "domainName"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "webmaster", domain = 5, collection = "email"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = 5, collection = "domainId"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = "OTHERDOMAIN", collection = "domainName"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "everything", domain = 5, collection = "email"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = 5, collection = "domainId"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = "OTHERDOMAIN", collection = "domainName"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "mark", domain = 6, collection = "email"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = 6, collection = "domainId"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "mark", domain = 4, collection = "email"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = 4, collection = "domainId"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "mark", domain = 5, collection = "email"
1780148216573	2026-05-30T13:36:56.573Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = 5, collection = "domainId"
1780148216572	2026-05-30T13:36:56.572Z	2026-05-30T13:36:56Z DEBUG Cache miss (store.cache-miss) key = "mark", domain = 2, collection = "email"
1780148216572	2026-05-30T13:36:56.572Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = 2, collection = "domainId"
1780148216572	2026-05-30T13:36:56.572Z	2026-05-30T13:36:56Z DEBUG Cache hit (store.cache-hit) key = "DOMAIN", collection = "domainName"
1780148213248	2026-05-30T13:36:53.248Z	2026-05-30T13:36:53Z TRACE LDAP query executed (store.ldap-query) details = "(&(objectClass=user)(|(mail=mark@DOMAIN)(mailAlias=mark@DOMAIN)(mailList=mark@DOMAIN))(memberOf=cn=Mail User,ou=groups,dc=ldap,dc=goauthentik,dc=io))", result = [["mailAlias", "mark@mOTHERDOMAIN, mark@OTHERDOMAIN, REDACTED"], ["memberOf", "..., cn=Mail User,ou=groups,dc=ldap,dc=goauthentik,dc=io, ..."], ["objectClass", "top, person, organizationalPerson, inetOrgPerson, user, posixAccount, goauthentik.io/ldap/user"], ["mail", "mark@DOMAIN"], "cn=mark,ou=users,dc=ldap,dc=goauthentik,dc=io"]
1780148211098	2026-05-30T13:36:51.098Z	2026-05-30T13:36:51Z DEBUG Cache miss (store.cache-miss) key = 2, collection = "domainId"
1780148211098	2026-05-30T13:36:51.098Z	2026-05-30T13:36:51Z TRACE Expression evaluation result (eval.result) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, id = "MtaStageRcpt with id singleton", key = "rewrite", result = ""
1780148211098	2026-05-30T13:36:51.098Z	2026-05-30T13:36:51Z TRACE Expression evaluation result (eval.result) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, id = "MtaStageRcpt with id singleton", key = "rewrite", result = "Integer(0)"
1780148211097	2026-05-30T13:36:51.097Z	2026-05-30T13:36:51Z TRACE Expression evaluation result (eval.result) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, id = "MtaStageRcpt with id singleton", key = "script", result = ""
1780148211097	2026-05-30T13:36:51.097Z	2026-05-30T13:36:51Z TRACE Expression evaluation result (eval.result) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, id = "MtaStageRcpt with id singleton", key = "script", result = "Integer(0)"
1780148211097	2026-05-30T13:36:51.097Z	2026-05-30T13:36:51Z TRACE Raw SMTP input received (smtp.raw-input) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, size = 34, contents = "RCPT TO:<mark+test1234@DOMAIN>\r\n"
1780148211097	2026-05-30T13:36:51.097Z	2026-05-30T13:36:51Z TRACE Raw SMTP output sent (smtp.raw-output) listenerId = "smtp", localPort = 25, remoteIp = 2607:f8b0:4864:20::1129, remotePort = 56812, size = 14, contents = "250 2.1.0 OK\r\n"

Let me know if you need more. It doesn’t pin it down for me, but I am no expert.

I have the same issue. Normal delivery works like a charm, but using subaddressing breaks. I’m also using an ldap directoy.

For now I’ve restored the backup, since I use subaddressing a lot.

Hello, I got the same problem with SQL directory (Subaddress and sql directory - #17 by palight).

Maybe a bug with subadressing and external directories?

@stalwart Would trace logs from @Kidswiss and @palight help?

@Kidswiss / @palight Could you maybe provide TRACE logs for a mail receive event where sub-addressing is not correctly followed?

(Maybe difficult for @Kidswiss if you have only one env and already downgraded)

Hi,

I tried tonight with trace logs.

Problem seems to be that with external directories, server first cache aliases from external directory and then when an email inbound, it look in cache entries if full recipient exists, no more obfuscating subaddress. So it can’t find user+test in cache and don’t ask to external directory.

2026-06-03T21:04:46Z INFO DSN permanent failure notification (delivery.dsn-perm-fail) queueId = 310883167003258880, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 4222, total = 1, to = “[email protected]”, hostname = “localhost”, details = “Unexpected response for RCPT TO:[email protected]: Code: 550, Enhanced code: 5.5.0, Message: Mailbox not found.”, total = 0

2026-06-03T21:04:46Z DEBUG Cache miss (store.cache-miss) key = “user+test”, domain = 1, collection = “email”

2026-06-03T21:04:46Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”

2026-06-03T21:04:46Z DEBUG Cache hit (store.cache-hit) key = “mydomain.com”, collection = “domainName”

2026-06-03T21:04:46Z TRACE Blob read operation (store.blob-read) key = base64:3MiViBee0jKDvrql2Oo6ZpQzx62xEzDS2RbpgkoCaDQ=, elapsed = 0ms, size = 2651

2026-06-03T21:04:46Z INFO New delivery attempt for domain (delivery.domain-delivery-start) queueId = 310883167003258880, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 4222, total = 1, domain = “mydomain.com

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

I can confirm that it works for my case now! Thank you very much!