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
6/// How strictly to validate RFC grammar.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum Strictness {
9    /// RFC 5321 envelope: dot-atom only, no comments, no quoted strings, no obs-*.
10    /// Rejects technically valid but practically useless addresses.
11    Strict,
12    /// RFC 5322 header: full grammar including quoted strings, comments, CFWS.
13    /// This is the correct conformant mode.
14    #[default]
15    Standard,
16    /// Standard + obs-local-part, obs-domain for legacy compatibility.
17    Lax,
18}
19
20/// How to handle dots in the local part.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum DotPolicy {
23    /// Do not strip dots.
24    #[default]
25    Preserve,
26    /// Strip dots only for known providers that ignore them (Gmail, Googlemail).
27    GmailOnly,
28    /// Always strip dots from local part.
29    Always,
30}
31
32/// How to handle letter case.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum CasePolicy {
35    /// Lowercase domain only (RFC says local part is case-sensitive, but domain is not).
36    #[default]
37    Domain,
38    /// Lowercase both local part and domain. Most providers are case-insensitive.
39    All,
40    /// Preserve original case for local part (domain is always lowercased per RFC 5321).
41    Preserve,
42}
43
44/// How to validate the domain.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum DomainCheck {
47    /// No domain validation beyond RFC syntax.
48    #[default]
49    Syntax,
50    /// Validate against Public Suffix List.
51    ///
52    /// **Requires the `psl` feature.** Falls back to [`Tld`](Self::Tld) check
53    /// when the `psl` feature is disabled.
54    Psl,
55    /// Require that the final label is syntactically TLD-like.
56    ///
57    /// Checks that the last label is at least two ASCII alphabetic characters
58    /// (e.g., `com`, `net`). Does *not* verify against a real TLD list —
59    /// use [`Psl`](Self::Psl) for semantic validation.
60    Tld,
61}
62
63/// Whether to strip +subaddress tags.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum SubaddressPolicy {
66    /// Keep subaddress in canonical form. Tag is still extracted and accessible.
67    #[default]
68    Preserve,
69    /// Strip subaddress from canonical form. Original still accessible.
70    Strip,
71}
72
73/// Configuration for email address parsing and normalization.
74///
75/// # Example
76///
77/// ```
78/// use structured_email_address::Config;
79///
80/// let config = Config::builder()
81///     .strip_subaddress()
82///     .dots_gmail_only()
83///     .lowercase_all()
84///     .build();
85/// ```
86#[derive(Debug, Clone)]
87pub struct Config {
88    pub(crate) strictness: Strictness,
89    pub(crate) dot_policy: DotPolicy,
90    pub(crate) case_policy: CasePolicy,
91    pub(crate) domain_check: DomainCheck,
92    pub(crate) subaddress: SubaddressPolicy,
93    pub(crate) subaddress_separator: char,
94    pub(crate) check_confusables: bool,
95    pub(crate) allow_domain_literal: bool,
96    pub(crate) allow_display_name: bool,
97    pub(crate) require_tld_dot: bool,
98}
99
100impl Default for Config {
101    fn default() -> Self {
102        Self {
103            strictness: Strictness::Standard,
104            dot_policy: DotPolicy::Preserve,
105            case_policy: CasePolicy::Domain,
106            domain_check: DomainCheck::Syntax,
107            subaddress: SubaddressPolicy::Preserve,
108            subaddress_separator: '+',
109            check_confusables: false,
110            allow_domain_literal: false,
111            allow_display_name: false,
112            require_tld_dot: true,
113        }
114    }
115}
116
117impl Config {
118    /// Create a builder with default settings.
119    pub fn builder() -> ConfigBuilder {
120        ConfigBuilder(Config::default())
121    }
122}
123
124/// Builder for [`Config`].
125pub struct ConfigBuilder(Config);
126
127impl ConfigBuilder {
128    /// Set RFC strictness level.
129    pub fn strictness(mut self, s: Strictness) -> Self {
130        self.0.strictness = s;
131        self
132    }
133
134    /// Strip subaddress from canonical form.
135    pub fn strip_subaddress(mut self) -> Self {
136        self.0.subaddress = SubaddressPolicy::Strip;
137        self
138    }
139
140    /// Keep subaddress in canonical form (default).
141    pub fn preserve_subaddress(mut self) -> Self {
142        self.0.subaddress = SubaddressPolicy::Preserve;
143        self
144    }
145
146    /// Set the subaddress separator character (default: `+`).
147    pub fn subaddress_separator(mut self, sep: char) -> Self {
148        self.0.subaddress_separator = sep;
149        self
150    }
151
152    /// Strip dots only for Gmail/Googlemail.
153    pub fn dots_gmail_only(mut self) -> Self {
154        self.0.dot_policy = DotPolicy::GmailOnly;
155        self
156    }
157
158    /// Always strip dots from local part.
159    pub fn dots_always_strip(mut self) -> Self {
160        self.0.dot_policy = DotPolicy::Always;
161        self
162    }
163
164    /// Preserve dots (default).
165    pub fn dots_preserve(mut self) -> Self {
166        self.0.dot_policy = DotPolicy::Preserve;
167        self
168    }
169
170    /// Lowercase both local part and domain.
171    pub fn lowercase_all(mut self) -> Self {
172        self.0.case_policy = CasePolicy::All;
173        self
174    }
175
176    /// Lowercase domain only (default, RFC-correct).
177    pub fn lowercase_domain(mut self) -> Self {
178        self.0.case_policy = CasePolicy::Domain;
179        self
180    }
181
182    /// Preserve original case for local part (domain is always lowercased per RFC 5321).
183    pub fn preserve_case(mut self) -> Self {
184        self.0.case_policy = CasePolicy::Preserve;
185        self
186    }
187
188    /// Validate domain against Public Suffix List (requires `psl` feature).
189    pub fn domain_check_psl(mut self) -> Self {
190        self.0.domain_check = DomainCheck::Psl;
191        self
192    }
193
194    /// Validate domain has a recognized TLD.
195    pub fn domain_check_tld(mut self) -> Self {
196        self.0.domain_check = DomainCheck::Tld;
197        self
198    }
199
200    /// Enable anti-homoglyph confusable detection.
201    pub fn check_confusables(mut self) -> Self {
202        self.0.check_confusables = true;
203        self
204    }
205
206    /// Allow domain literals like `[192.168.1.1]`.
207    pub fn allow_domain_literal(mut self) -> Self {
208        self.0.allow_domain_literal = true;
209        self
210    }
211
212    /// Allow display names like `"John Doe" <john@example.com>`.
213    pub fn allow_display_name(mut self) -> Self {
214        self.0.allow_display_name = true;
215        self
216    }
217
218    /// Do not require a dot in the domain (allow single-label domains).
219    pub fn allow_single_label_domain(mut self) -> Self {
220        self.0.require_tld_dot = false;
221        self
222    }
223
224    /// Syntax-only domain check (default). Resets from `Psl`/`Tld` back to syntax.
225    pub fn domain_check_syntax(mut self) -> Self {
226        self.0.domain_check = DomainCheck::Syntax;
227        self
228    }
229
230    /// Build the config.
231    pub fn build(self) -> Config {
232        self.0
233    }
234}