Issue Description
When using Bulwark to compose and send an email, there is a UI button that allows the user to override the sender address. Despite the MTA being configured with mustMatchSender = true, the server accepts forged From: headers in the JMAP Email/set request and delivers the message without validation.
Expected Behavior
When mustMatchSender = true, the server must reject any Email/set/EmailSubmission/set request where:
The from.email in the envelope does not match the authenticated user's identity or verified sender addresses
This check should occur before message ingestion, regardless of JMAP layer
Actual Behavior
Bulwark (connected from OIDC) allows UI override → client sends from address [email protected]
JMAP handler accepts the Email/set request and passes it to EmailSubmission/set
SMTP MAIL FROM uses the authenticated user’s address (e.g., [email protected])
Message is queued and delivered, but From: header is forged
mustMatchSender = true has no effect — the MTA stage checks the envelope sender only, not the From: header in the JMAP message content
Reproduction Steps
Log in via Bulwark as authenticated user (account ID X, email [email protected])
Compose a new email
Click the "override sender" button in the UI and set From: to an unverified address (e.g., [email protected])
Send the message
Message is accepted and delivered — no rejection despite mustMatchSender = true
Relevant Log Output
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, id = “Http with id singleton”, key = “allowedEndpoints”, result = “Integer(200)”
2026-07-01T07:32:53Z TRACE HTTP request body (http.request-body) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, details = [[“host”, “stalwart.localdomain”], [“user-agent”, “Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0”], [“content-length”, “928”], [“accept”, “/”], [“accept-encoding”, “gzip, deflate, br, zstd”], [“accept-language”, “en-US,en;q=0.9”], [“authorization”, “Bearer mybearer”], [“content-type”, “application/json”], [“origin”, “http”], [“priority”, “u=4”], [“referer”, “https”], [“sec-fetch-dest”, “empty”], [“sec-fetch-mode”, “cors”], [“sec-fetch-site”, “cross-site”], [“te”, “trailers”], [“via”, “2.0 Caddy”], [“x-forwarded-for”, “10.10.10.10”], [“x-forwarded-host”, “stalwart.localdomain”], [“x-forwarded-proto”, “https”]], contents = “{“using”:[“urn:ietf:params:jmap:core”,“urn:ietf:params:jmap:mail”,“urn:ietf:params:jmap:submission”],“methodCalls”:[[“Email/set”,{“accountId”:“f”,“destroy”:[“vyaaaafo”]},“0”],[“Email/set”,{“accountId”:“f”,“create”:{“send-1782891173173”:{“from”:[{“name”:“donotexist”,“email”:“[email protected]”}],“to”:[{“name”:“Louis B”,“email”:“[email protected]”}],“subject”:“wdcs”,“keywords”:{”$seen":true,“$draft”:true},“mailboxIds”:{“d”:true},“bodyValues”:{“text”:{“value”:“efefef”},“html”:{“value”:“
efefef
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z DEBUG JMAP method call (jmap.method-call) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, id = “Email/set”, accountId = 5, elapsed = 1ms
2026-07-01T07:32:53Z INFO Message appended via JMAP (message-ingest.jmap-append) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, accountId = 5, documentId = 175, mailboxId = [3], blobId = “1ce39a3d7ef32229964a5471487c4e90f5b07a277137d6d9792c01e7539c6fc4”, changeId = 395, messageId = “18be1a136c6db6ae.f4934807f0a8449e.870229071add5395@12b8726fd2c2”, size = 686, elapsed = 1ms
2026-07-01T07:32:53Z DEBUG JMAP method call (jmap.method-call) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, id = “Email/set”, accountId = 5, elapsed = 2ms
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageMail with id singleton”, key = “isSenderAllowed”, result = “Integer(1)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageMail with id singleton”, key = “script”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageMail with id singleton”, key = “script”, result = “”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageMail with id singleton”, key = “rewrite”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageMail with id singleton”, key = “rewrite”, result = “”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageAuth with id singleton”, key = “mustMatchSender”, result = “Integer(1)”
2026-07-01T07:32:53Z INFO SMTP MAIL FROM command (smtp.mail-from) from = “[email protected]”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “maxFailures”, result = “Integer(5)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “waitOnFail”, result = “Integer(5000)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “maxRecipients”, result = “Integer(100)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaExtensions with id singleton”, key = “dsn”, result = “Integer(1)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “maxMessageSize”, result = “Integer(104857600)”
2026-07-01T07:32:53Z TRACE Raw SMTP output sent (smtp.raw-output) size = 14, contents = “250 2.1.0 OK\r\n”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “script”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “script”, result = “”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “rewrite”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageRcpt with id singleton”, key = “rewrite”, result = “”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “mail.localdomain”, collection = “domainName”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “myuser”, domain = 1, collection = “email”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 5, collection = “account”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaInboundThrottle with id ivqqsqpoabqb”, key = “match”, result = “Integer(1)”
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 1
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 1
2026-07-01T07:32:53Z INFO SMTP RCPT TO command (smtp.rcpt-to) to = “[email protected]”
2026-07-01T07:32:53Z TRACE Raw SMTP output sent (smtp.raw-output) size = 14, contents = “250 2.1.5 OK\r\n”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “maxReceivedHeaders”, result = “Integer(50)”
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 1
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “SenderAuth with id singleton”, key = “dkimVerify”, result = “Constant(Relaxed)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “SenderAuth with id singleton”, key = “dmarcVerify”, result = “Constant(Disable)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “DkimReportSettings with id singleton”, key = “sendFrequency”, result = “Array([Integer(1), Integer(86400000)])”
2026-07-01T07:32:53Z INFO DKIM verification failed (smtp.dkim-fail) strict = false, result = , elapsed = 0ms
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “SenderAuth with id singleton”, key = “arcVerify”, result = “Constant(Disable)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “addReceivedHeader”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “addAuthResultsHeader”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “enableSpamFilter”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “script”, result = “Integer(0)”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “script”, result = “”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “mail.localdomain”, collection = “domainName”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaOutboundStrategy with id singleton”, key = “schedule”, result = “String(Borrowed(“local”))”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “MtaStageData with id singleton”, key = “addReturnPathHeader”, result = “Integer(0)”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “mail.localdomain”, collection = “domainName”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) id = “SenderAuth with id singleton”, key = “dkimSignDomain”, result = “String(Borrowed(“mail.localdomain”))”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “mail.localdomain”, collection = “domainName”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “dkimSigners”
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 1ms, total = 1
2026-07-01T07:32:53Z TRACE Blob write operation (store.blob-write) key = base64:ZLwG+hlG+fewfwefwfwef=, elapsed = 0ms, size = 1082
2026-07-01T07:32:53Z INFO Queued message submission for delivery (queue.authenticated-message-queued) queueId = 315854523132316674, from = “[email protected]”, to = [“[email protected]”], size = 1545, nextRetry = 2026-07-01T07:32:53Z, nextDsn = 2026-07-02T07:32:53Z, expires = 2026-07-04T07:32:53Z
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 5
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 3
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 6
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
2026-07-01T07:32:53Z DEBUG JMAP method call (jmap.method-call) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, id = “EmailSubmission/set”, accountId = 5, elapsed = 6ms
2026-07-01T07:32:53Z INFO Delivery attempt started (delivery.attempt-start) queueId = 315854523132316674, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 1545, total = 1
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “mail.localdomain”, collection = “domainName”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) queueId = 315854523132316674, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 1545, total = 1, id = “MtaOutboundStrategy with id singleton”, key = “route”, result = “String(Borrowed(“local”))”
2026-07-01T07:32:53Z INFO New delivery attempt for domain (delivery.domain-delivery-start) queueId = 315854523132316674, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 1545, total = 1, domain = “mail.localdomain”
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z DEBUG Cache update (store.cache-update) accountId = 5, collection = “email”, changeId = 395, details = [1, 0], total = [107, 5], elapsed = 0ms
2026-07-01T07:32:53Z TRACE Blob read operation (store.blob-read) key = base64:ZLwG+hlG+fewfwefwfwef=, elapsed = 0ms, size = 1082
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “mail.localdomain”, collection = “domainName”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = “myuser”, domain = 1, collection = “email”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 5, collection = “accessToken”
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 4
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 5, collection = “account”
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 7
2026-07-01T07:32:53Z DEBUG JMAP method call (jmap.method-call) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, id = “Email/set”, accountId = 5, elapsed = 0ms
2026-07-01T07:32:53Z TRACE HTTP response body (http.response-body) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, contents = “{“methodResponses”:[[“Email/set”,{“accountId”:“f”,“oldState”:“srebq”,“newState”:“sribq”,“destroyed”:[“vyaaaafo”]},“0”],[“Email/set”,{“accountId”:“f”,“oldState”:“sribq”,“newState”:“srmbq”,“created”:{“send-1782891173173”:{“id”:“v2aaaafp”,“threadId”:“fp”,“blobId”:“caoohgr0p9zsekmwjjkhcsd2j7iplmd7e0ytpvwzpewadz7ttrx2ibiav2aq”,“size”:686}}},“1”],[“EmailSubmission/set”,{“accountId”:“f”,“newState”:“srqbq”,“created”:{“1”:{“id”:“bh”,“sendAt”:“2026-07-01T07:32:53Z”,“undoStatus”:“final”}}},“2”],[“Email/set”,{“accountId”:“f”,“oldState”:“srmbq”,“newState”:“srubq”,“updated”:{“v2aaaafp”:null}},“2”]],“sessionState”:“731974b5”}”, code = 200, size = 618
2026-07-01T07:32:53Z TRACE Data store iteration operation (store.data-iterate) elapsed = 0ms
2026-07-01T07:32:53Z DEBUG HTTP request URL (http.request-url) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, remoteIp = 10.10.10.10, url = “/jmap/”
2026-07-01T07:32:53Z TRACE Expression evaluation result (eval.result) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, id = “Http with id singleton”, key = “allowedEndpoints”, result = “Integer(200)”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 5, collection = “accessToken”
2026-07-01T07:32:53Z DEBUG Cache update (store.cache-update) accountId = 5, collection = “email”, changeId = 397, details = [1, 0], total = [107, 5], elapsed = 0ms
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 4, collection = “account”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z DEBUG Cache hit (store.cache-hit) key = 1, collection = “domainId”
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 6
2026-07-01T07:32:53Z TRACE HTTP request body (http.request-body) listenerId = “http”, localPort = 8080, remoteIp = 10.10.10.10, remotePort = 49806, details = [[“host”, “stalwart.localdomain”], [“user-agent”, “Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0”], [“content-length”, “481”], [“accept”, “/”], [“accept-encoding”, “gzip, deflate, br, zstd”], [“accept-language”, “en-US,en;q=0.9”], [“authorization”, “Bearer mybearer”], [“content-type”, “application/json”], [“origin”, “https”], [“priority”, “u=4”], [“referer”, “https”], [“sec-fetch-dest”, “empty”], [“sec-fetch-mode”, “cors”], [“sec-fetch-site”, “cross-site”], [“te”, “trailers”], [“via”, “2.0 Caddy”], [“x-forwarded-for”, “10.10.10.10”], [“x-forwarded-host”, “stalwart.localdomain”], [“x-forwarded-proto”, “https”]], contents = “{“using”:[“urn:ietf:params:jmap:core”,“urn:ietf:params:jmap:mail”],“methodCalls”:[[“Email/query”,{“accountId”:“f”,“filter”:{“inMailbox”:“a”},“sort”:[{“property”:“receivedAt”,“isAscending”:false}],“limit”:50,“position”:0,“calculateTotal”:true},“0”],[“Email/get”,{“accountId”:“f”,”#ids”:{“resultOf”:“0”,“name”:“Email/query”,“path”:“/ids”},“properties”:[“id”,“threadId”,“mailboxIds”,“keywords”,“size”,“receivedAt”,“from”,“to”,“cc”,“subject”,“preview”,“hasAttachment”,“blobId”]},“1”]]}", size = 481
2026-07-01T07:32:53Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 12
2026-07-01T07:32:53Z INFO Message ingested (message-ingest.ham) queueId = 315854523132316674, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 1545, total = 1, accountId = 5, documentId = 176, mailboxId = [0], blobId = “64bc06fa1946faf159ba4845e50569bf49b7b2491ac714b19670c49fb7e939bb”, changeId = 398, messageId = “18be1a136c6db6ae.f4934807f0a8449e.870229071add5395@12b8726fd2c2”, size = 1545, elapsed = 1ms
2026-07-01T07:32:53Z INFO DSN success notification (delivery.dsn-success) queueId = 315854523132316674, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 1545, total = 1, to = “[email protected]”, hostname = “localhost”, code = 250, details = “OK”
2026-07-01T07:32:53Z INFO Delivery completed (delivery.completed) queueId = 315854523132316674, queueName = “local”, from = “[email protected]”, to = [“[email protected]”], size = 1545, total = 1, elapsed = 0ms
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
OIDC
Additional Context
Environment
Stalwart version: docker compose v0.16.11
Deployment type: Self-hosted, behind reverse proxy, TLS enabled
Auth method: JWT (OAuth2-style)
Email client: Bulwark (web UI) with JMAP
MTA config: mustMatchSender = true (enabled in MtaStageAuth)
Defaults spam rules activated
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