tuwunel_service/threepid/canonical.rs
1//! Canonical email keys for binding and verification lookups.
2//!
3//! Canonicalization splits on the final at sign, lowercases both components,
4//! and expands the German sharp s. It does not otherwise normalize mailbox or
5//! domain syntax.
6
7use tuwunel_core::{Err, Result, err};
8
9/// Upper bound on an email address length, applied before canonicalization so
10/// a pathological input cannot drive unbounded work.
11const MAX_EMAIL_LEN: usize = 500;
12
13/// Canonicalizes an email address for storage and matching.
14///
15/// Both components are lowercased, and `ß` is expanded to `ss`, so
16/// `Strauß@Example.com` and `strauss@example.com` share one key.
17///
18/// Returns an error when the address exceeds 500 bytes or its final `@` does
19/// not separate nonempty local and domain parts.
20pub fn canonicalize_email(address: &str) -> Result<String> {
21 if address.len() > MAX_EMAIL_LEN {
22 return Err!(Request(InvalidParam("Email address is too long")));
23 }
24
25 let (local, domain) = address
26 .rsplit_once('@')
27 .ok_or_else(|| err!(Request(InvalidParam("Email address must contain a domain"))))?;
28
29 if local.is_empty() || domain.is_empty() {
30 return Err!(Request(InvalidParam("Email address is malformed")));
31 }
32
33 let local = case_fold(local);
34 let domain = case_fold(domain);
35
36 Ok(format!("{local}@{domain}"))
37}
38
39/// Applies the service's per-character email case fold.
40///
41/// Unicode lowercase expansion covers the common path. The German sharp s is
42/// mapped explicitly because `char::to_lowercase` does not perform that full
43/// fold.
44fn case_fold(input: &str) -> String {
45 input.chars().fold(String::new(), |mut out, c| {
46 match c {
47 | 'ß' => out.push_str("ss"),
48 | other => out.extend(other.to_lowercase()),
49 }
50
51 out
52 })
53}