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
- 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 ind=. - Address it to a recipient at an IDN domain, so the envelope carries the A-label and the
To:header the U-label. - Inspect
Authentication-ResultsandX-Spam-Resulton 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