Skip to main content

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;