Skip to main content

tuwunel_service/threepid/
canonical.rs

1use tuwunel_core::{Err, Result, err};
2
3/// Upper bound on an email address length, applied before canonicalization so
4/// a pathological input cannot drive unbounded work.
5const MAX_EMAIL_LEN: usize = 500;
6
7/// Canonicalize an email address for storage and matching: case-fold the
8/// whole address and lower-case the domain. This is stronger than
9/// `str::to_lowercase`, which leaves `ß` intact; case-folding maps it to `ss`
10/// so `Strauß@Example.com` and `strauss@example.com` collide on one key.
11///
12/// Errors when the address exceeds [`MAX_EMAIL_LEN`] or has no `@` separating
13/// a non-empty local part from a non-empty domain.
14pub fn canonicalize_email(address: &str) -> Result<String> {
15	if address.len() > MAX_EMAIL_LEN {
16		return Err!(Request(InvalidParam("Email address is too long")));
17	}
18
19	let (local, domain) = address
20		.rsplit_once('@')
21		.ok_or_else(|| err!(Request(InvalidParam("Email address must contain a domain"))))?;
22
23	if local.is_empty() || domain.is_empty() {
24		return Err!(Request(InvalidParam("Email address is malformed")));
25	}
26
27	let local = case_fold(local);
28	let domain = case_fold(domain);
29
30	Ok(format!("{local}@{domain}"))
31}
32
33/// Per-character Unicode case fold. `char::to_lowercase` covers the common
34/// path; the full-fold expansions it omits (the German sharp s being the one
35/// that matters for email) are mapped explicitly.
36fn case_fold(input: &str) -> String {
37	input.chars().fold(String::new(), |mut out, c| {
38		match c {
39			| 'ß' => out.push_str("ss"),
40			| other => out.extend(other.to_lowercase()),
41		}
42
43		out
44	})
45}