Skip to main content

structured_email_address/
config.rs

1//! Configuration for email address parsing, validation, and normalization.
2//!
3//! The builder pattern allows fine-grained control over every aspect of
4//! email handling — from RFC strictness level to provider-aware normalization.
5
6use crate::provider::{ProviderRegistry, ProviderRule};
7
8/// How strictly to validate RFC grammar.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum Strictness {
11    /// RFC 5321 envelope: dot-atom only, no comments, no quoted strings, no obs-*.
12    /// Rejects technically valid but practically useless addresses.
13    Strict,
14    /// RFC 5322 header: full grammar including quoted strings, comments, CFWS.
15    /// This is the correct conformant mode.
16    #[default]
17    Standard,
18    /// Standard + obs-local-part, obs-domain for legacy compatibility.
19    Lax,
20}
21
22/// How to handle dots in the local part.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DotPolicy {
25    /// Do not strip dots.
26    #[default]
27    Preserve,
28    /// Strip dots only for known providers that ignore them (Gmail, Googlemail).
29    GmailOnly,
30    /// Always strip dots from local part.
31    Always,
32}
33
34/// How to handle letter case.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum CasePolicy {
37    /// Lowercase domain only (RFC says local part is case-sensitive, but domain is not).
38    #[default]
39    Domain,
40    /// Lowercase both local part and domain. Most providers are case-insensitive.
41    All,
42    /// Preserve original case for local part (domain is always lowercased per RFC 5321).
43    Preserve,
44}
45
46/// Which `address-literal` spellings the domain may take (RFC 5321 §4.1.3).
47///
48/// The grammar names three alternatives:
49///
50/// ```text
51/// address-literal = "[" ( IPv4-address-literal /
52///                         IPv6-address-literal /
53///                         General-address-literal ) "]"
54/// ```
55///
56/// A domain literal is refused unless asked for, so the default is
57/// [`Reject`](Self::Reject). Of the two readings a caller can opt into, the
58/// destination-oriented one, [`Routable`](Self::Routable), covers the first two
59/// alternatives, which are the only ones mail can be delivered to. A reader of
60/// an identity rather than a destination needs the third:
61/// an X.509 `rfc822Name` is a `Mailbox` as RFC 5321 defines it (RFC 5280
62/// §4.2.1.6), and refusing a spelling the grammar names makes the certificate
63/// carrying it unreadable rather than merely unroutable.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum AddressLiteral {
66    /// Reject every `[...]` domain literal.
67    #[default]
68    Reject,
69    /// Accept only a literal naming a routable destination: an IPv4 dotted-quad
70    /// or an `IPv6:`-tagged IPv6 address.
71    ///
72    /// Stricter than the grammar in one place, deliberately: RFC 5321 `Snum` is
73    /// `1*3DIGIT` in the range 0..=255 with no rule against padding, so
74    /// `[012.0.2.1]` is inside the grammar. It is rejected here because a
75    /// zero-padded octet is read as octal by some resolvers, and a destination
76    /// that resolves two ways is not one. Use [`Rfc5321`](Self::Rfc5321) to read
77    /// the grammar as written.
78    Routable,
79    /// Accept every alternative the grammar names, `General-address-literal`
80    /// included, so `postmaster@[AS400:QSYS]` parses.
81    ///
82    /// ```text
83    /// General-address-literal = Standardized-tag ":" 1*dcontent
84    /// Standardized-tag        = Ldh-str
85    /// dcontent                = %d33-90 / %d94-126
86    /// ```
87    ///
88    /// The `IPv6` tag keeps its own alternative's meaning: RFC 5321 §4.1.3
89    /// requires a standardized tag to be defined by a Standards-Track RFC, and
90    /// IPv6 is, so `[IPv6:...]` must still hold an IPv6 address and is not
91    /// reinterpreted as free `dcontent`.
92    Rfc5321,
93}
94
95/// How to validate the domain.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum DomainCheck {
98    /// No domain validation beyond RFC syntax.
99    #[default]
100    Syntax,
101    /// Validate against Public Suffix List.
102    ///
103    /// **Requires the `psl` feature.** Falls back to [`Tld`](Self::Tld) check
104    /// when the `psl` feature is disabled.
105    Psl,
106    /// Require that the final label is syntactically TLD-like.
107    ///
108    /// Checks that the last label is at least two ASCII alphabetic characters
109    /// (e.g., `com`, `net`). Does *not* verify against a real TLD list —
110    /// use [`Psl`](Self::Psl) for semantic validation.
111    Tld,
112}
113
114/// Whether to strip +subaddress tags.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum SubaddressPolicy {
117    /// Keep subaddress in canonical form. Tag is still extracted and accessible.
118    #[default]
119    Preserve,
120    /// Strip subaddress from canonical form. Original still accessible.
121    Strip,
122}
123
124/// Configuration for email address parsing and normalization.
125///
126/// # Example
127///
128/// ```
129/// use structured_email_address::Config;
130///
131/// let config = Config::builder()
132///     .strip_subaddress()
133///     .dots_gmail_only()
134///     .lowercase_all()
135///     .build();
136/// ```
137#[derive(Debug, Clone)]
138pub struct Config {
139    pub(crate) strictness: Strictness,
140    pub(crate) dot_policy: DotPolicy,
141    pub(crate) case_policy: CasePolicy,
142    pub(crate) domain_check: DomainCheck,
143    pub(crate) subaddress: SubaddressPolicy,
144    pub(crate) subaddress_separator: char,
145    pub(crate) check_confusables: bool,
146    pub(crate) address_literal: AddressLiteral,
147    pub(crate) allow_display_name: bool,
148    pub(crate) require_tld_dot: bool,
149    /// When true, a matched provider rule's dot/case/separator override the
150    /// global policies for that address.
151    pub(crate) provider_aware: bool,
152    /// Provider registry: source of truth for [`is_freemail`](crate::EmailAddress::is_freemail)
153    /// (always) and provider-aware normalization (when `provider_aware`).
154    pub(crate) providers: ProviderRegistry,
155}
156
157impl Default for Config {
158    fn default() -> Self {
159        Self {
160            strictness: Strictness::Standard,
161            dot_policy: DotPolicy::Preserve,
162            case_policy: CasePolicy::Domain,
163            domain_check: DomainCheck::Syntax,
164            subaddress: SubaddressPolicy::Preserve,
165            subaddress_separator: '+',
166            check_confusables: false,
167            address_literal: AddressLiteral::Reject,
168            allow_display_name: false,
169            require_tld_dot: true,
170            provider_aware: false,
171            providers: ProviderRegistry::builtin(),
172        }
173    }
174}
175
176impl Config {
177    /// Create a builder with default settings.
178    pub fn builder() -> ConfigBuilder {
179        ConfigBuilder(Config::default())
180    }
181}
182
183/// Builder for [`Config`].
184pub struct ConfigBuilder(Config);
185
186impl ConfigBuilder {
187    /// Set RFC strictness level.
188    pub fn strictness(mut self, s: Strictness) -> Self {
189        self.0.strictness = s;
190        self
191    }
192
193    /// Strip subaddress from canonical form.
194    pub fn strip_subaddress(mut self) -> Self {
195        self.0.subaddress = SubaddressPolicy::Strip;
196        self
197    }
198
199    /// Keep subaddress in canonical form (default).
200    pub fn preserve_subaddress(mut self) -> Self {
201        self.0.subaddress = SubaddressPolicy::Preserve;
202        self
203    }
204
205    /// Set the subaddress separator character (default: `+`).
206    pub fn subaddress_separator(mut self, sep: char) -> Self {
207        self.0.subaddress_separator = sep;
208        self
209    }
210
211    /// Strip dots only for Gmail/Googlemail.
212    pub fn dots_gmail_only(mut self) -> Self {
213        self.0.dot_policy = DotPolicy::GmailOnly;
214        self
215    }
216
217    /// Always strip dots from local part.
218    pub fn dots_always_strip(mut self) -> Self {
219        self.0.dot_policy = DotPolicy::Always;
220        self
221    }
222
223    /// Preserve dots (default).
224    pub fn dots_preserve(mut self) -> Self {
225        self.0.dot_policy = DotPolicy::Preserve;
226        self
227    }
228
229    /// Lowercase both local part and domain.
230    pub fn lowercase_all(mut self) -> Self {
231        self.0.case_policy = CasePolicy::All;
232        self
233    }
234
235    /// Lowercase domain only (default, RFC-correct).
236    pub fn lowercase_domain(mut self) -> Self {
237        self.0.case_policy = CasePolicy::Domain;
238        self
239    }
240
241    /// Preserve original case for local part (domain is always lowercased per RFC 5321).
242    pub fn preserve_case(mut self) -> Self {
243        self.0.case_policy = CasePolicy::Preserve;
244        self
245    }
246
247    /// Validate domain against Public Suffix List (requires `psl` feature).
248    pub fn domain_check_psl(mut self) -> Self {
249        self.0.domain_check = DomainCheck::Psl;
250        self
251    }
252
253    /// Validate domain has a recognized TLD.
254    pub fn domain_check_tld(mut self) -> Self {
255        self.0.domain_check = DomainCheck::Tld;
256        self
257    }
258
259    /// Enable anti-homoglyph confusable detection.
260    pub fn check_confusables(mut self) -> Self {
261        self.0.check_confusables = true;
262        self
263    }
264
265    /// Allow domain literals that name a routable destination, like
266    /// `[192.168.1.1]` or `[IPv6:::1]`.
267    ///
268    /// See [`AddressLiteral::Routable`] for the one place this reads the RFC
269    /// 5321 grammar more strictly than it is written.
270    pub fn allow_domain_literal(mut self) -> Self {
271        self.0.address_literal = AddressLiteral::Routable;
272        self
273    }
274
275    /// Allow every `address-literal` RFC 5321 §4.1.3 names, including
276    /// `General-address-literal`.
277    ///
278    /// Use this to read an address out of a document rather than to route mail
279    /// to it: `postmaster@[AS400:QSYS]` is a `Mailbox` by the grammar, and an
280    /// X.509 `rfc822Name` may hold one (RFC 5280 §4.2.1.6).
281    ///
282    /// ```
283    /// use structured_email_address::{Config, EmailAddress};
284    ///
285    /// let config = Config::builder().allow_address_literal_rfc5321().build();
286    /// let email = EmailAddress::parse_with("postmaster@[AS400:QSYS]", &config).unwrap();
287    /// assert_eq!(email.domain(), "[AS400:QSYS]");
288    /// ```
289    pub fn allow_address_literal_rfc5321(mut self) -> Self {
290        self.0.address_literal = AddressLiteral::Rfc5321;
291        self
292    }
293
294    /// Allow display names like `"John Doe" <john@example.com>`.
295    pub fn allow_display_name(mut self) -> Self {
296        self.0.allow_display_name = true;
297        self
298    }
299
300    /// Do not require a dot in the domain (allow single-label domains).
301    pub fn allow_single_label_domain(mut self) -> Self {
302        self.0.require_tld_dot = false;
303        self
304    }
305
306    /// Syntax-only domain check (default). Resets from `Psl`/`Tld` back to syntax.
307    pub fn domain_check_syntax(mut self) -> Self {
308        self.0.domain_check = DomainCheck::Syntax;
309        self
310    }
311
312    /// Enable provider-aware normalization.
313    ///
314    /// When enabled, an address whose domain matches a registered
315    /// [`ProviderRule`] is normalized by that provider's rule (dot stripping,
316    /// case folding, subaddress separator) instead of the global policies.
317    /// Addresses with no matching provider still use the global policies.
318    ///
319    /// Provider lookups for [`is_freemail`](crate::EmailAddress::is_freemail)
320    /// work regardless of this setting — it only gates normalization.
321    pub fn provider_aware(mut self) -> Self {
322        self.0.provider_aware = true;
323        self
324    }
325
326    /// Register a custom [`ProviderRule`], extending the built-in registry.
327    ///
328    /// User rules take precedence over built-ins for the same domain, so this
329    /// can also redefine a built-in provider. Affects [`is_freemail`](crate::EmailAddress::is_freemail)
330    /// always, and normalization when [`provider_aware`](Self::provider_aware) is set.
331    pub fn add_provider(mut self, rule: ProviderRule) -> Self {
332        self.0.providers.add(rule);
333        self
334    }
335
336    /// Replace the entire provider registry (e.g. start from
337    /// [`ProviderRegistry::empty`](crate::ProviderRegistry::empty)).
338    pub fn providers(mut self, registry: ProviderRegistry) -> Self {
339        self.0.providers = registry;
340        self
341    }
342
343    /// Build the config.
344    pub fn build(self) -> Config {
345        self.0
346    }
347}