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    /// Whether `Local-part` keeps its `Quoted-string` alternative under
150    /// [`Strictness::Strict`]. The other two modes admit it either way.
151    pub(crate) quoted_local_part: bool,
152    /// When true, a matched provider rule's dot/case/separator override the
153    /// global policies for that address.
154    pub(crate) provider_aware: bool,
155    /// Provider registry: source of truth for [`is_freemail`](crate::EmailAddress::is_freemail)
156    /// (always) and provider-aware normalization (when `provider_aware`).
157    pub(crate) providers: ProviderRegistry,
158}
159
160impl Default for Config {
161    fn default() -> Self {
162        Self {
163            strictness: Strictness::Standard,
164            dot_policy: DotPolicy::Preserve,
165            case_policy: CasePolicy::Domain,
166            domain_check: DomainCheck::Syntax,
167            subaddress: SubaddressPolicy::Preserve,
168            subaddress_separator: '+',
169            check_confusables: false,
170            address_literal: AddressLiteral::Reject,
171            allow_display_name: false,
172            require_tld_dot: true,
173            quoted_local_part: false,
174            provider_aware: false,
175            providers: ProviderRegistry::builtin(),
176        }
177    }
178}
179
180impl Config {
181    /// Create a builder with default settings.
182    pub fn builder() -> ConfigBuilder {
183        ConfigBuilder(Config::default())
184    }
185}
186
187/// Builder for [`Config`].
188pub struct ConfigBuilder(Config);
189
190impl ConfigBuilder {
191    /// Set RFC strictness level.
192    pub fn strictness(mut self, s: Strictness) -> Self {
193        self.0.strictness = s;
194        self
195    }
196
197    /// Strip subaddress from canonical form.
198    pub fn strip_subaddress(mut self) -> Self {
199        self.0.subaddress = SubaddressPolicy::Strip;
200        self
201    }
202
203    /// Keep subaddress in canonical form (default).
204    pub fn preserve_subaddress(mut self) -> Self {
205        self.0.subaddress = SubaddressPolicy::Preserve;
206        self
207    }
208
209    /// Set the subaddress separator character (default: `+`).
210    pub fn subaddress_separator(mut self, sep: char) -> Self {
211        self.0.subaddress_separator = sep;
212        self
213    }
214
215    /// Strip dots only for Gmail/Googlemail.
216    pub fn dots_gmail_only(mut self) -> Self {
217        self.0.dot_policy = DotPolicy::GmailOnly;
218        self
219    }
220
221    /// Always strip dots from local part.
222    pub fn dots_always_strip(mut self) -> Self {
223        self.0.dot_policy = DotPolicy::Always;
224        self
225    }
226
227    /// Preserve dots (default).
228    pub fn dots_preserve(mut self) -> Self {
229        self.0.dot_policy = DotPolicy::Preserve;
230        self
231    }
232
233    /// Lowercase both local part and domain.
234    pub fn lowercase_all(mut self) -> Self {
235        self.0.case_policy = CasePolicy::All;
236        self
237    }
238
239    /// Lowercase domain only (default, RFC-correct).
240    pub fn lowercase_domain(mut self) -> Self {
241        self.0.case_policy = CasePolicy::Domain;
242        self
243    }
244
245    /// Preserve original case for local part (domain is always lowercased per RFC 5321).
246    pub fn preserve_case(mut self) -> Self {
247        self.0.case_policy = CasePolicy::Preserve;
248        self
249    }
250
251    /// Validate domain against Public Suffix List (requires `psl` feature).
252    pub fn domain_check_psl(mut self) -> Self {
253        self.0.domain_check = DomainCheck::Psl;
254        self
255    }
256
257    /// Validate domain has a recognized TLD.
258    pub fn domain_check_tld(mut self) -> Self {
259        self.0.domain_check = DomainCheck::Tld;
260        self
261    }
262
263    /// Enable anti-homoglyph confusable detection.
264    pub fn check_confusables(mut self) -> Self {
265        self.0.check_confusables = true;
266        self
267    }
268
269    /// Allow domain literals that name a routable destination, like
270    /// `[192.168.1.1]` or `[IPv6:::1]`.
271    ///
272    /// See [`AddressLiteral::Routable`] for the one place this reads the RFC
273    /// 5321 grammar more strictly than it is written.
274    pub fn allow_domain_literal(mut self) -> Self {
275        self.0.address_literal = AddressLiteral::Routable;
276        self
277    }
278
279    /// Allow every `address-literal` RFC 5321 §4.1.3 names, including
280    /// `General-address-literal`.
281    ///
282    /// Use this to read an address out of a document rather than to route mail
283    /// to it: `postmaster@[AS400:QSYS]` is a `Mailbox` by the grammar, and an
284    /// X.509 `rfc822Name` may hold one (RFC 5280 §4.2.1.6).
285    ///
286    /// ```
287    /// use structured_email_address::{Config, EmailAddress};
288    ///
289    /// let config = Config::builder().allow_address_literal_rfc5321().build();
290    /// let email = EmailAddress::parse_with("postmaster@[AS400:QSYS]", &config).unwrap();
291    /// assert_eq!(email.domain(), "[AS400:QSYS]");
292    /// ```
293    pub fn allow_address_literal_rfc5321(mut self) -> Self {
294        self.0.address_literal = AddressLiteral::Rfc5321;
295        self
296    }
297
298    /// Accept a quoted local part, `"a b"@example.com`.
299    ///
300    /// ```text
301    /// Local-part = Dot-string / Quoted-string
302    /// ```
303    ///
304    /// Under [`Strictness::Strict`] this restores the second alternative (RFC
305    /// 5321 §4.1.2), which is what a consumer validating an identity rather
306    /// than routing mail needs: an X.509 `rfc822Name` is a `Mailbox` (RFC 5280
307    /// §4.2.1.6), and a quoted local part is one.
308    /// [`Standard`](Strictness::Standard) and [`Lax`](Strictness::Lax) already
309    /// read the wider RFC 5322 grammar, which includes the quoted form, so this
310    /// changes nothing there.
311    ///
312    /// This settles the left of the `@` only:
313    ///
314    /// ```text
315    /// Mailbox = Local-part "@" ( Domain / address-literal )
316    /// ```
317    ///
318    /// The right has its own alternatives, and the second of them is off by
319    /// default too. Reading every `Mailbox` the grammar names — which is what a
320    /// certificate can carry — takes all three calls:
321    ///
322    /// ```
323    /// use structured_email_address::{Config, EmailAddress, Strictness};
324    ///
325    /// let mailbox = Config::builder()
326    ///     .strictness(Strictness::Strict)
327    ///     .allow_quoted_local_part()
328    ///     .allow_address_literal_rfc5321()
329    ///     .build();
330    ///
331    /// assert!(EmailAddress::parse_with("\"a b\"@example.com", &mailbox).is_ok());
332    /// assert!(EmailAddress::parse_with("postmaster@[AS400:QSYS]", &mailbox).is_ok());
333    /// ```
334    ///
335    /// A single-label `Domain` is the one place that reading stays narrower
336    /// than the grammar by default: RFC 5321 admits it, and
337    /// [`allow_single_label_domain`](Self::allow_single_label_domain) asks for
338    /// it back.
339    ///
340    /// The alphabet is the envelope one, `qtextSMTP` and `quoted-pairSMTP`:
341    /// printable ASCII and space, with the quote and the backslash reachable
342    /// only through a backslash, plus UTF-8 (RFC 6531 §3.3). It is narrower
343    /// than the RFC 5322 quoted string, so this cannot smuggle the header
344    /// grammar into the envelope one — a tab, a folded line or a control
345    /// character is still refused.
346    ///
347    /// ```
348    /// use structured_email_address::{Config, EmailAddress, Strictness};
349    ///
350    /// let config = Config::builder()
351    ///     .strictness(Strictness::Strict)
352    ///     .allow_quoted_local_part()
353    ///     .build();
354    ///
355    /// let email = EmailAddress::parse_with("\"a b\"@example.com", &config).unwrap();
356    /// assert_eq!(email.local_part(), "a b");
357    /// assert_eq!(email.canonical(), "\"a b\"@example.com");
358    ///
359    /// // A comment is header syntax, and Strict still refuses it.
360    /// assert!(EmailAddress::parse_with("a(c)@example.com", &config).is_err());
361    /// ```
362    pub fn allow_quoted_local_part(mut self) -> Self {
363        self.0.quoted_local_part = true;
364        self
365    }
366
367    /// Allow display names like `"John Doe" <john@example.com>`.
368    pub fn allow_display_name(mut self) -> Self {
369        self.0.allow_display_name = true;
370        self
371    }
372
373    /// Do not require a dot in the domain (allow single-label domains).
374    pub fn allow_single_label_domain(mut self) -> Self {
375        self.0.require_tld_dot = false;
376        self
377    }
378
379    /// Syntax-only domain check (default). Resets from `Psl`/`Tld` back to syntax.
380    pub fn domain_check_syntax(mut self) -> Self {
381        self.0.domain_check = DomainCheck::Syntax;
382        self
383    }
384
385    /// Enable provider-aware normalization.
386    ///
387    /// When enabled, an address whose domain matches a registered
388    /// [`ProviderRule`] is normalized by that provider's rule (dot stripping,
389    /// case folding, subaddress separator) instead of the global policies.
390    /// Addresses with no matching provider still use the global policies.
391    ///
392    /// Provider lookups for [`is_freemail`](crate::EmailAddress::is_freemail)
393    /// work regardless of this setting — it only gates normalization.
394    pub fn provider_aware(mut self) -> Self {
395        self.0.provider_aware = true;
396        self
397    }
398
399    /// Register a custom [`ProviderRule`], extending the built-in registry.
400    ///
401    /// User rules take precedence over built-ins for the same domain, so this
402    /// can also redefine a built-in provider. Affects [`is_freemail`](crate::EmailAddress::is_freemail)
403    /// always, and normalization when [`provider_aware`](Self::provider_aware) is set.
404    pub fn add_provider(mut self, rule: ProviderRule) -> Self {
405        self.0.providers.add(rule);
406        self
407    }
408
409    /// Replace the entire provider registry (e.g. start from
410    /// [`ProviderRegistry::empty`](crate::ProviderRegistry::empty)).
411    pub fn providers(mut self, registry: ProviderRegistry) -> Self {
412        self.0.providers = registry;
413        self
414    }
415
416    /// Build the config.
417    pub fn build(self) -> Config {
418        self.0
419    }
420}