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/// How to validate the domain.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum DomainCheck {
49    /// No domain validation beyond RFC syntax.
50    #[default]
51    Syntax,
52    /// Validate against Public Suffix List.
53    ///
54    /// **Requires the `psl` feature.** Falls back to [`Tld`](Self::Tld) check
55    /// when the `psl` feature is disabled.
56    Psl,
57    /// Require that the final label is syntactically TLD-like.
58    ///
59    /// Checks that the last label is at least two ASCII alphabetic characters
60    /// (e.g., `com`, `net`). Does *not* verify against a real TLD list —
61    /// use [`Psl`](Self::Psl) for semantic validation.
62    Tld,
63}
64
65/// Whether to strip +subaddress tags.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum SubaddressPolicy {
68    /// Keep subaddress in canonical form. Tag is still extracted and accessible.
69    #[default]
70    Preserve,
71    /// Strip subaddress from canonical form. Original still accessible.
72    Strip,
73}
74
75/// Configuration for email address parsing and normalization.
76///
77/// # Example
78///
79/// ```
80/// use structured_email_address::Config;
81///
82/// let config = Config::builder()
83///     .strip_subaddress()
84///     .dots_gmail_only()
85///     .lowercase_all()
86///     .build();
87/// ```
88#[derive(Debug, Clone)]
89pub struct Config {
90    pub(crate) strictness: Strictness,
91    pub(crate) dot_policy: DotPolicy,
92    pub(crate) case_policy: CasePolicy,
93    pub(crate) domain_check: DomainCheck,
94    pub(crate) subaddress: SubaddressPolicy,
95    pub(crate) subaddress_separator: char,
96    pub(crate) check_confusables: bool,
97    pub(crate) allow_domain_literal: bool,
98    pub(crate) allow_display_name: bool,
99    pub(crate) require_tld_dot: bool,
100    /// When true, a matched provider rule's dot/case/separator override the
101    /// global policies for that address.
102    pub(crate) provider_aware: bool,
103    /// Provider registry: source of truth for [`is_freemail`](crate::EmailAddress::is_freemail)
104    /// (always) and provider-aware normalization (when `provider_aware`).
105    pub(crate) providers: ProviderRegistry,
106}
107
108impl Default for Config {
109    fn default() -> Self {
110        Self {
111            strictness: Strictness::Standard,
112            dot_policy: DotPolicy::Preserve,
113            case_policy: CasePolicy::Domain,
114            domain_check: DomainCheck::Syntax,
115            subaddress: SubaddressPolicy::Preserve,
116            subaddress_separator: '+',
117            check_confusables: false,
118            allow_domain_literal: false,
119            allow_display_name: false,
120            require_tld_dot: true,
121            provider_aware: false,
122            providers: ProviderRegistry::builtin(),
123        }
124    }
125}
126
127impl Config {
128    /// Create a builder with default settings.
129    pub fn builder() -> ConfigBuilder {
130        ConfigBuilder(Config::default())
131    }
132}
133
134/// Builder for [`Config`].
135pub struct ConfigBuilder(Config);
136
137impl ConfigBuilder {
138    /// Set RFC strictness level.
139    pub fn strictness(mut self, s: Strictness) -> Self {
140        self.0.strictness = s;
141        self
142    }
143
144    /// Strip subaddress from canonical form.
145    pub fn strip_subaddress(mut self) -> Self {
146        self.0.subaddress = SubaddressPolicy::Strip;
147        self
148    }
149
150    /// Keep subaddress in canonical form (default).
151    pub fn preserve_subaddress(mut self) -> Self {
152        self.0.subaddress = SubaddressPolicy::Preserve;
153        self
154    }
155
156    /// Set the subaddress separator character (default: `+`).
157    pub fn subaddress_separator(mut self, sep: char) -> Self {
158        self.0.subaddress_separator = sep;
159        self
160    }
161
162    /// Strip dots only for Gmail/Googlemail.
163    pub fn dots_gmail_only(mut self) -> Self {
164        self.0.dot_policy = DotPolicy::GmailOnly;
165        self
166    }
167
168    /// Always strip dots from local part.
169    pub fn dots_always_strip(mut self) -> Self {
170        self.0.dot_policy = DotPolicy::Always;
171        self
172    }
173
174    /// Preserve dots (default).
175    pub fn dots_preserve(mut self) -> Self {
176        self.0.dot_policy = DotPolicy::Preserve;
177        self
178    }
179
180    /// Lowercase both local part and domain.
181    pub fn lowercase_all(mut self) -> Self {
182        self.0.case_policy = CasePolicy::All;
183        self
184    }
185
186    /// Lowercase domain only (default, RFC-correct).
187    pub fn lowercase_domain(mut self) -> Self {
188        self.0.case_policy = CasePolicy::Domain;
189        self
190    }
191
192    /// Preserve original case for local part (domain is always lowercased per RFC 5321).
193    pub fn preserve_case(mut self) -> Self {
194        self.0.case_policy = CasePolicy::Preserve;
195        self
196    }
197
198    /// Validate domain against Public Suffix List (requires `psl` feature).
199    pub fn domain_check_psl(mut self) -> Self {
200        self.0.domain_check = DomainCheck::Psl;
201        self
202    }
203
204    /// Validate domain has a recognized TLD.
205    pub fn domain_check_tld(mut self) -> Self {
206        self.0.domain_check = DomainCheck::Tld;
207        self
208    }
209
210    /// Enable anti-homoglyph confusable detection.
211    pub fn check_confusables(mut self) -> Self {
212        self.0.check_confusables = true;
213        self
214    }
215
216    /// Allow domain literals like `[192.168.1.1]`.
217    pub fn allow_domain_literal(mut self) -> Self {
218        self.0.allow_domain_literal = true;
219        self
220    }
221
222    /// Allow display names like `"John Doe" <john@example.com>`.
223    pub fn allow_display_name(mut self) -> Self {
224        self.0.allow_display_name = true;
225        self
226    }
227
228    /// Do not require a dot in the domain (allow single-label domains).
229    pub fn allow_single_label_domain(mut self) -> Self {
230        self.0.require_tld_dot = false;
231        self
232    }
233
234    /// Syntax-only domain check (default). Resets from `Psl`/`Tld` back to syntax.
235    pub fn domain_check_syntax(mut self) -> Self {
236        self.0.domain_check = DomainCheck::Syntax;
237        self
238    }
239
240    /// Enable provider-aware normalization.
241    ///
242    /// When enabled, an address whose domain matches a registered
243    /// [`ProviderRule`] is normalized by that provider's rule (dot stripping,
244    /// case folding, subaddress separator) instead of the global policies.
245    /// Addresses with no matching provider still use the global policies.
246    ///
247    /// Provider lookups for [`is_freemail`](crate::EmailAddress::is_freemail)
248    /// work regardless of this setting — it only gates normalization.
249    pub fn provider_aware(mut self) -> Self {
250        self.0.provider_aware = true;
251        self
252    }
253
254    /// Register a custom [`ProviderRule`], extending the built-in registry.
255    ///
256    /// User rules take precedence over built-ins for the same domain, so this
257    /// can also redefine a built-in provider. Affects [`is_freemail`](crate::EmailAddress::is_freemail)
258    /// always, and normalization when [`provider_aware`](Self::provider_aware) is set.
259    pub fn add_provider(mut self, rule: ProviderRule) -> Self {
260        self.0.providers.add(rule);
261        self
262    }
263
264    /// Replace the entire provider registry (e.g. start from
265    /// [`ProviderRegistry::empty`](crate::ProviderRegistry::empty)).
266    pub fn providers(mut self, registry: ProviderRegistry) -> Self {
267        self.0.providers = registry;
268        self
269    }
270
271    /// Build the config.
272    pub fn build(self) -> Config {
273        self.0
274    }
275}