No CRLF Normalisation

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

This is a well-researched bug report. Let me check the relevant source files to confirm the behaviour described before responding.

The reporter has done detailed, accurate source-level analysis. The issue is real and well-documented. This is a bug/feature-request that the community or staff will need to address – there is nothing in the current docs or source (as accessible to me) describing any existing CRLF normalization on outbound paths.


Thank you for this thorough and well-researched report. The analysis is accurate: Stalwart currently streams message blobs verbatim on outbound SMTP (DATA and BDAT), and there is no normalization of bare LF to CRLF at any ingestion point (inbound SMTP, JMAP submission, or blob storage). As you note, the code itself documents the assumption of pre-existing conformance rather than enforcing it.

This is a genuine gap for anyone using Stalwart as a drop-in replacement for a Postfix+Dovecot stack, where Postfix has historically performed this normalization silently. The DKIM interaction you flag (signing a bare-LF body that then fails verification at receivers with stricter canonicalization) is a particularly sharp edge.

A few points worth noting for anyone else hitting this:

  • Workaround via relay: You can route outbound through a local Postfix instance (which does normalize line endings) by configuring a relay host. The “hard-coded prevention of connecting to a relay on localhost” you mention can be overridden via configuration, so that path is viable as a short-term fix.
  • mlmmj and other LDA/MTA integrations: Any tool submitting via LMTP or SMTP that emits bare LF will trigger this. The fix needs to be either in the submitting tool or at ingestion time in Stalwart.
  • Correct fix location: Normalization belongs at ingestion (inbound SMTP DataReceiver::ingest and JMAP raw import), not at send time, so the stored blob is always conformant. The outbound path then requires no changes.

This is a legitimate bug/enhancement request. A human staff member will follow up on whether this is planned or accepted.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

To clarify: I didn’t find a way to allow connecting to localhost or 127.0.0.1 for outbound relay, it is in code, not config, as it appears.

Normalising CRLF has the risk of breaking DKIM (depending on the canonicalization used). The correct fix is that Stalwart rejects LFs but that would reject messages from mlmmj.

Separating different concerns:

Responsibility as MTA:

Stalwart should never try to send invalid content to the next hop. Existing systems reject or silently correct misformed messages, that would seem to be the expected behaviour of a well-behaved MTA.

DKIM risk:

In a normal flow, DKIM would be signed by Stalwart after ingestion, so there is no DKIM to break.

If we are considering Stalwart as an intermediate hop, then the behaviour would still match that of Postfix, which does fix CRLF.

Having said that, perhaps the target use case for Stalwart is less of a routing MTA and more of a Swiss Army Knife leaf mail server. As a consequence, correcting any local sender/submission, which Stalwart would then sign is not a real risk.