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}