Issue Description
Stalwart does not normalise CRLF in outgoing messages, nor does it have controls on the ingestion paths.
Expected Behavior
Stalwart, as an MTA intended to be stand alone and on the perimeter and talk directly MX and relay hosts, should ensure that messages leaving it are RFC conformant.
Code comments highlight that the system assumes conformance. However, with the intent of replacing, for example, a Postfix+Dovecot combination, Stalwart should be able to handle non-conformant data.
Actual Behavior
Non-conformant content is passed to the next mail hop, which rejects with 521/550/552/554 errors depending on MX or relay host type.
In addition, Stalwart has hard-coded prevention of connecting to a relay on localhost (although it is possible to set it in configuration), which makes it difficult to put Postfix or similar in line on the outbound path to ensure conformance.
Reproduction Steps
My particular setup includes mlmmj for mailing lists, which is not emitting fully conformant data.
Relevant Log Output
521/550/552/554 errors depending on MX or relay host type, e.g.,
552 5.6.0 message data must use CRLF for line endings
Stalwart Version
v0.16.x
Installation Method
Binary (Linux)
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
Internal
Additional Context
Outbound DATA — crates/smtp/src/outbound/client.rs::write_message:
The blob is streamed verbatim; the only mutation is inserting a transparency dot, terminated with \r\n.\r\n. The code comment explicitly states it assumes RFC 5322bis §2.3 compliance (CR/LF only as CRLF) rather than enforcing it. Note the stuffing trigger (is_cr_or_lf) fires on a bare CR or bare LF preceding a dot — deliberate over-stuffing — but no line-ending rewriting occurs.
Outbound BDAT/CHUNKING — client.rs::write_chunks:
Pure write_all of each chunk. No dot-stuffing (correct for BDAT), no normalization.
Blob provenance — crates/smtp/src/queue/spool.rs::queue:
raw_headers ++ raw_message concatenated and put_blob’d verbatim. send_message fetches exactly those bytes. No transform between persist and wire.
Inbound ingestion — smtp-proto 0.2.1 DataReceiver::ingest: Performs un-dot-stuffing and . terminator detection only. Bare \r and bare \n are pushed to the buffer unchanged. BdatReceiver::ingest is a straight byte copy. A message arriving with bare LF is persisted with bare LF.
JMAP submission — crates/jmap/src/submission/set.rs::send_message:
Fetches the Email blob, optionally splices a byte range for envelope params, assigns to session.data.message, and calls queue_message() — same non-normalizing path. A raw RFC822 blob imported via JMAP with bare LF is relayed with bare LF.
Net result: outbound emits whatever line endings exist in the stored blob. A non-conformant message (bare LF) ingested via inbound SMTP DATA/BDAT or JMAP raw import is relayed to the MX/relay host with bare LF intact — the sole outbound modification being dot-stuffing.
One downstream consequence worth flagging: since DKIM signing canonicalizes only for hash computation and leaves the body bytes untouched, a bare-LF body that is signed and then relayed bare-LF can fail verification at receivers that canonicalize the as-received octets differently.
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