Issue Description
When setting an Identity (or any address that goes through sanitize_email),
an email whose domain is already in A-label (Punycode) form, such as
user@例子.com, is rejected as an “Invalid e-mail address”, while the
same domain in Unicode form (user@例子.com) is accepted. This breaks
internationalized domain names (IDNs) that are supplied in their canonical
on-the-wire form, which is common for users in China, Japan, Korea, the
Arabic-speaking world, India, Greece and many other regions.
Expected Behavior
sanitize_email should accept email addresses whose domain is already encoded
as an A-label (Punycode). For example:
sanitize_email("[email protected]") == Some("[email protected]")
The result should also be identical to passing the same domain in Unicode form
(user@例子.com), i.e. the function must be idempotent for A-label input.
Actual Behavior
sanitize_email returns None for any domain containing the “xn–” prefix, so the
address is rejected. In the Identity flow this surfaces as:
SetError::invalid_properties()
.with_property(IdentityProperty::Email)
.with_description("Invalid e-mail address.")
- [email protected] → accepted
- user@例子.com → accepted (Unicode is converted internally)
- user@例子.com → REJECTED (any pre-encoded A-label domain fails)
Root cause: in the domain-parsing loop of sanitize_email, ‘.’, ‘-’ and ‘_’
share one branch that requires the previous character to be alphanumeric:
'.' | '-' | '_' => {
if !last_ch.is_alphanumeric() {
return None;
}
result.push(ch);
}
Every Punycode label carries the “xn–” prefix. When the parser reaches the
SECOND ‘-’, last_ch is already ‘-’, the check fails, and the whole address is
discarded. Unicode input is unaffected because it is normalised to A-label form
via idna::domain_to_ascii AFTER the loop, so existing tests (which use Unicode
input) do not catch this.
Proposed fix: give ‘.’ its own branch and only forbid ‘-’/‘_’ at the start of a
label (start of the domain or right after a ‘.’), allowing consecutive hyphens
as required by “xn–”. Final validity is still enforced by is_valid_domain.
'.' => {
if !last_ch.is_alphanumeric() {
return None;
}
result.push('.');
}
'-' | '_' => {
if last_ch == NIL_CHAR || last_ch == '.' {
return None;
}
result.push(ch);
}
I’m happy to provide the full patched function and a regression test, or to
open a PR if a maintainer can grant access.
Reproduction Steps
- Create or update an Identity and set its email to a domain in A-label form,
e.g. user@例子.com (this is the Punycode of user@例子.com). - The request fails with SetError invalid_properties on the Email property:
“Invalid e-mail address.” - Repeat with the Unicode form user@例子.com — it is accepted, proving the
address is valid and the A-label form should be too.
Minimal unit-test reproduction:
assert_eq!(
sanitize_email("[email protected]").as_deref(),
Some("[email protected]")
); // currently returns None
Relevant Log Output
(N/A — this is a validation logic bug in sanitize_email, reproducible directly
as a unit test. No server logs required.)
Stalwart Version
v0.16.x
Installation Method
Docker
Database Backend
RocksDB
Blob Storage
RocksDB
Search Engine
Internal
Directory Backend
OIDC
Additional Context
This is not a configuration issue — it is a logic bug in the sanitize_email
function (utils crate). It is independent of the database, blob store, search
engine, directory backend and installation method; the dropdown values above
were selected only because the form requires them.
The fix is small and self-contained. Suggested regression test:
#[test]
fn a_label_email_domains_are_accepted_and_idempotent() {
assert_eq!(
sanitize_email("[email protected]").as_deref(),
Some("[email protected]")
);
assert_eq!(
sanitize_email("User@例子.com").as_deref(),
sanitize_email("[email protected]").as_deref()
);
}
Full fixed function:
// Basic email sanitizer
pub fn sanitize_email(email: &str) → Option {
let mut result = String::with_capacity(email.len());
let mut last_ch = NIL_CHAR;
let mut chars = email.chars();
for ch in chars.by_ref() {
match ch {
'.' => {
if last_ch == NIL_CHAR || last_ch == '.' {
return None;
}
result.push('.');
}
'!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_'
| '`' | '{' | '|' | '}' | '~' => {
result.push(ch);
}
' ' | '\x09'..='\x0d' => continue,
'@' => {
if result.is_empty() || last_ch == '.' {
return None;
}
last_ch = ch;
result.push(ch);
break;
}
_ => {
if ch.is_uppercase() {
for ch in ch.to_lowercase() {
result.push(ch);
}
} else if ch.is_alphanumeric() {
result.push(ch);
} else {
return None;
}
}
}
last_ch = ch;
}
if last_ch != '@' {
return None;
}
last_ch = NIL_CHAR;
let domain_start = result.len();
let mut domain_is_ascii = true;
for ch in chars {
match ch {
'.' => {
if !last_ch.is_alphanumeric() {
return None;
}
result.push('.');
}
'-' | '_' => {
if last_ch == NIL_CHAR || last_ch == '.' {
return None;
}
result.push(ch);
}
' ' | '\x09'..='\x0d' => continue,
_ => {
if !ch.is_ascii() {
domain_is_ascii = false;
}
if ch.is_uppercase() {
for ch in ch.to_lowercase() {
result.push(ch);
}
} else if ch.is_alphanumeric() {
result.push(ch);
} else {
return None;
}
}
}
last_ch = ch;
}
if !last_ch.is_alphanumeric() {
return None;
}
if domain_is_ascii {
is_valid_domain(&result[domain_start..]).then_some(result)
} else {
let domain = idna::domain_to_ascii(&result[domain_start..]).ok()?;
if !is_valid_domain(&domain) {
return None;
}
result.truncate(domain_start);
result.push_str(&domain);
Some(result)
}
}
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