structured_email_address/provider.rs
1//! Provider-aware normalization rules.
2//!
3//! Different mail providers treat the local part differently: Gmail ignores
4//! dots, most freemail providers fold case, subaddress separators vary. A
5//! [`ProviderRegistry`] maps domains to [`ProviderRule`]s so normalization can
6//! be provider-aware, and applications can register their own providers.
7//!
8//! The registry is also the source of truth for [`EmailAddress::is_freemail`],
9//! independent of whether provider-aware normalization is enabled.
10//!
11//! [`EmailAddress::is_freemail`]: crate::EmailAddress::is_freemail
12
13use alloc::borrow::Cow;
14use alloc::string::String;
15
16/// Normalization rule for one mail provider (a set of equivalent domains).
17///
18/// Construct with [`ProviderRule::new`] and refine with the builder-style
19/// setters. Fields are private so the rule can gain options without a breaking
20/// change.
21///
22/// # Example
23///
24/// ```
25/// use structured_email_address::ProviderRule;
26///
27/// // A corporate provider that ignores dots and folds case, tag separator '+'.
28/// let rule = ProviderRule::new(["mail.example.com"])
29/// .strip_dots(true)
30/// .lowercase_local(true)
31/// .freemail(false);
32/// assert!(rule.matches("MAIL.EXAMPLE.COM"));
33/// ```
34#[derive(Debug, Clone)]
35pub struct ProviderRule {
36 /// Borrowed for the built-in rules, which are static data, and owned for a
37 /// rule a caller builds. Keeping both in one type lets the built-in registry
38 /// be a `static` rather than something initialized at first use.
39 domains: Cow<'static, [Cow<'static, str>]>,
40 strip_dots: bool,
41 lowercase_local: bool,
42 subaddress_sep: Option<char>,
43 is_freemail: bool,
44}
45
46impl ProviderRule {
47 /// Create a rule for the given domains.
48 ///
49 /// Domains are stored in their IDNA-ASCII (punycode) canonical form so a
50 /// rule registered as `münchen.de` and one as `xn--mnchen-3ya.de` are
51 /// equivalent, and matching agrees with the canonical domain used elsewhere.
52 ///
53 /// Defaults: no dot stripping, no case folding, `+` subaddress separator,
54 /// and `is_freemail = false` (a custom rule is treated as a private domain
55 /// unless you opt in with [`freemail(true)`](Self::freemail)).
56 pub fn new<I, S>(domains: I) -> Self
57 where
58 I: IntoIterator<Item = S>,
59 S: Into<String>,
60 {
61 Self {
62 domains: Cow::Owned(
63 domains
64 .into_iter()
65 .map(|d| Cow::Owned(canonical_domain(&d.into())))
66 .collect(),
67 ),
68 strip_dots: false,
69 lowercase_local: false,
70 subaddress_sep: Some('+'),
71 is_freemail: false,
72 }
73 }
74
75 /// Set whether dots in the local part are insignificant (e.g. Gmail).
76 #[must_use]
77 pub fn strip_dots(mut self, yes: bool) -> Self {
78 self.strip_dots = yes;
79 self
80 }
81
82 /// Set whether the local part is case-insensitive (folded to lowercase).
83 #[must_use]
84 pub fn lowercase_local(mut self, yes: bool) -> Self {
85 self.lowercase_local = yes;
86 self
87 }
88
89 /// Set the subaddress separator, or `None` if the provider has no
90 /// subaddressing.
91 #[must_use]
92 pub fn subaddress_separator(mut self, sep: Option<char>) -> Self {
93 self.subaddress_sep = sep;
94 self
95 }
96
97 /// Set whether this provider is a free webmail provider.
98 #[must_use]
99 pub fn freemail(mut self, yes: bool) -> Self {
100 self.is_freemail = yes;
101 self
102 }
103
104 /// Returns true if `domain` belongs to this provider.
105 ///
106 /// The domain is canonicalized to IDNA-ASCII before comparison, so Unicode
107 /// and punycode spellings of the same domain match.
108 pub fn matches(&self, domain: &str) -> bool {
109 self.matches_canonical(&canonical_domain(domain))
110 }
111
112 /// Match against a domain already in canonical (IDNA-ASCII) form.
113 fn matches_canonical(&self, canonical: &str) -> bool {
114 self.domains.iter().any(|d| &**d == canonical)
115 }
116
117 /// Whether the local part's dots are insignificant.
118 pub fn strips_dots(&self) -> bool {
119 self.strip_dots
120 }
121
122 /// Whether the local part is case-insensitive.
123 pub fn folds_case(&self) -> bool {
124 self.lowercase_local
125 }
126
127 /// The provider's subaddress separator, if any.
128 pub fn separator(&self) -> Option<char> {
129 self.subaddress_sep
130 }
131
132 /// Whether this is a free webmail provider.
133 pub fn is_freemail(&self) -> bool {
134 self.is_freemail
135 }
136}
137
138/// A set of [`ProviderRule`]s with domain lookup.
139///
140/// [`builtin`](Self::builtin) seeds the well-known providers; applications can
141/// extend it with [`add`](Self::add). User-added rules take precedence over
142/// built-ins, so a custom rule can redefine a built-in provider.
143#[derive(Debug, Clone)]
144pub struct ProviderRegistry {
145 rules: Cow<'static, [ProviderRule]>,
146}
147
148/// Spell a built-in domain list, which is static data.
149macro_rules! domains {
150 ($($d:literal),+ $(,)?) => {
151 &[$(Cow::Borrowed($d)),+]
152 };
153}
154
155/// One built-in provider. Every built-in folds local-part case, uses `+` as its
156/// subaddress separator and is freemail; only the dot policy varies.
157///
158/// The domains skip [`canonical_domain`] because they are already written in
159/// canonical form: lowercase ASCII with no IDN label to punycode.
160const fn builtin(domains: &'static [Cow<'static, str>], strip_dots: bool) -> ProviderRule {
161 ProviderRule {
162 domains: Cow::Borrowed(domains),
163 strip_dots,
164 lowercase_local: true,
165 subaddress_sep: Some('+'),
166 is_freemail: true,
167 }
168}
169
170// The built-in registry is static data, so it is a `static` and not something
171// built at first use. That costs no allocation, no lazy-initialization branch
172// and, unlike a `OnceLock`/`OnceBox`, no pointer-width atomic: bare-metal
173// targets without a compare-and-swap (thumbv6m, riscv32i) can build the crate.
174// `builtin()` clones it and the GmailOnly dot-policy borrows it, and cloning a
175// borrowed Cow copies nothing.
176static BUILTIN: ProviderRegistry = ProviderRegistry {
177 rules: Cow::Borrowed(&[
178 builtin(domains!["gmail.com", "googlemail.com"], true),
179 builtin(
180 domains!["outlook.com", "hotmail.com", "live.com", "msn.com"],
181 false,
182 ),
183 builtin(domains!["yahoo.com", "yahoo.co.uk", "yahoo.co.jp"], false),
184 builtin(domains!["protonmail.com", "proton.me"], false),
185 builtin(domains!["icloud.com", "me.com", "mac.com"], false),
186 builtin(domains!["yandex.ru", "yandex.com"], false),
187 builtin(domains!["mail.ru"], false),
188 // Freemail providers without special normalization quirks.
189 builtin(
190 domains![
191 "aol.com",
192 "mail.com",
193 "zoho.com",
194 "gmx.com",
195 "gmx.de",
196 "web.de",
197 "tutanota.com",
198 "tuta.io",
199 "fastmail.com",
200 ],
201 false,
202 ),
203 ]),
204};
205
206/// Borrow the process-wide built-in registry without allocating.
207pub(crate) fn builtin_ref() -> &'static ProviderRegistry {
208 &BUILTIN
209}
210
211impl ProviderRegistry {
212 /// An empty registry.
213 pub fn empty() -> Self {
214 Self {
215 rules: Cow::Borrowed(&[]),
216 }
217 }
218
219 /// The built-in registry of well-known mail providers.
220 ///
221 /// Only Gmail/Googlemail ignore dots; every entry folds local-part case and
222 /// uses `+` as its subaddress separator. All built-ins are freemail.
223 ///
224 /// Returns an owned clone of a process-wide shared registry, so the rule set
225 /// is constructed (and its domains IDNA-canonicalized) only once.
226 pub fn builtin() -> Self {
227 builtin_ref().clone()
228 }
229
230 /// Add a rule. User-added rules take precedence over earlier ones.
231 /// Adding to a registry still borrowing the built-ins copies them once,
232 /// here, rather than on every `builtin()` call.
233 pub fn add(&mut self, rule: ProviderRule) {
234 self.rules.to_mut().push(rule);
235 }
236
237 /// Builder-style [`add`](Self::add).
238 #[must_use]
239 pub fn with(mut self, rule: ProviderRule) -> Self {
240 self.add(rule);
241 self
242 }
243
244 /// Look up the rule for a domain, or `None` if no provider matches.
245 ///
246 /// Most-recently-added rules win, so a custom rule overrides a built-in for
247 /// the same domain. The domain is canonicalized to IDNA-ASCII once, so
248 /// Unicode and punycode spellings resolve to the same rule.
249 pub fn lookup(&self, domain: &str) -> Option<&ProviderRule> {
250 let canonical = canonical_domain(domain);
251 self.rules
252 .iter()
253 .rev()
254 .find(|r| r.matches_canonical(&canonical))
255 }
256}
257
258impl Default for ProviderRegistry {
259 fn default() -> Self {
260 Self::builtin()
261 }
262}
263
264/// Canonicalize a domain to its IDNA-ASCII (punycode) form, lowercased.
265///
266/// Falls back to ASCII lowercasing if the input is not a valid domain, so
267/// matching never panics on arbitrary registry input.
268fn canonical_domain(domain: &str) -> String {
269 idna::domain_to_ascii(domain).unwrap_or_else(|_| domain.to_ascii_lowercase())
270}
271
272#[cfg(test)]
273mod tests;