sanitize_email rejects valid emails with pre-encoded A-label (Punycode) domains

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.")

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

  1. 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).
  2. The request fails with SetError invalid_properties on the Email property:
    “Invalid e-mail address.”
  3. 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

Good bug report with a clear root cause and a well-reasoned fix. Let me verify the current source to confirm the analysis.

Your analysis is exactly right, and the source code confirms the bug.

The current sanitize_email domain-parsing loop in crates/utils/src/lib.rs groups ., -, and _ into a single branch:

'.' | '-' | '_' => {
    if !last_ch.is_alphanumeric() {
        return None;
    }
    result.push(ch);
}

This means when the parser encounters the second - in an xn-- Punycode prefix, last_ch is -, which is not alphanumeric, so the function returns None. Meanwhile, Unicode-form domains (e.g. user@例子.com) escape this path because they are flagged as non-ASCII and are only converted to A-label via idna::domain_to_ascii after the loop, so existing tests with Unicode input never exercise this code path for Punycode labels.

Your proposed fix is logically sound: split . into its own branch (keeping the no-leading/trailing-dot guard), and change the -/_ branch to only reject a hyphen/underscore that appears at the start of a label (i.e. when last_ch == NIL_CHAR || last_ch == '.'), allowing consecutive hyphens as required by RFC 5891 Punycode A-labels. Final domain validity is still enforced by is_valid_domain.

This looks like a genuine bug worth raising as a code fix. Since the forum guidelines here ask for human follow-up on confirmed issues: a maintainer will review and, if they agree, apply the patch or guide you through a contribution. The minimal test case you’ve provided (assert_eq!(sanitize_email("[email protected]").as_deref(), Some("[email protected]"))) is an excellent regression test to include.

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

This has been fixed in v0.16.12 which will be released this week.