Skip to main content

daaki_smtp/types/
validated.rs

1//! Validated newtypes for SMTP protocol values.
2//!
3//! Each type validates its contents on construction so that encoders
4//! can accept them without redundant runtime checks. Validation rules
5//! are derived from the cited RFC sections.
6//!
7//! # References
8//! - RFC 5321 Section 4.1.2 (Domain, Mailbox, path syntax)
9//! - RFC 5321 Section 4.1.3 (address-literal syntax)
10//! - RFC 5321 Section 4.5.3.1 (size limits)
11//! - RFC 3461 Section 4.4 (ENVID xtext values)
12
13use std::fmt;
14use std::net::{Ipv4Addr, Ipv6Addr};
15
16pub use daaki_message::ValidationError;
17
18// ============================================================================
19// Domain — RFC 5321 Section 4.1.2
20// ============================================================================
21
22/// A validated SMTP domain name.
23///
24/// RFC 5321 Section 4.1.2: `Domain = sub-domain *("." sub-domain)`.
25/// Each label is 1-63 octets of `Let-dig [Ldh-str]`, and the total
26/// domain is at most 255 octets (RFC 5321 Section 4.5.3.1.2).
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
30pub struct Domain(String);
31
32impl Domain {
33    /// Create a new `Domain` after validating RFC 5321 Section 4.1.2 syntax.
34    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
35        let s = s.into();
36        validate_domain_syntax(&s)?;
37        Ok(Self(s))
38    }
39
40    /// Return the domain as a string slice.
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44
45    /// Consume and return the inner `String`.
46    pub fn into_inner(self) -> String {
47        self.0
48    }
49}
50
51impl AsRef<str> for Domain {
52    fn as_ref(&self) -> &str {
53        &self.0
54    }
55}
56
57impl fmt::Display for Domain {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(&self.0)
60    }
61}
62
63impl TryFrom<String> for Domain {
64    type Error = ValidationError;
65    fn try_from(s: String) -> Result<Self, ValidationError> {
66        Self::new(s)
67    }
68}
69
70impl TryFrom<&str> for Domain {
71    type Error = ValidationError;
72    fn try_from(s: &str) -> Result<Self, ValidationError> {
73        Self::new(s)
74    }
75}
76
77impl From<Domain> for String {
78    fn from(d: Domain) -> Self {
79        d.into_inner()
80    }
81}
82
83// ============================================================================
84// AddressLiteral — RFC 5321 Section 4.1.3
85// ============================================================================
86
87/// A validated SMTP address-literal (e.g. `[127.0.0.1]` or `[IPv6::1]`).
88///
89/// RFC 5321 Section 4.1.3: `address-literal = "[" ( IPv4-address-literal /
90/// IPv6-addr / General-address-literal ) "]"`.
91#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
94pub struct AddressLiteral(String);
95
96impl AddressLiteral {
97    /// Create a new `AddressLiteral` after validating RFC 5321 Section 4.1.3 syntax.
98    ///
99    /// The value must include the surrounding `[` and `]` brackets.
100    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
101        let s = s.into();
102        validate_address_literal_syntax(&s)?;
103        Ok(Self(s))
104    }
105
106    /// Return the address literal as a string slice (including brackets).
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    /// Consume and return the inner `String`.
112    pub fn into_inner(self) -> String {
113        self.0
114    }
115}
116
117impl AsRef<str> for AddressLiteral {
118    fn as_ref(&self) -> &str {
119        &self.0
120    }
121}
122
123impl fmt::Display for AddressLiteral {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.write_str(&self.0)
126    }
127}
128
129impl TryFrom<String> for AddressLiteral {
130    type Error = ValidationError;
131    fn try_from(s: String) -> Result<Self, ValidationError> {
132        Self::new(s)
133    }
134}
135
136impl TryFrom<&str> for AddressLiteral {
137    type Error = ValidationError;
138    fn try_from(s: &str) -> Result<Self, ValidationError> {
139        Self::new(s)
140    }
141}
142
143impl From<AddressLiteral> for String {
144    fn from(a: AddressLiteral) -> Self {
145        a.into_inner()
146    }
147}
148
149// ============================================================================
150// DomainOrLiteral — RFC 5321 Section 4.1.1.1
151// ============================================================================
152
153/// A validated SMTP domain or address-literal, for use in EHLO/LHLO.
154///
155/// RFC 5321 Section 4.1.1.1: `ehlo-domain = Domain / address-literal`.
156#[non_exhaustive]
157#[derive(Debug, Clone, PartialEq, Eq, Hash)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159pub enum DomainOrLiteral {
160    /// A DNS domain name.
161    Domain(Domain),
162    /// An address literal (`[IPv4]`, `[IPv6:...]`, or generalized).
163    Literal(AddressLiteral),
164}
165
166impl DomainOrLiteral {
167    /// Create from a string, detecting whether it's a domain or literal.
168    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
169        let s = s.into();
170        if s.is_empty() {
171            return Err(ValidationError::new(
172                "SMTP greeting domain must not be empty (RFC 5321 Section 4.1.1.1)",
173            ));
174        }
175        if s.starts_with('[') && s.ends_with(']') {
176            Ok(Self::Literal(AddressLiteral::new(s)?))
177        } else {
178            Ok(Self::Domain(Domain::new(s)?))
179        }
180    }
181
182    /// Return the value as a string slice.
183    pub fn as_str(&self) -> &str {
184        match self {
185            Self::Domain(d) => d.as_str(),
186            Self::Literal(l) => l.as_str(),
187        }
188    }
189}
190
191impl AsRef<str> for DomainOrLiteral {
192    fn as_ref(&self) -> &str {
193        self.as_str()
194    }
195}
196
197impl fmt::Display for DomainOrLiteral {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.write_str(self.as_str())
200    }
201}
202
203impl TryFrom<String> for DomainOrLiteral {
204    type Error = ValidationError;
205    fn try_from(s: String) -> Result<Self, ValidationError> {
206        Self::new(s)
207    }
208}
209
210impl TryFrom<&str> for DomainOrLiteral {
211    type Error = ValidationError;
212    fn try_from(s: &str) -> Result<Self, ValidationError> {
213        Self::new(s)
214    }
215}
216
217// ============================================================================
218// Mailbox — RFC 5321 Section 4.1.2
219// ============================================================================
220
221/// A validated SMTP mailbox (`local-part@domain`).
222///
223/// RFC 5321 Section 4.1.2: `Mailbox = Local-part "@" ( Domain / address-literal )`.
224/// Local-part is at most 64 octets (RFC 5321 Section 4.5.3.1.1).
225/// The full path (`<mailbox>`) is at most 256 octets (RFC 5321 Section 4.5.3.1.3).
226#[derive(Debug, Clone, PartialEq, Eq, Hash)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
228#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
229pub struct Mailbox(String);
230
231impl Mailbox {
232    /// Create a new `Mailbox` after validating RFC 5321 Section 4.1.2 syntax.
233    ///
234    /// The value must be a bare addr-spec (`user@domain`), not a display-name
235    /// form like `"User" <user@domain>`.
236    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
237        let s = s.into();
238        validate_mailbox_syntax(&s)?;
239        Ok(Self(strip_obsolete_source_route(&s)?.to_string()))
240    }
241
242    /// Return the mailbox as a string slice.
243    pub fn as_str(&self) -> &str {
244        &self.0
245    }
246
247    /// Consume and return the inner `String`.
248    pub fn into_inner(self) -> String {
249        self.0
250    }
251
252    /// Returns `true` if this mailbox contains non-ASCII characters,
253    /// requiring the SMTPUTF8 extension (RFC 6531 Sections 3.3-3.4).
254    pub fn requires_smtputf8(&self) -> bool {
255        !self.0.is_ascii()
256    }
257}
258
259impl AsRef<str> for Mailbox {
260    fn as_ref(&self) -> &str {
261        &self.0
262    }
263}
264
265impl fmt::Display for Mailbox {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.write_str(&self.0)
268    }
269}
270
271impl TryFrom<String> for Mailbox {
272    type Error = ValidationError;
273    fn try_from(s: String) -> Result<Self, ValidationError> {
274        Self::new(s)
275    }
276}
277
278impl TryFrom<&str> for Mailbox {
279    type Error = ValidationError;
280    fn try_from(s: &str) -> Result<Self, ValidationError> {
281        Self::new(s)
282    }
283}
284
285impl From<Mailbox> for String {
286    fn from(m: Mailbox) -> Self {
287        m.into_inner()
288    }
289}
290
291// ============================================================================
292// ReversePath — RFC 5321 Section 4.1.1.2
293// ============================================================================
294
295/// A validated SMTP reverse-path for MAIL FROM.
296///
297/// RFC 5321 Section 4.1.1.2: `Reverse-path = Path / "<>"`.
298/// A null reverse-path (`<>`) indicates a bounce message.
299/// Obsolete source routes in `Path` syntax are accepted but ignored per
300/// RFC 5321 Section 4.1.2.
301#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq, Hash)]
303#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
304pub enum ReversePath {
305    /// Null reverse-path (`<>`) — bounce/DSN message.
306    Null,
307    /// A mailbox address.
308    Mailbox(Mailbox),
309}
310
311impl ReversePath {
312    /// Create from a string. An empty string produces `Null`.
313    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
314        let s = s.into();
315        if s.is_empty() {
316            Ok(Self::Null)
317        } else {
318            Ok(Self::Mailbox(Mailbox::new(s)?))
319        }
320    }
321
322    /// Return the path as a string slice (empty for null path).
323    pub fn as_str(&self) -> &str {
324        match self {
325            Self::Null => "",
326            Self::Mailbox(m) => m.as_str(),
327        }
328    }
329
330    /// Returns `true` if this is a non-null path with non-ASCII characters,
331    /// requiring the SMTPUTF8 extension (RFC 6531 Sections 3.3-3.4).
332    pub fn requires_smtputf8(&self) -> bool {
333        match self {
334            Self::Null => false,
335            Self::Mailbox(m) => m.requires_smtputf8(),
336        }
337    }
338}
339
340impl fmt::Display for ReversePath {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        f.write_str(self.as_str())
343    }
344}
345
346impl TryFrom<String> for ReversePath {
347    type Error = ValidationError;
348    fn try_from(s: String) -> Result<Self, ValidationError> {
349        Self::new(s)
350    }
351}
352
353impl TryFrom<&str> for ReversePath {
354    type Error = ValidationError;
355    fn try_from(s: &str) -> Result<Self, ValidationError> {
356        Self::new(s)
357    }
358}
359
360// ============================================================================
361// ForwardPath — RFC 5321 Section 4.1.1.3
362// ============================================================================
363
364/// A validated SMTP forward-path for RCPT TO.
365///
366/// RFC 5321 Section 4.1.1.3: `Forward-path = Path`. Also accepts the
367/// special `<Postmaster>` recipient.
368/// Obsolete source routes in `Path` syntax are accepted but ignored per
369/// RFC 5321 Section 4.1.2.
370#[non_exhaustive]
371#[derive(Debug, Clone, PartialEq, Eq, Hash)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
373pub enum ForwardPath {
374    /// The special `Postmaster` recipient (case-insensitive).
375    Postmaster,
376    /// A mailbox address.
377    Mailbox(Mailbox),
378}
379
380impl ForwardPath {
381    /// Create from a string. Case-insensitive "postmaster" produces `Postmaster`.
382    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
383        let s = s.into();
384        if s.eq_ignore_ascii_case("postmaster") {
385            Ok(Self::Postmaster)
386        } else {
387            Ok(Self::Mailbox(Mailbox::new(s)?))
388        }
389    }
390
391    /// Return the path as a string slice.
392    pub fn as_str(&self) -> &str {
393        match self {
394            Self::Postmaster => "Postmaster",
395            Self::Mailbox(m) => m.as_str(),
396        }
397    }
398
399    /// Returns `true` if this is a non-postmaster path with non-ASCII characters.
400    pub fn requires_smtputf8(&self) -> bool {
401        match self {
402            Self::Postmaster => false,
403            Self::Mailbox(m) => m.requires_smtputf8(),
404        }
405    }
406}
407
408impl fmt::Display for ForwardPath {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        f.write_str(self.as_str())
411    }
412}
413
414impl TryFrom<String> for ForwardPath {
415    type Error = ValidationError;
416    fn try_from(s: String) -> Result<Self, ValidationError> {
417        Self::new(s)
418    }
419}
420
421impl TryFrom<&str> for ForwardPath {
422    type Error = ValidationError;
423    fn try_from(s: &str) -> Result<Self, ValidationError> {
424        Self::new(s)
425    }
426}
427
428// ============================================================================
429// EnvidValue — RFC 3461 Section 4.4
430// ============================================================================
431
432/// A validated DSN ENVID source value.
433///
434/// RFC 3461 Section 4.4: printable US-ASCII, non-empty, at most 100
435/// characters before xtext encoding.
436#[derive(Debug, Clone, PartialEq, Eq, Hash)]
437#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
438#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
439pub struct EnvidValue(String);
440
441impl EnvidValue {
442    /// Consume and return the inner `String`.
443    pub fn into_inner(self) -> String {
444        self.0
445    }
446
447    /// Create a new `EnvidValue` after validating RFC 3461 Section 4.4 constraints.
448    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
449        let s = s.into();
450        if s.is_empty() {
451            return Err(ValidationError::new(
452                "ENVID value must be non-empty (RFC 3461 Section 4.4: xtext = 1*xchar)",
453            ));
454        }
455        if !s
456            .bytes()
457            .all(|b| b.is_ascii() && (0x20..=0x7E).contains(&b))
458        {
459            return Err(ValidationError::new(
460                "ENVID source value must contain only printable US-ASCII before xtext encoding \
461                 (RFC 3461 Section 4.4)",
462            ));
463        }
464        if s.len() > 100 {
465            return Err(ValidationError::new(
466                "ENVID value must be at most 100 characters before xtext encoding \
467                 (RFC 3461 Section 4.4)",
468            ));
469        }
470        Ok(Self(s))
471    }
472
473    /// Return the value as a string slice.
474    pub fn as_str(&self) -> &str {
475        &self.0
476    }
477}
478
479impl AsRef<str> for EnvidValue {
480    fn as_ref(&self) -> &str {
481        &self.0
482    }
483}
484
485impl fmt::Display for EnvidValue {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        f.write_str(&self.0)
488    }
489}
490
491impl TryFrom<String> for EnvidValue {
492    type Error = ValidationError;
493    fn try_from(s: String) -> Result<Self, ValidationError> {
494        Self::new(s)
495    }
496}
497
498impl TryFrom<&str> for EnvidValue {
499    type Error = ValidationError;
500    fn try_from(s: &str) -> Result<Self, ValidationError> {
501        Self::new(s)
502    }
503}
504
505impl From<EnvidValue> for String {
506    fn from(e: EnvidValue) -> Self {
507        e.into_inner()
508    }
509}
510
511// ============================================================================
512// XtextSafe — RFC 5321 Section 4.1.2
513// ============================================================================
514
515/// A validated printable US-ASCII string for use in SMTP command arguments.
516///
517/// RFC 5321 Section 4.1.2: SMTP `String` arguments must contain only
518/// printable US-ASCII characters (0x20-0x7E), excluding control characters.
519/// Must also be non-empty.
520#[derive(Debug, Clone, PartialEq, Eq, Hash)]
521#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
522#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
523pub struct XtextSafe(String);
524
525impl XtextSafe {
526    /// Consume and return the inner `String`.
527    pub fn into_inner(self) -> String {
528        self.0
529    }
530
531    /// Create a new `XtextSafe` after validating printable ASCII constraints.
532    pub fn new(s: impl Into<String>) -> Result<Self, ValidationError> {
533        let s = s.into();
534        if s.is_empty() {
535            return Err(ValidationError::new(
536                "SMTP query argument must not be empty \
537                 (RFC 5321 Sections 4.1.1.6-4.1.1.7)",
538            ));
539        }
540        for &b in s.as_bytes() {
541            if b < 0x20 || b == 0x7F {
542                return Err(ValidationError::new(format!(
543                    "SMTP argument contains control character (byte 0x{b:02X}); \
544                     only printable US-ASCII is permitted (RFC 5321 Section 4.1.2)"
545                )));
546            }
547            if b > 0x7F {
548                return Err(ValidationError::new(
549                    "SMTP argument contains non-ASCII characters; \
550                     only printable US-ASCII is permitted (RFC 5321 Section 4.1.2)",
551                ));
552            }
553        }
554        // Note: CR (0x0D) and LF (0x0A) are already rejected by the
555        // `b < 0x20` control-character check above, so no separate
556        // CR/LF guard is needed.
557        Ok(Self(s))
558    }
559
560    /// Return the value as a string slice.
561    pub fn as_str(&self) -> &str {
562        &self.0
563    }
564}
565
566impl AsRef<str> for XtextSafe {
567    fn as_ref(&self) -> &str {
568        &self.0
569    }
570}
571
572impl fmt::Display for XtextSafe {
573    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
574        f.write_str(&self.0)
575    }
576}
577
578impl TryFrom<String> for XtextSafe {
579    type Error = ValidationError;
580    fn try_from(s: String) -> Result<Self, ValidationError> {
581        Self::new(s)
582    }
583}
584
585impl TryFrom<&str> for XtextSafe {
586    type Error = ValidationError;
587    fn try_from(s: &str) -> Result<Self, ValidationError> {
588        Self::new(s)
589    }
590}
591
592impl From<XtextSafe> for String {
593    fn from(x: XtextSafe) -> Self {
594        x.into_inner()
595    }
596}
597
598// ============================================================================
599// Validation helpers (internal)
600// ============================================================================
601
602/// Validate RFC 5321 Section 4.1.2 `Domain` syntax.
603fn validate_domain_syntax(domain: &str) -> Result<(), ValidationError> {
604    if domain.is_empty() {
605        return Err(ValidationError::new(
606            "domain must not be empty (RFC 5321 Section 4.1.2)",
607        ));
608    }
609    // RFC 5321 Section 4.5.3.1.2: domain ≤ 255 octets.
610    if domain.len() > 255 {
611        return Err(ValidationError::new(format!(
612            "domain exceeds 255-octet limit (RFC 5321 Section 4.5.3.1.2): {} octets",
613            domain.len()
614        )));
615    }
616    if domain.starts_with('[') && domain.ends_with(']') {
617        return Err(ValidationError::new(
618            "value is an address-literal, not a domain (RFC 5321 Section 4.1.2)",
619        ));
620    }
621    for label in domain.split('.') {
622        validate_domain_label(label)?;
623    }
624    Ok(())
625}
626
627/// Validate an SMTP mailbox/source-route domain when RFC 6531 SMTPUTF8 is in use.
628///
629/// RFC 6531 Section 3.3 extends RFC 5321 Section 4.1.2 `sub-domain` with
630/// `U-label`, so mailbox domains may contain UTF-8 labels. ASCII labels still
631/// follow the RFC 5321 LDH rules, while non-ASCII labels are accepted
632/// conservatively for SMTPUTF8 paths.
633pub(crate) fn validate_smtputf8_domain_syntax(domain: &str) -> Result<(), ValidationError> {
634    if domain.is_empty() {
635        return Err(ValidationError::new(
636            "domain must not be empty (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)",
637        ));
638    }
639    // RFC 5321 Section 4.5.3.1.2 applies to mailbox domains as well.
640    if domain.len() > 255 {
641        return Err(ValidationError::new(format!(
642            "domain exceeds 255-octet limit (RFC 5321 Section 4.5.3.1.2): {} octets",
643            domain.len()
644        )));
645    }
646    if domain.starts_with('[') && domain.ends_with(']') {
647        return Err(ValidationError::new(
648            "value is an address-literal, not a domain (RFC 5321 Section 4.1.2)",
649        ));
650    }
651
652    for label in domain.split('.') {
653        if label.is_empty() {
654            return Err(ValidationError::new(
655                "domain contains an empty label (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)",
656            ));
657        }
658        if label.is_ascii() {
659            validate_domain_label(label)?;
660            continue;
661        }
662        // RFC 6531 Section 3.3 extends `sub-domain` with `U-label`.
663        // RFC 5321 Section 4.5.3.1.2 / RFC 1035 Section 2.3.4: each label
664        // must be at most 63 octets, regardless of whether it contains
665        // non-ASCII code points.
666        if label.len() > 63 {
667            return Err(ValidationError::new(format!(
668                "domain label exceeds 63-octet limit \
669                 (RFC 5321 Section 4.5.3.1.2 / RFC 6531 Section 3.3): {} octets",
670                label.len()
671            )));
672        }
673        // Preserve the RFC 5321 hyphen placement rule for the ASCII LDH
674        // portion while allowing UTF-8 code points in the label body.
675        if label.starts_with('-') || label.ends_with('-') {
676            return Err(ValidationError::new(
677                "domain labels must begin and end with a letter or digit \
678                 (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)",
679            ));
680        }
681        for ch in label.chars() {
682            if ch.is_ascii() && !(ch.is_ascii_alphanumeric() || ch == '-') {
683                return Err(ValidationError::new(format!(
684                    "domain label contains invalid character {ch:?}; \
685                     only letters, digits, '-', and UTF-8 U-label code points \
686                     are permitted (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
687                )));
688            }
689        }
690    }
691    Ok(())
692}
693
694/// Validate a single DNS label per RFC 5321 Section 4.1.2 / RFC 1035 Section 2.3.4.
695fn validate_domain_label(label: &str) -> Result<(), ValidationError> {
696    if label.is_empty() {
697        return Err(ValidationError::new(
698            "domain contains an empty label (RFC 5321 Section 4.1.2)",
699        ));
700    }
701    // RFC 1035 Section 2.3.4: labels are at most 63 octets.
702    if label.len() > 63 {
703        return Err(ValidationError::new(format!(
704            "domain label exceeds 63-octet limit \
705             (RFC 1035 Section 2.3.4 / RFC 5321 Section 4.1.2): {} octets",
706            label.len()
707        )));
708    }
709    let bytes = label.as_bytes();
710    // RFC 5321 Section 4.1.2: Let-dig = ALPHA / DIGIT
711    if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
712        return Err(ValidationError::new(
713            "domain labels must begin and end with a letter or digit \
714             (RFC 5321 Section 4.1.2)",
715        ));
716    }
717    // RFC 5321 Section 4.1.2: Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig
718    for &b in bytes {
719        if !(b.is_ascii_alphanumeric() || b == b'-') {
720            return Err(ValidationError::new(format!(
721                "domain label contains invalid character {:?}; \
722                 only letters, digits, and '-' are permitted (RFC 5321 Section 4.1.2)",
723                b as char
724            )));
725        }
726    }
727    Ok(())
728}
729
730/// Validate RFC 5321 Section 4.1.3 `address-literal` syntax.
731fn validate_address_literal_syntax(literal: &str) -> Result<(), ValidationError> {
732    // RFC 5321 Section 4.5.3.1.2: ≤ 255 octets.
733    if literal.len() > 255 {
734        return Err(ValidationError::new(format!(
735            "address-literal exceeds 255-octet limit \
736             (RFC 5321 Section 4.5.3.1.2): {} octets",
737            literal.len()
738        )));
739    }
740
741    let Some(body) = literal.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else {
742        return Err(ValidationError::new(
743            "address-literal must be enclosed in '[' and ']' (RFC 5321 Section 4.1.3)",
744        ));
745    };
746
747    if body.is_empty() {
748        return Err(ValidationError::new(
749            "address-literal must not be empty (RFC 5321 Section 4.1.3)",
750        ));
751    }
752
753    // IPv4
754    if body.parse::<Ipv4Addr>().is_ok() {
755        return Ok(());
756    }
757
758    // IPv6
759    if body.len() >= 5 && body[..5].eq_ignore_ascii_case("IPv6:") {
760        return body[5..].parse::<Ipv6Addr>().map(|_| ()).map_err(|_| {
761            ValidationError::new("invalid IPv6 address in address-literal (RFC 5321 Section 4.1.3)")
762        });
763    }
764
765    // General-address-literal = Standardized-tag ":" 1*dcontent
766    let Some((tag, value)) = body.split_once(':') else {
767        return Err(ValidationError::new(
768            "address-literal must be IPv4, IPv6, or generalized tag:value syntax \
769             (RFC 5321 Section 4.1.3)",
770        ));
771    };
772
773    validate_ldh_str(tag)?;
774
775    if value.is_empty() {
776        return Err(ValidationError::new(
777            "generalized address-literal must contain data after ':' \
778             (RFC 5321 Section 4.1.3)",
779        ));
780    }
781    for &b in value.as_bytes() {
782        // RFC 5321 Section 4.1.3: dcontent = %d33-90 / %d94-126.
783        if !((33..=90).contains(&b) || (94..=126).contains(&b)) {
784            return Err(ValidationError::new(format!(
785                "address-literal contains invalid data byte 0x{b:02X} \
786                 (RFC 5321 Section 4.1.3)"
787            )));
788        }
789    }
790    Ok(())
791}
792
793/// Validate an Ldh-str tag per RFC 5321 Section 4.1.3.
794fn validate_ldh_str(tag: &str) -> Result<(), ValidationError> {
795    if tag.is_empty() {
796        return Err(ValidationError::new(
797            "generalized address-literal tag must not be empty (RFC 5321 Section 4.1.3)",
798        ));
799    }
800    let bytes = tag.as_bytes();
801    // RFC 5321 Section 4.1.3: Standardized-tag = Ldh-str, and Ldh-str is
802    // `*( ALPHA / DIGIT / "-" ) Let-dig`, so only the trailing byte is
803    // required to be alphanumeric.
804    if !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
805        return Err(ValidationError::new(
806            "generalized address-literal tag must end with a letter or digit \
807             (RFC 5321 Section 4.1.3: Standardized-tag = Ldh-str)",
808        ));
809    }
810    for &b in bytes {
811        if !(b.is_ascii_alphanumeric() || b == b'-') {
812            return Err(ValidationError::new(format!(
813                "generalized address-literal tag contains invalid character {:?} \
814                 (RFC 5321 Section 4.1.3)",
815                b as char
816            )));
817        }
818    }
819    Ok(())
820}
821
822/// RFC 5321 Section 4.1.2: `Path = "<" [ A-d-l ":" ] Mailbox ">"`.
823///
824/// The obsolete `A-d-l` source route MUST be accepted, SHOULD NOT be
825/// generated, and SHOULD be ignored. This helper validates any source route
826/// that is present and returns the mailbox portion to validate and store.
827fn strip_obsolete_source_route(address: &str) -> Result<&str, ValidationError> {
828    if !address.starts_with('@') {
829        return Ok(address);
830    }
831
832    let Some((route, mailbox)) = address.split_once(':') else {
833        return Err(ValidationError::new(
834            "obsolete source route must end with ':' before the mailbox \
835             (RFC 5321 Section 4.1.2)",
836        ));
837    };
838    if mailbox.is_empty() {
839        return Err(ValidationError::new(
840            "obsolete source route must be followed by a mailbox \
841             (RFC 5321 Section 4.1.2)",
842        ));
843    }
844
845    for at_domain in route.split(',') {
846        let Some(domain) = at_domain.strip_prefix('@') else {
847            return Err(ValidationError::new(
848                "obsolete source route entries must be @domain tokens \
849                 (RFC 5321 Section 4.1.2)",
850            ));
851        };
852        validate_smtputf8_domain_syntax(domain)?;
853    }
854
855    Ok(mailbox)
856}
857
858/// Validate RFC 5321 Section 4.1.2 `Mailbox` syntax.
859fn validate_mailbox_syntax(address: &str) -> Result<(), ValidationError> {
860    // RFC 5321 Section 4.1.2 defines `Path = "<" [ A-d-l ":" ] Mailbox ">"`,
861    // so the 256-octet path limit includes any obsolete source route that is
862    // present on the input.
863    if address.len() > 254 {
864        return Err(ValidationError::new(format!(
865            "mailbox exceeds 256-octet path limit including <> \
866             (RFC 5321 Section 4.5.3.1.3): {} octets",
867            address.len() + 2
868        )));
869    }
870
871    let address = strip_obsolete_source_route(address)?;
872
873    if address != address.trim() {
874        return Err(ValidationError::new(
875            "mailbox must not contain leading or trailing whitespace \
876             (RFC 5321 Section 4.1.2)",
877        ));
878    }
879
880    // Parse as an addr-spec and reject display-name forms.
881    match address.parse::<daaki_message::Address>() {
882        Ok(parsed) if parsed.name.is_none() && parsed.email == address => {}
883        Ok(_) => {
884            return Err(ValidationError::new(
885                "mailbox must be a bare addr-spec, not a name-addr or comment form \
886                 (RFC 5321 Section 4.1.2)",
887            ));
888        }
889        Err(err) => {
890            return Err(ValidationError::new(format!(
891                "mailbox is syntactically invalid: {err} (RFC 5321 Section 4.1.2)"
892            )));
893        }
894    }
895
896    // RFC 5321 Section 4.1.3 defines SMTP address-literals more narrowly than
897    // RFC 5322 header-address domain-literals. The shared message parser is
898    // intentionally liberal for header syntax, so SMTP mailbox validation must
899    // re-check any bracketed domain against RFC 5321's IPv4 / IPv6 /
900    // generalized-literal grammar.
901    if let Some(at_pos) = address.rfind('@') {
902        let local_part = &address[..at_pos];
903        let domain = &address[at_pos + 1..];
904        if local_part.starts_with('"') && local_part.ends_with('"') {
905            validate_smtp_quoted_local_part(local_part)?;
906        }
907        if domain.starts_with('[') && domain.ends_with(']') {
908            validate_address_literal_syntax(domain)?;
909        }
910
911        // RFC 5321 Section 4.5.3.1.1: local-part ≤ 64 octets.
912        if local_part.len() > 64 {
913            return Err(ValidationError::new(format!(
914                "local-part exceeds 64-octet limit \
915                 (RFC 5321 Section 4.5.3.1.1): {} octets",
916                local_part.len()
917            )));
918        }
919    }
920
921    Ok(())
922}
923
924/// Validates an SMTP quoted local-part per RFC 5321 Section 4.1.2.
925///
926/// SMTP quoted local-parts use `Quoted-string = DQUOTE *QcontentSMTP DQUOTE`,
927/// where `QcontentSMTP = qtextSMTP / quoted-pairSMTP`. Unlike RFC 5322 header
928/// quoted-strings, SMTP does not permit HTAB/FWS inside a mailbox local-part.
929/// RFC 6531 Section 3.3 extends `qtextSMTP` with UTF-8 non-ASCII, but it does
930/// not extend `quoted-pairSMTP`, which remains ASCII-only.
931///
932/// # References
933/// - RFC 5321 Section 4.1.2
934/// - RFC 6531 Section 3.3
935fn validate_smtp_quoted_local_part(local_part: &str) -> Result<(), ValidationError> {
936    let Some(inner) = local_part
937        .strip_prefix('"')
938        .and_then(|value| value.strip_suffix('"'))
939    else {
940        return Err(ValidationError::new(
941            "quoted local-part must be enclosed in double quotes \
942             (RFC 5321 Section 4.1.2)",
943        ));
944    };
945
946    let bytes = inner.as_bytes();
947    let mut index = 0;
948    while index < bytes.len() {
949        let byte = bytes[index];
950        if byte == b'\\' {
951            index += 1;
952            if index >= bytes.len() {
953                return Err(ValidationError::new(
954                    "quoted local-part has trailing backslash \
955                     (RFC 5321 Section 4.1.2 quoted-pairSMTP)",
956                ));
957            }
958
959            let escaped = bytes[index];
960            if !(0x20..=0x7E).contains(&escaped) {
961                return Err(ValidationError::new(format!(
962                    "quoted local-part contains invalid escaped byte 0x{escaped:02X}; \
963                     quoted-pairSMTP permits only ASCII space or VCHAR \
964                     (RFC 5321 Section 4.1.2)"
965                )));
966            }
967        } else if byte == b'"' || byte == b'\\' {
968            return Err(ValidationError::new(
969                "quoted local-part contains an unescaped quote or backslash \
970                 (RFC 5321 Section 4.1.2 qtextSMTP)",
971            ));
972        } else if byte == b'\t' || byte < 0x20 || byte == 0x7F {
973            return Err(ValidationError::new(format!(
974                "quoted local-part contains control character 0x{byte:02X}; \
975                 qtextSMTP permits ASCII space/graphics or UTF-8 non-ASCII only \
976                 (RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
977            )));
978        }
979        index += 1;
980    }
981
982    Ok(())
983}
984
985#[cfg(test)]
986#[path = "validated_tests.rs"]
987mod tests;