Bug: Legitimate internationalized (EAI) mail scored as spam; DMARC alignment does not convert U-labels to A-labels

Issue Description

A fully authenticated message from an IDN domain — dkim=pass, spf=pass, iprev=pass, TLS 1.3 — was classified as spam with a score of 10.60. Three separate issues, all from comparing an A-label against a U-label without normalising first, contributed 7.50 of that score:

RCVD_ILLEGAL_CHARS        4.00   fires on any UTF-8 in a Received header
FORGED_RECIPIENTS         2.00   envelope recipient (A-label) vs To: header (U-label)
DMARC_POLICY_QUARANTINE   1.50   DMARC alignment compares A-label d= to U-label From:

Without them the message scores 3.10.

1. DMARC alignment does not convert U-labels to A-labels

The message is signed d=xn--eebajf.xn--9dbq2a (A-label, as required) and its From: domain is the U-label מייל.קום. These are the same domain, but alignment compares them as raw strings, so DMARC fails and the quarantine policy is applied.

In mail-auth, src/dmarc/verify.rs, the From domain is taken verbatim with no IDNA conversion:

let mut rfc5322_from_domain = "";
for from in &message.from {
    if let Some((_, domain)) = from.rsplit_once('@') {
        if rfc5322_from_domain.is_empty() {
            rfc5322_from_domain = domain;

and is then compared directly, for both SPF and DKIM alignment:

let aligned = rfc5321_mail_from_domain == rfc5322_from_domain || …
…
if d == rfc5322_from_domain || …

Since the two strings can never be equal when the From: domain is a U-label, alignment cannot succeed for internationalised mail.

RFC 9989 section 4.10.1, which the surrounding code comments cite, says: “If the domain is a U-label, the domain MUST be converted to an A-label, as described in Section 2.3 of RFC 5890, for further processing.” RFC 8616 section 6 says the same in updating RFC 7489 section 6.6.1.

Converting rfc5322_from_domain to an A-label immediately after extraction, before the tree walk and before any alignment comparison, looks like the fix.

2. RCVD_ILLEGAL_CHARS fires on legitimate EAI Received headers

crates/spam-filter/src/analysis/received.rs:31:

if !ctx.input.message.raw_message()
    .get(header.offset_start as usize..header.offset_end as usize)
    .unwrap_or_default()
    .is_ascii()
{
    ctx.result.add_tag("RCVD_ILLEGAL_CHARS");
}

Any non-ASCII byte anywhere in a Received header adds 4.00. For SMTPUTF8 mail this is normal and legal — RFC 6532 extends header fields to allow UTF-8, and a relaying MTA records the envelope recipient in the for clause. The message was received over UTF8SMTPSA and the previous hop’s header legitimately reads:

Received: from mail-pl1-f179.google.com (…)
	by mail.xn--eebajf.xn--9dbq2a (Postfix) with UTF8SMTPSA id F22983FDA5
	for <יוסי@לשם-שמים.ישראל>; Tue, 11 Aug 2026 19:54:27 +0000 (UTC)

The intent looks like homograph detection — the fixture in tests/resources/smtp/antispam/received.test uses bay0-hmr08.bay0.hótmail.com, which is a sensible thing to flag. That intent is defeated rather than served by matching every UTF-8 mailbox address in an EAI message.

Restricting the check to the hostname/domain tokens of the parsed header, or skipping it for SMTPUTF8 messages, would keep the homograph case working.

3. FORGED_RECIPIENTS fires when envelope and header use different label forms

crates/spam-filter/src/analysis/recipient.rs:205:

for env_rcpt in &ctx.output.env_to_orig_addr {
    if !unique_recipients.iter().any(|rcpt| rcpt.email == *env_rcpt)
        && env_rcpt != &ctx.output.env_from_addr
    {
        ctx.result.add_tag("FORGED_RECIPIENTS");
        break;
    }
}

Exact string equality. The envelope recipient arrived in A-label form while the To: header carries the U-label form of the same address, so no match is found and the message is tagged as having forged recipients. The same comparison feeds TO_MATCH_ENVRCPT_ALL / _SOME at line 164.

Normalising the domain part of both sides where the addresses are collected (analysis/init.rs:280) would give every consumer a consistent form.

Expected Behavior

DMARC aligns for an internationalised domain whose From: is a U-label and whose DKIM d= is the corresponding A-label, and neither spam rule fires on a legitimate SMTPUTF8 message.

Actual Behavior

Observed headers on the received message:

Authentication-Results: …
	dkim=pass header.d=xn--eebajf.xn--9dbq2a header.s=mail header.b=UuJn1PtB;
	spf=pass … smtp.mailfrom=יוסי@מייל.קום;
	iprev=pass policy.iprev=176.58.113.229;
	dmarc=fail (policy not aligned) header.from=מייל.קום policy.dmarc=quarantine

X-Spam-Result: DKIM_ALLOW (-0.20), SPF_ALLOW (-0.20), ARC_NA (0.00),
	DKIM2_NA (0.00), DKIM_SIGNED (0.00), FROMTLD_EQ_ENV_FROMTLD (0.00),
	FROM_HAS_DN (0.00), HTML_SHORT_1 (0.00), RCPT_COUNT_ONE (0.00),
	RCVD_COUNT_TWO (0.00), RCVD_IN_DNSWL_NONE (0.00), RCVD_TLS_LAST (0.00),
	SOURCE_ASN_63949 (0.00), TO_DN_NONE (0.00), DMARC_POLICY_QUARANTINE (1.50),
	FORGED_RECIPIENTS (2.00), PYZOR (3.50), RCVD_ILLEGAL_CHARS (4.00)
X-Spam-Score: spam, score=10.60

Incidentally, Stalwart’s own Received header on this message contains a U-label:

Received: from mail.xn--eebajf.xn--9dbq2a (mail.מייל.קום [176.58.113.229] …)

so a message relayed onward through Stalwart would trip its own RCVD_ILLEGAL_CHARS at the next hop.

Reproduction Steps

  1. Send an authenticated message from a domain with an IDN name, with the From: header in U-label form and a valid DKIM signature using the A-label in d=.
  2. Address it to a recipient at an IDN domain, so the envelope carries the A-label and the To: header the U-label.
  3. Inspect Authentication-Results and X-Spam-Result on the receiving Stalwart.

Result: dmarc=fail (policy not aligned) despite dkim=pass, plus RCVD_ILLEGAL_CHARS and FORGED_RECIPIENTS, totalling 7.50 toward the spam score.

Happy to split this into three separate topics if you would prefer — I kept them together because they were all observed in a single message and share the same root cause.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

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 detailed, well-researched bug report with source references rather than a question the documentation can answer, so I can’t confirm from docs whether/when this will be addressed.

This isn’t covered in the current docs. A community member or staff member will 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.