Skip to main content

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}