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}