rfc5321/
parser.rs

1use crate::client_types::SmtpClientTimeouts;
2#[cfg(test)]
3use bstr::BStr;
4use bstr::{BString, ByteSlice};
5#[cfg(feature = "lua")]
6use mlua::{FromLua, MetaMethod, UserData, UserDataFields, UserDataMethods};
7use nom::branch::alt;
8use nom::bytes::complete::{take_while1, take_while_m_n};
9use nom::combinator::{all_consuming, map, map_res, opt, recognize};
10use nom::error::context;
11use nom::multi::{many0, many1};
12use nom::sequence::pair;
13use nom::Parser;
14use nom_utils::{
15    domain_name, explain_nom, ipv4_address, ipv6_address, make_span, tag, tag_no_case,
16    utf8_non_ascii, DomainString, IResult, Span,
17};
18use pastey::paste;
19use std::borrow::Cow;
20use std::hash::{Hash, Hasher};
21use std::net::{Ipv4Addr, Ipv6Addr};
22use std::time::Duration;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum CommandVerb {
26    Ehlo,
27    Helo,
28    Lhlo,
29    Mail,
30    Rcpt,
31    Data,
32    Rset,
33    Quit,
34    Vrfy,
35    Expn,
36    Help,
37    Noop,
38    StartTls,
39    Auth,
40    XClient,
41    Unknown(BString),
42}
43
44/// Domain part of a mailbox in a MAIL FROM address.
45#[derive(Clone, PartialEq, Eq, Hash)]
46pub enum Domain {
47    /// A valid DNS domain name
48    DomainName(DomainString),
49    /// An IPv4 address literal, e.g. from `[10.0.0.1]`
50    V4(Ipv4Addr),
51    /// An IPv6 address literal, e.g. from `[IPv6:::1]`
52    V6(Ipv6Addr),
53    /// A general/tagged address literal, e.g. from `[future:something]`.
54    /// Stores the original `"tag:literal"` string; split on the first `:`
55    /// when the tag or literal parts are needed individually.
56    Tagged(String),
57}
58
59impl std::fmt::Debug for Domain {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Domain::DomainName(s) => write!(f, "{}", s),
63            Domain::V4(ip) => write!(f, "[{}]", ip),
64            Domain::V6(ip) => write!(f, "[IPv6:{}]", ip),
65            Domain::Tagged(s) => write!(f, "[{}]", s),
66        }
67    }
68}
69
70impl std::fmt::Display for Domain {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Domain::DomainName(s) => write!(f, "{}", s),
74            Domain::V4(ip) => write!(f, "[{}]", ip),
75            Domain::V6(ip) => write!(f, "[IPv6:{}]", ip),
76            Domain::Tagged(s) => write!(f, "[{}]", s),
77        }
78    }
79}
80
81impl Domain {
82    /// Returns true if the wire representation of this domain is pure ASCII.
83    ///
84    /// `DomainName` is always normalized to ASCII punycode on the wire, so it
85    /// is always considered ASCII here.  IP address literals are inherently
86    /// ASCII.  Tagged literals are checked character-by-character.
87    pub fn is_ascii(&self) -> bool {
88        match self {
89            Domain::DomainName(_) | Domain::V4(_) | Domain::V6(_) => true,
90            Domain::Tagged(s) => s.is_ascii(),
91        }
92    }
93}
94
95/// An email mailbox: `local-part "@" domain`
96#[derive(Clone, Debug)]
97pub struct Mailbox {
98    pub(crate) local_part: String,
99    pub domain: Domain,
100}
101
102impl PartialEq for Mailbox {
103    fn eq(&self, other: &Self) -> bool {
104        self.local_part() == other.local_part() && self.domain == other.domain
105    }
106}
107
108impl Eq for Mailbox {}
109
110impl Mailbox {
111    /// Returns true if both the local part and domain are pure ASCII.
112    pub fn is_ascii(&self) -> bool {
113        self.local_part.is_ascii() && self.domain.is_ascii()
114    }
115
116    /// Returns the normalized local part.
117    /// Normalization removes any quoting from the local part,
118    /// so that `"\f\o\o"` and `"foo"` will both be returned
119    /// as `foo` and will compare as equal.
120    pub fn local_part(&self) -> Cow<'_, str> {
121        // Check if the local_part is a quoted string
122        if self.local_part.starts_with('"') {
123            // Quoted string - need to unquote
124            let mut result = String::new();
125            let mut chars = self.local_part.chars();
126            chars.next(); // skip initial quote
127            while let Some(c) = chars.next() {
128                match c {
129                    '\\' => match chars.next() {
130                        Some(c) => {
131                            result.push(c);
132                        }
133                        None => {
134                            result.push('\\');
135                        }
136                    },
137                    '"' => {
138                        // Probably the final closing quote.
139                        // Should be impossible/illegal otherwise
140                        continue;
141                    }
142                    c => {
143                        result.push(c);
144                    }
145                }
146            }
147            Cow::Owned(result)
148        } else {
149            Cow::Borrowed(self.local_part.as_str())
150        }
151    }
152}
153
154#[cfg(test)]
155mod mailbox_tests {
156    use super::*;
157
158    #[test]
159    fn test_mailbox_local_part_normalized() {
160        // Test that local_part() normalizes quoted strings
161        let mb1 = Mailbox {
162            local_part: String::from("foo"),
163            domain: Domain::DomainName("example.com".parse().unwrap()),
164        };
165        let mb2 = Mailbox {
166            local_part: String::from("\"foo\""),
167            domain: Domain::DomainName("example.com".parse().unwrap()),
168        };
169        let mb3 = Mailbox {
170            local_part: String::from("\"f\\oo\""),
171            domain: Domain::DomainName("example.com".parse().unwrap()),
172        };
173
174        // All three should normalize to "foo"
175        k9::assert_equal!(mb1.local_part(), "foo");
176        k9::assert_equal!(mb2.local_part(), "foo");
177        k9::assert_equal!(mb3.local_part(), "foo");
178    }
179
180    #[test]
181    fn test_mailbox_local_part_eq_normalized() {
182        // Test that Mailbox equality uses normalized local_part
183        let mb1 = Mailbox {
184            local_part: String::from("foo"),
185            domain: Domain::DomainName("example.com".parse().unwrap()),
186        };
187        let mb2 = Mailbox {
188            local_part: String::from("\"foo\""),
189            domain: Domain::DomainName("example.com".parse().unwrap()),
190        };
191        let mb3 = Mailbox {
192            local_part: String::from("\"f\\oo\""),
193            domain: Domain::DomainName("example.com".parse().unwrap()),
194        };
195
196        // All three should be equal due to normalized local_part
197        k9::assert_equal!(mb1, mb2);
198        k9::assert_equal!(mb2, mb3);
199        k9::assert_equal!(mb1, mb3);
200    }
201
202    #[test]
203    fn test_mailbox_local_part_unquoted_borrowed() {
204        // Test that unquoted valid UTF-8 returns Cow::Borrowed
205        let mb = Mailbox {
206            local_part: String::from("foo"),
207            domain: Domain::DomainName("example.com".parse().unwrap()),
208        };
209
210        let local_part = mb.local_part();
211        match local_part {
212            Cow::Borrowed(_) => {}
213            Cow::Owned(_) => panic!("Expected Cow::Borrowed for unquoted valid UTF-8"),
214        }
215    }
216
217    #[test]
218    fn test_mailbox_local_part_quoted_unquoted_borrowed() {
219        // Test that quoted valid UTF-8 returns Cow::Owned (needs unquoting)
220        let mb = Mailbox {
221            local_part: String::from("\"foo\""),
222            domain: Domain::DomainName("example.com".parse().unwrap()),
223        };
224
225        let local_part = mb.local_part();
226        match local_part {
227            Cow::Borrowed(_) => panic!("Expected Cow::Owned for quoted string"),
228            Cow::Owned(s) => {
229                k9::assert_equal!(s, "foo");
230            }
231        }
232    }
233}
234
235impl Hash for Mailbox {
236    fn hash<H: Hasher>(&self, state: &mut H) {
237        self.local_part().hash(state);
238        self.domain.hash(state);
239    }
240}
241
242/// A parsed email path: optional source route (at-domain-list) plus mailbox.
243///
244/// Per RFC 5321 §4.1.2, the source route (at-domain-list) MUST be accepted
245/// when parsing, SHOULD NOT be generated when encoding, and SHOULD be ignored.
246#[derive(Clone, PartialEq, Eq, Hash)]
247pub struct MailPath {
248    /// Optional source route: list of domains (without the `@` prefix).
249    pub at_domain_list: Vec<String>,
250    /// The final mailbox (local-part@domain).
251    pub mailbox: Mailbox,
252}
253
254impl std::fmt::Debug for MailPath {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        let mut local_part = Vec::new();
257
258        // Add source route if present
259        if !self.at_domain_list.is_empty() {
260            for (i, domain) in self.at_domain_list.iter().enumerate() {
261                if i > 0 {
262                    local_part.push(b',');
263                }
264                local_part.push(b'@'); // RFC 5321 source route has @ prefix
265                local_part.extend_from_slice(domain.as_bytes());
266            }
267            local_part.push(b':');
268        }
269
270        // Add local-part
271        local_part.extend_from_slice(self.mailbox.local_part.as_bytes());
272
273        // Format as MailPath("local_part@domain") with proper escaping using escape_bytes
274        write!(
275            f,
276            "MailPath(\"{}@{:?}\")",
277            local_part.escape_bytes(),
278            self.mailbox.domain
279        )
280    }
281}
282
283impl std::fmt::Display for MailPath {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        if !self.at_domain_list.is_empty() {
286            for (i, domain) in self.at_domain_list.iter().enumerate() {
287                if i > 0 {
288                    f.write_str(",")?;
289                }
290                write!(f, "@{domain}")?;
291            }
292            f.write_str(":")?;
293        }
294        write!(f, "{}@{}", self.mailbox.local_part, self.mailbox.domain)
295    }
296}
297
298impl MailPath {
299    /// Returns true if the mailbox address is pure ASCII.
300    ///
301    /// The source route (`at_domain_list`) is intentionally ignored, matching
302    /// the old parser behaviour (RFC 5321 says source routes SHOULD be ignored).
303    pub fn is_ascii(&self) -> bool {
304        self.mailbox.is_ascii()
305    }
306}
307
308/// The reverse path (sender) for a MAIL FROM command
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub enum ReversePath {
311    /// A mailbox path
312    Path(MailPath),
313    /// Null sender: `MAIL FROM:<>`
314    NullSender,
315}
316
317impl ReversePath {
318    /// Returns true if the address is pure ASCII.
319    pub fn is_ascii(&self) -> bool {
320        match self {
321            Self::NullSender => true,
322            Self::Path(p) => p.is_ascii(),
323        }
324    }
325}
326
327impl TryFrom<&str> for ReversePath {
328    type Error = anyhow::Error;
329    fn try_from(s: &str) -> Result<Self, Self::Error> {
330        EnvelopeAddress::parse(s)?
331            .try_into()
332            .map_err(|e: &'static str| anyhow::anyhow!(e))
333    }
334}
335
336impl std::fmt::Display for ReversePath {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        match self {
339            Self::NullSender => Ok(()),
340            Self::Path(p) => p.fmt(f),
341        }
342    }
343}
344
345/// The forward path (recipient) for a RCPT TO command
346#[derive(Debug, Clone, PartialEq, Eq, Hash)]
347pub enum ForwardPath {
348    /// A mailbox path
349    Path(MailPath),
350    /// Postmaster: `RCPT TO:<Postmaster>`  (RFC 5321 §4.1.1.3)
351    Postmaster,
352}
353
354impl ForwardPath {
355    /// Returns true if the address is pure ASCII.
356    pub fn is_ascii(&self) -> bool {
357        match self {
358            Self::Postmaster => true,
359            Self::Path(p) => p.is_ascii(),
360        }
361    }
362}
363
364impl TryFrom<&str> for ForwardPath {
365    type Error = anyhow::Error;
366    fn try_from(s: &str) -> Result<Self, Self::Error> {
367        EnvelopeAddress::parse(s)?
368            .try_into()
369            .map_err(|e: &'static str| anyhow::anyhow!(e))
370    }
371}
372
373impl std::fmt::Display for ForwardPath {
374    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375        match self {
376            Self::Postmaster => write!(f, "Postmaster"),
377            Self::Path(p) => p.fmt(f),
378        }
379    }
380}
381
382/// An envelope address: either a path, null sender, or postmaster.
383#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
384#[serde(try_from = "String", into = "String")]
385pub enum EnvelopeAddress {
386    /// Null sender: `<>`
387    Null,
388    /// Postmaster: `<Postmaster>` or `Postmaster`
389    Postmaster,
390    /// A path: `<path>` or bare path
391    Path(MailPath),
392}
393
394impl std::fmt::Display for EnvelopeAddress {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            EnvelopeAddress::Null => write!(f, ""),
398            EnvelopeAddress::Postmaster => write!(f, "Postmaster"),
399            // Drop the source route (at-domain-list) per RFC 5321 §4.4:
400            // "SHOULD NOT copy source route (at-domain-list)"
401            EnvelopeAddress::Path(path) => {
402                write!(f, "{}@{}", path.mailbox.local_part, path.mailbox.domain)
403            }
404        }
405    }
406}
407
408impl std::fmt::Debug for EnvelopeAddress {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        write!(f, "<{}>", self)
411    }
412}
413
414impl From<MailPath> for EnvelopeAddress {
415    fn from(path: MailPath) -> Self {
416        EnvelopeAddress::Path(path)
417    }
418}
419
420impl TryFrom<String> for EnvelopeAddress {
421    type Error = anyhow::Error;
422
423    fn try_from(s: String) -> Result<Self, Self::Error> {
424        EnvelopeAddress::parse(&s)
425    }
426}
427
428impl From<EnvelopeAddress> for String {
429    fn from(addr: EnvelopeAddress) -> Self {
430        addr.to_string()
431    }
432}
433
434impl std::str::FromStr for EnvelopeAddress {
435    type Err = String;
436    fn from_str(s: &str) -> Result<Self, Self::Err> {
437        EnvelopeAddress::parse_impl(s)
438    }
439}
440
441impl From<ForwardPath> for EnvelopeAddress {
442    fn from(fp: ForwardPath) -> Self {
443        match fp {
444            ForwardPath::Postmaster => EnvelopeAddress::Postmaster,
445            ForwardPath::Path(p) => EnvelopeAddress::Path(p),
446        }
447    }
448}
449
450impl TryFrom<ReversePath> for EnvelopeAddress {
451    type Error = &'static str;
452    fn try_from(rp: ReversePath) -> Result<Self, Self::Error> {
453        match rp {
454            ReversePath::NullSender => Ok(EnvelopeAddress::Null),
455            ReversePath::Path(p) => Ok(EnvelopeAddress::Path(p)),
456        }
457    }
458}
459
460impl EnvelopeAddress {
461    /// Parse an envelope address from a string.
462    ///
463    /// Accepts either forward or reverse path syntax, with or without angle brackets.
464    /// Internal parsing function that returns String error type.
465    /// This is used by FromStr (which requires String) and by parse().
466    fn parse_impl(input: &str) -> Result<EnvelopeAddress, String> {
467        // Handle empty string as null sender.  The Display impl for
468        // EnvelopeAddress::Null produces "", so we must be able to
469        // round-trip it through parse().  An empty string is NOT a
470        // valid mailbox, but it is a valid representation of the
471        // null reverse path (NDRs per RFC 5321 §4.5.5).
472        if input.is_empty() {
473            return Ok(EnvelopeAddress::Null);
474        }
475
476        let input = make_span(input.as_bytes());
477        let (_, result) = all_consuming(alt((
478            map(tag_no_case("<>"), |_| EnvelopeAddress::Null),
479            map(tag_no_case("<Postmaster>"), |_| EnvelopeAddress::Postmaster),
480            map(path, EnvelopeAddress::Path),
481            map(mailbox, EnvelopeAddress::from),
482            map(tag_no_case("Postmaster"), |_| EnvelopeAddress::Postmaster),
483        )))
484        .parse(input)
485        .map_err(|e| explain_nom(input, e))?;
486        Ok(result)
487    }
488
489    /// Parse an envelope address from a string.
490    ///
491    /// Accepts either forward or reverse path syntax, with or without angle brackets.
492    /// Returns anyhow::Result for easier error handling with anyhow-based code.
493    pub fn parse(input: &str) -> anyhow::Result<Self> {
494        EnvelopeAddress::parse_impl(input).map_err(|e| anyhow::anyhow!(e))
495    }
496
497    /// Returns the local-part (user) portion of the address.
498    /// Returns "postmaster" for Postmaster, empty string for Null.
499    pub fn user(&self) -> String {
500        match self {
501            EnvelopeAddress::Postmaster => "postmaster".to_string(),
502            EnvelopeAddress::Null => "".to_string(),
503            EnvelopeAddress::Path(path) => path.mailbox.local_part().into(),
504        }
505    }
506
507    /// Returns the domain portion of the address.
508    /// Returns empty string for Postmaster and Null.
509    pub fn domain(&self) -> String {
510        match self {
511            EnvelopeAddress::Postmaster | EnvelopeAddress::Null => "".to_string(),
512            EnvelopeAddress::Path(path) => path.mailbox.domain.to_string(),
513        }
514    }
515
516    /// Returns the null sender address: `EnvelopeAddress::Null`
517    pub fn null_sender() -> Self {
518        EnvelopeAddress::Null
519    }
520}
521
522#[cfg(feature = "lua")]
523impl FromLua for EnvelopeAddress {
524    fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
525        match value {
526            mlua::Value::String(s) => s
527                .to_str()?
528                .parse::<EnvelopeAddress>()
529                .map_err(|e: String| mlua::Error::RuntimeError(e)),
530            _ => {
531                let ud = mlua::UserDataRef::<EnvelopeAddress>::from_lua(value, lua)?;
532                Ok(ud.clone())
533            }
534        }
535    }
536}
537
538#[cfg(feature = "lua")]
539impl UserData for EnvelopeAddress {
540    fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
541        fields.add_field_method_get("user", |_, this| Ok(this.user()));
542        fields.add_field_method_get("domain", |_, this| Ok(this.domain()));
543        fields.add_field_method_get("email", |_, this| Ok(this.to_string()));
544    }
545
546    fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
547        methods.add_meta_method(MetaMethod::ToString, |_, this, _: ()| Ok(this.to_string()));
548    }
549}
550
551impl From<MailPath> for ReversePath {
552    fn from(path: MailPath) -> Self {
553        ReversePath::Path(path)
554    }
555}
556
557impl From<MailPath> for ForwardPath {
558    fn from(path: MailPath) -> Self {
559        ForwardPath::Path(path)
560    }
561}
562
563impl TryFrom<ReversePath> for MailPath {
564    type Error = &'static str;
565
566    fn try_from(path: ReversePath) -> Result<Self, Self::Error> {
567        match path {
568            ReversePath::Path(mailpath) => Ok(mailpath),
569            ReversePath::NullSender => Err("Cannot convert NullSender to MailPath"),
570        }
571    }
572}
573
574impl TryFrom<ForwardPath> for MailPath {
575    type Error = &'static str;
576
577    fn try_from(path: ForwardPath) -> Result<Self, Self::Error> {
578        match path {
579            ForwardPath::Path(mailpath) => Ok(mailpath),
580            ForwardPath::Postmaster => Err("Cannot convert Postmaster to MailPath"),
581        }
582    }
583}
584
585// ============================================================================
586// Conversions from Mailbox
587// ============================================================================
588
589/// Infallible conversion: wraps a Mailbox in a MailPath with no source route.
590impl From<Mailbox> for MailPath {
591    fn from(mailbox: Mailbox) -> Self {
592        MailPath {
593            at_domain_list: vec![],
594            mailbox,
595        }
596    }
597}
598
599/// Infallible conversion: wraps a Mailbox in an EnvelopeAddress::Path.
600impl From<Mailbox> for EnvelopeAddress {
601    fn from(mailbox: Mailbox) -> Self {
602        EnvelopeAddress::Path(MailPath {
603            at_domain_list: vec![],
604            mailbox,
605        })
606    }
607}
608
609/// Infallible conversion: wraps a Mailbox in a ReversePath::Path.
610impl From<Mailbox> for ReversePath {
611    fn from(mailbox: Mailbox) -> Self {
612        ReversePath::Path(MailPath {
613            at_domain_list: vec![],
614            mailbox,
615        })
616    }
617}
618
619/// Infallible conversion: wraps a Mailbox in a ForwardPath::Path.
620impl From<Mailbox> for ForwardPath {
621    fn from(mailbox: Mailbox) -> Self {
622        ForwardPath::Path(MailPath {
623            at_domain_list: vec![],
624            mailbox,
625        })
626    }
627}
628
629// ============================================================================
630// Fallible conversions to Mailbox
631// ============================================================================
632
633impl TryFrom<EnvelopeAddress> for Mailbox {
634    type Error = &'static str;
635
636    fn try_from(addr: EnvelopeAddress) -> Result<Self, Self::Error> {
637        match addr {
638            EnvelopeAddress::Path(path) => Ok(path.mailbox),
639            EnvelopeAddress::Null => Err("Cannot convert Null to Mailbox"),
640            EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to Mailbox"),
641        }
642    }
643}
644
645impl TryFrom<ReversePath> for Mailbox {
646    type Error = &'static str;
647
648    fn try_from(path: ReversePath) -> Result<Self, Self::Error> {
649        match path {
650            ReversePath::Path(path) => Ok(path.mailbox),
651            ReversePath::NullSender => Err("Cannot convert NullSender to Mailbox"),
652        }
653    }
654}
655
656impl TryFrom<ForwardPath> for Mailbox {
657    type Error = &'static str;
658
659    fn try_from(path: ForwardPath) -> Result<Self, Self::Error> {
660        match path {
661            ForwardPath::Path(path) => Ok(path.mailbox),
662            ForwardPath::Postmaster => Err("Cannot convert Postmaster to Mailbox"),
663        }
664    }
665}
666
667// ============================================================================
668// Fallible conversions to MailPath
669// ============================================================================
670
671impl TryFrom<EnvelopeAddress> for MailPath {
672    type Error = &'static str;
673
674    fn try_from(addr: EnvelopeAddress) -> Result<Self, Self::Error> {
675        match addr {
676            EnvelopeAddress::Path(path) => Ok(path),
677            EnvelopeAddress::Null => Err("Cannot convert Null to MailPath"),
678            EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to MailPath"),
679        }
680    }
681}
682
683impl TryFrom<ReversePath> for ForwardPath {
684    type Error = &'static str;
685
686    fn try_from(path: ReversePath) -> Result<Self, Self::Error> {
687        match path {
688            ReversePath::Path(mailpath) => Ok(ForwardPath::Path(mailpath)),
689            ReversePath::NullSender => Err("Cannot convert NullSender to ForwardPath"),
690        }
691    }
692}
693
694impl TryFrom<ForwardPath> for ReversePath {
695    type Error = &'static str;
696
697    fn try_from(path: ForwardPath) -> Result<Self, Self::Error> {
698        match path {
699            ForwardPath::Path(mailpath) => Ok(ReversePath::Path(mailpath)),
700            ForwardPath::Postmaster => Err("Cannot convert Postmaster to ReversePath"),
701        }
702    }
703}
704
705impl TryFrom<EnvelopeAddress> for ReversePath {
706    type Error = &'static str;
707
708    fn try_from(addr: EnvelopeAddress) -> Result<Self, Self::Error> {
709        match addr {
710            EnvelopeAddress::Path(path) => Ok(ReversePath::Path(path)),
711            EnvelopeAddress::Null => Ok(ReversePath::NullSender),
712            EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to ReversePath"),
713        }
714    }
715}
716
717impl TryFrom<EnvelopeAddress> for ForwardPath {
718    type Error = &'static str;
719
720    fn try_from(addr: EnvelopeAddress) -> Result<Self, Self::Error> {
721        match addr {
722            EnvelopeAddress::Path(path) => Ok(ForwardPath::Path(path)),
723            EnvelopeAddress::Null => Err("Cannot convert Null to ForwardPath"),
724            EnvelopeAddress::Postmaster => Ok(ForwardPath::Postmaster),
725        }
726    }
727}
728
729/// An ESMTP parameter: `name["=" value]`
730#[derive(Clone, Debug, PartialEq, Eq)]
731pub struct EsmtpParameter {
732    pub name: String,
733    pub value: Option<String>,
734}
735
736/// A single XCLIENT parameter: `name=xtext-value`.
737/// The `value` field stores the **xtext-decoded** string; the wire form
738/// uses xtext encoding where non-printable bytes appear as `+XX` hex pairs.
739#[derive(Debug, Clone, PartialEq, Eq)]
740pub struct XClientParameter {
741    pub name: String,
742    pub value: String,
743}
744
745impl XClientParameter {
746    /// Returns true if the parameter name matches the given name (case-insensitive).
747    pub fn is_name(&self, name: impl AsRef<str>) -> bool {
748        self.name.eq_ignore_ascii_case(name.as_ref())
749    }
750
751    /// Parse the parameter value as type T.
752    ///
753    /// Converts the value to a string and then parses it as T.
754    pub fn parse<T>(&self) -> Result<T, String>
755    where
756        T: std::str::FromStr,
757        T::Err: std::fmt::Display,
758    {
759        let parsed: Result<T, T::Err> = self.value.parse();
760        parsed.map_err(|e| e.to_string())
761    }
762}
763
764#[derive(Clone, PartialEq, Eq)]
765pub enum Command {
766    Ehlo(Domain),
767    Helo(Domain),
768    Lhlo(Domain),
769    Noop(Option<String>),
770    Help(Option<String>),
771    Vrfy(Option<String>),
772    Expn(Option<String>),
773    Data,
774    /// The end-of-data terminator sent after the message body: `".\r\n"`.
775    ///
776    /// This variant is never produced by the parser — it is constructed
777    /// programmatically by SMTP client code and serialized via
778    /// [`Command::encode`] when the client needs to signal the end of the
779    /// DATA content stream (RFC 5321 §4.5.2).
780    DataDot,
781    Rset,
782    Quit,
783    StartTls,
784    MailFrom {
785        address: ReversePath,
786        parameters: Vec<EsmtpParameter>,
787    },
788    RcptTo {
789        address: ForwardPath,
790        parameters: Vec<EsmtpParameter>,
791    },
792    Auth {
793        sasl_mech: String,
794        initial_response: Option<String>,
795    },
796    XClient(Vec<XClientParameter>),
797    Unknown(BString),
798}
799
800/// Placeholder substituted for a secret when a command is rendered for logs
801/// or diagnostics rather than the wire.
802pub const REDACTED: &str = "[REDACTED]";
803
804/// Render an `AUTH` command line with its SASL initial-response
805/// masked as [`REDACTED`].  When `has_initial_response` is false,
806/// the returned line is just the verb and mechanism, matching the wire form
807/// for a command with no initial-response.
808pub fn redacted_auth_command(sasl_mech: &str, has_initial_response: bool) -> String {
809    if has_initial_response {
810        format!("AUTH {sasl_mech} {REDACTED}")
811    } else {
812        format!("AUTH {sasl_mech}")
813    }
814}
815
816impl std::fmt::Debug for Command {
817    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818        // Route through encode_for_logging, not encode: the Debug of an AUTH
819        // command must not expose the credential in its SASL initial-response.
820        let encoded = self.encode_for_logging();
821        write!(f, "Command(\"{}\")", encoded.escape_bytes())
822    }
823}
824
825/// Reason why a command was only partially parsed.
826#[derive(Clone, Debug, PartialEq, Eq)]
827pub enum PartialReason {
828    /// General command syntax error — verb unrecognised, missing prefix, etc.
829    Syntax,
830    /// "MAIL FROM:" prefix matched but the sender address failed to parse.
831    /// The SMTP server should respond with 501 5.1.7.
832    InvalidSenderAddress,
833    /// "RCPT TO:" prefix matched but the recipient address failed to parse.
834    /// The SMTP server should respond with 501 5.1.3.
835    InvalidRecipientAddress,
836}
837
838#[derive(Clone, PartialEq, Eq)]
839pub enum MaybePartialCommand {
840    Full(Command),
841    Partial {
842        verb: CommandVerb,
843        remainder: BString,
844        reason: PartialReason,
845    },
846}
847
848impl std::fmt::Debug for MaybePartialCommand {
849    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
850        match self {
851            MaybePartialCommand::Full(cmd) => write!(f, "Full({cmd:?})"),
852            MaybePartialCommand::Partial {
853                verb,
854                remainder,
855                reason,
856            } => {
857                write!(
858                    f,
859                    "Partial {{ verb: {verb:?}, remainder: {:?}, reason: {reason:?} }}",
860                    remainder.escape_bytes()
861                )
862            }
863        }
864    }
865}
866
867macro_rules! parse_single {
868    ($func_name:ident, $token:literal, $verb:ident) => {
869        fn $func_name(input: Span) -> IResult<Span, MaybePartialCommand> {
870            context(
871                $token,
872                alt((
873                    map(
874                        all_consuming((tag_no_case($token), wsp, anything)),
875                        |(_cmd, _space, remainder)| MaybePartialCommand::Partial {
876                            verb: CommandVerb::$verb,
877                            remainder: (*remainder).into(),
878                            reason: PartialReason::Syntax,
879                        },
880                    ),
881                    map(all_consuming(tag_no_case($token)), |_| {
882                        MaybePartialCommand::Full(Command::$verb)
883                    }),
884                )),
885            )
886            .parse(input)
887        }
888
889        paste! {
890            #[cfg(test)]
891            #[test]
892            fn [<test_ $func_name>]() {
893                k9::assert_equal!(
894                    unwrapper(Command::parse($token)),
895                    MaybePartialCommand::Full(Command::$verb)
896                );
897                k9::assert_equal!(
898                    unwrapper(Command::parse($token.to_lowercase())),
899                    MaybePartialCommand::Full(Command::$verb)
900                );
901                k9::assert_equal!(
902                    unwrapper(Command::parse(format!("{} trailing garbage", $token))),
903                    MaybePartialCommand::Partial {
904                        verb: CommandVerb::$verb,
905                        remainder:"trailing garbage".into(),
906                        reason: PartialReason::Syntax,
907                    }
908                );
909            }
910        }
911    };
912}
913
914macro_rules! parse_opt_arg {
915    ($func_name:ident, $token:literal, $verb:ident) => {
916        fn $func_name(input: Span) -> IResult<Span, MaybePartialCommand> {
917            context(
918                $token,
919                alt((
920                    map(
921                        all_consuming((tag_no_case($token), wsp, string)),
922                        |(_cmd, _space, param)| match String::from_utf8(param.fragment().to_vec()) {
923                            Ok(s) => MaybePartialCommand::Full(Command::$verb(Some(s))),
924                            Err(_) => MaybePartialCommand::Partial {
925                                verb: CommandVerb::$verb,
926                                remainder: BString::default(),
927                                reason: PartialReason::Syntax,
928                            },
929                        },
930                    ),
931                    map(
932                        all_consuming((tag_no_case($token), wsp, anything)),
933                        |(_cmd, _space, remainder)| MaybePartialCommand::Partial {
934                            verb: CommandVerb::$verb,
935                            remainder: (*remainder).into(),
936                            reason: PartialReason::Syntax,
937                        },
938                    ),
939                    map(all_consuming(tag_no_case($token)), |_| {
940                        MaybePartialCommand::Full(Command::$verb(None))
941                    }),
942                )),
943            )
944            .parse(input)
945        }
946
947        paste! {
948            #[cfg(test)]
949            #[test]
950            fn [<test_ $func_name>]() {
951                k9::assert_equal!(
952                    unwrapper(Command::parse($token)),
953                    MaybePartialCommand::Full(Command::$verb(None)),
954                    "full no param"
955                );
956                k9::assert_equal!(
957                    unwrapper(Command::parse($token.to_lowercase())),
958                    MaybePartialCommand::Full(Command::$verb(None)),
959                    "full no param, different case"
960                );
961                k9::assert_equal!(
962                    unwrapper(Command::parse(format!("{} parameter", $token))),
963                    MaybePartialCommand::Full(Command::$verb(Some("parameter".into()))),
964                    "full with param"
965                );
966                k9::assert_equal!(
967                    unwrapper(Command::parse(format!("{} trailing garbage", $token))),
968                    MaybePartialCommand::Partial {
969                        verb: CommandVerb::$verb,
970                        remainder:"trailing garbage".into(),
971                        reason: PartialReason::Syntax,
972                    },
973                    "should have partial"
974                );
975            }
976        }
977    };
978}
979
980/// Helper that does an unwrap, but rather than print the error string
981/// with escapes, uses its Display impl. This makes it easier to see
982/// what the error message is, because the error in this context is
983/// typically a nom_utils error which is multi-line and uses a caret
984/// to point to the appropriate column in the input.
985#[cfg(test)]
986fn unwrapper<T, E: std::fmt::Display>(result: Result<T, E>) -> T {
987    match result {
988        Ok(r) => r,
989        Err(err) => panic!("{err}"),
990    }
991}
992
993parse_opt_arg!(parse_noop, "NOOP", Noop);
994parse_opt_arg!(parse_help, "HELP", Help);
995parse_opt_arg!(parse_vrfy, "VRFY", Vrfy);
996parse_opt_arg!(parse_expn, "EXPN", Expn);
997parse_single!(parse_data, "DATA", Data);
998parse_single!(parse_rset, "RSET", Rset);
999parse_single!(parse_quit, "QUIT", Quit);
1000parse_single!(parse_starttls, "STARTTLS", StartTls);
1001
1002fn parse_with<'a, R, F>(text: &'a [u8], parser: F) -> Result<R, String>
1003where
1004    F: Fn(Span<'a>) -> IResult<'a, Span<'a>, R>,
1005{
1006    let input = make_span(text);
1007    let (_, result) = all_consuming(parser)
1008        .parse(input)
1009        .map_err(|err| explain_nom(input, err))?;
1010    Ok(result)
1011}
1012
1013impl Command {
1014    pub fn parse(input: impl AsRef<[u8]>) -> Result<MaybePartialCommand, String> {
1015        // Strip a trailing CRLF (or bare LF) so that both wire-format input
1016        // (with CRLF terminator, as produced by encode()) and bare command
1017        // strings (as used in tests and interactive contexts) are accepted.
1018        let bytes = input.as_ref();
1019        let bytes = bytes
1020            .strip_suffix(b"\r\n")
1021            .or_else(|| bytes.strip_suffix(b"\n"))
1022            .unwrap_or(bytes);
1023        parse_with(bytes, Self::parse_span)
1024    }
1025
1026    fn parse_span(input: Span) -> IResult<Span, MaybePartialCommand> {
1027        context(
1028            "command-verb",
1029            alt((
1030                parse_ehlo,
1031                parse_helo,
1032                parse_lhlo,
1033                parse_help,
1034                parse_noop,
1035                parse_vrfy,
1036                parse_expn,
1037                parse_data,
1038                parse_rset,
1039                parse_quit,
1040                parse_starttls,
1041                parse_mail_from,
1042                parse_rcpt_to,
1043                parse_auth,
1044                parse_xclient,
1045                Self::parse_unknown,
1046            )),
1047        )
1048        .parse(input)
1049    }
1050
1051    /// Re-encode the command as a single line of text ready to send on the
1052    /// wire, including the trailing `\r\n`.
1053    ///
1054    /// The returned `BString` can be fed back to [`Command::parse`] to
1055    /// recover the original `Command` value (round-trip stable), with the
1056    /// one intentional exception that `at_domain_list` source routes are not
1057    /// re-emitted (RFC 5321 says they SHOULD NOT be generated).
1058    pub fn encode(&self) -> BString {
1059        let mut buf: Vec<u8> = Vec::new();
1060        match self {
1061            Self::Ehlo(domain) => {
1062                buf.extend_from_slice(b"EHLO ");
1063                buf.extend_from_slice(encode_domain(domain).as_ref());
1064            }
1065            Self::Helo(domain) => {
1066                buf.extend_from_slice(b"HELO ");
1067                buf.extend_from_slice(encode_domain(domain).as_ref());
1068            }
1069            Self::Lhlo(domain) => {
1070                buf.extend_from_slice(b"LHLO ");
1071                buf.extend_from_slice(encode_domain(domain).as_ref());
1072            }
1073            Self::Noop(None) => buf.extend_from_slice(b"NOOP"),
1074            Self::Noop(Some(s)) => {
1075                buf.extend_from_slice(b"NOOP ");
1076                buf.extend_from_slice(s.as_bytes());
1077            }
1078            Self::Help(None) => buf.extend_from_slice(b"HELP"),
1079            Self::Help(Some(s)) => {
1080                buf.extend_from_slice(b"HELP ");
1081                buf.extend_from_slice(s.as_bytes());
1082            }
1083            Self::Vrfy(None) => buf.extend_from_slice(b"VRFY"),
1084            Self::Vrfy(Some(s)) => {
1085                buf.extend_from_slice(b"VRFY ");
1086                buf.extend_from_slice(s.as_bytes());
1087            }
1088            Self::Expn(None) => buf.extend_from_slice(b"EXPN"),
1089            Self::Expn(Some(s)) => {
1090                buf.extend_from_slice(b"EXPN ");
1091                buf.extend_from_slice(s.as_bytes());
1092            }
1093            Self::Data => buf.extend_from_slice(b"DATA"),
1094            // DataDot encodes as exactly ".\r\n" — return before the
1095            // unconditional CRLF that all other arms rely on below.
1096            Self::DataDot => return BString::from(".\r\n"),
1097            Self::Rset => buf.extend_from_slice(b"RSET"),
1098            Self::Quit => buf.extend_from_slice(b"QUIT"),
1099            Self::StartTls => buf.extend_from_slice(b"STARTTLS"),
1100            Self::MailFrom {
1101                address,
1102                parameters,
1103            } => {
1104                buf.extend_from_slice(b"MAIL FROM:<");
1105                buf.extend(encode_reverse_path(address));
1106                buf.push(b'>');
1107                buf.extend(encode_esmtp_params(parameters));
1108            }
1109            Self::RcptTo {
1110                address,
1111                parameters,
1112            } => {
1113                buf.extend_from_slice(b"RCPT TO:<");
1114                buf.extend(encode_forward_path(address));
1115                buf.push(b'>');
1116                buf.extend(encode_esmtp_params(parameters));
1117            }
1118            Self::Auth {
1119                sasl_mech,
1120                initial_response: None,
1121            } => {
1122                buf.extend_from_slice(b"AUTH ");
1123                buf.extend_from_slice(sasl_mech.as_bytes());
1124            }
1125            Self::Auth {
1126                sasl_mech,
1127                initial_response: Some(resp),
1128            } => {
1129                buf.extend_from_slice(b"AUTH ");
1130                buf.extend_from_slice(sasl_mech.as_bytes());
1131                buf.push(b' ');
1132                buf.extend_from_slice(resp.as_bytes());
1133            }
1134            Self::XClient(params) => {
1135                buf.extend_from_slice(b"XCLIENT");
1136                buf.extend(encode_xclient_params(params));
1137            }
1138            Self::Unknown(s) => {
1139                buf.extend_from_slice(s);
1140            }
1141        }
1142        buf.extend_from_slice(b"\r\n");
1143        BString::from(buf)
1144    }
1145
1146    /// Encode the command for a log or diagnostic render,
1147    /// and it will mask AUTH credentials.
1148    pub fn encode_for_logging(&self) -> BString {
1149        match self {
1150            Self::Auth {
1151                sasl_mech,
1152                initial_response,
1153            } => {
1154                let mut line = redacted_auth_command(sasl_mech, initial_response.is_some());
1155                line.push_str("\r\n");
1156                BString::from(line)
1157            }
1158            _ => self.encode(),
1159        }
1160    }
1161
1162    /// Timeout for reading the response to this command.
1163    pub fn client_timeout(&self, timeouts: &SmtpClientTimeouts) -> Duration {
1164        match self {
1165            Self::Helo(_) | Self::Ehlo(_) | Self::Lhlo(_) => timeouts.ehlo_timeout,
1166            Self::MailFrom { .. } => timeouts.mail_from_timeout,
1167            Self::RcptTo { .. } => timeouts.rcpt_to_timeout,
1168            Self::Data => timeouts.data_timeout,
1169            Self::DataDot => timeouts.data_dot_timeout,
1170            Self::Rset => timeouts.rset_timeout,
1171            Self::StartTls => timeouts.starttls_timeout,
1172            Self::Quit | Self::Vrfy(_) | Self::Expn(_) | Self::Help(_) | Self::Noop(_) => {
1173                timeouts.idle_timeout
1174            }
1175            Self::Auth { .. } => timeouts.auth_timeout,
1176            Self::XClient(_) => timeouts.auth_timeout, // FIXME: xclient specific timeout
1177            Self::Unknown(_) => timeouts.mail_from_timeout, // No good option for this TBH.
1178        }
1179    }
1180
1181    /// Timeout for writing the request.
1182    pub fn client_timeout_request(&self, timeouts: &SmtpClientTimeouts) -> Duration {
1183        self.client_timeout(timeouts).min(Duration::from_secs(60))
1184    }
1185
1186    fn parse_unknown(input: Span) -> IResult<Span, MaybePartialCommand> {
1187        context(
1188            "unknown-command",
1189            alt((
1190                map(
1191                    all_consuming(recognize((command_word, wsp, anything))),
1192                    |command| MaybePartialCommand::Full(Command::Unknown((*command).into())),
1193                ),
1194                map(all_consuming(command_word), |command| {
1195                    MaybePartialCommand::Full(Command::Unknown((*command).into()))
1196                }),
1197            )),
1198        )
1199        .parse(input)
1200    }
1201}
1202
1203fn command_word(input: Span) -> IResult<Span, Span> {
1204    context(
1205        "command-word",
1206        take_while1(|c: u8| c.is_ascii_alphanumeric()),
1207    )
1208    .parse(input)
1209}
1210
1211fn wsp(input: Span) -> IResult<Span, Span> {
1212    context("wsp", take_while1(|c| c == b' ' || c == b'\t')).parse(input)
1213}
1214
1215fn anything(input: Span) -> IResult<Span, Span> {
1216    context("anything", take_while1(|_| true)).parse(input)
1217}
1218
1219fn atext(input: Span) -> IResult<Span, Span> {
1220    recognize(alt((
1221        take_while_m_n(1, 1, |c: u8| {
1222            matches!(
1223                c,
1224                b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'/' | b'=' | b'?'
1225                    | b'^' | b'_' | b'`' | b'{' | b'|' | b'}' | b'~'
1226                    | b'A'..=b'Z'
1227                    | b'a'..=b'z'
1228                    | b'0'..=b'9'
1229            )
1230        }),
1231        utf8_non_ascii,
1232    )))
1233    .parse(input)
1234}
1235
1236fn atom(input: Span) -> IResult<Span, Span> {
1237    context("atom", recognize(many1(atext))).parse(input)
1238}
1239
1240fn quoted_string(input: Span) -> IResult<Span, Span> {
1241    context(
1242        "quoted-string",
1243        recognize((
1244            tag("\""),
1245            many0(alt((
1246                recognize(pair(
1247                    tag("\\"),
1248                    take_while_m_n(1, 1, |c: u8| c >= 0x20 && c <= 0x7e),
1249                )),
1250                take_while_m_n(1, 1, |c: u8| {
1251                    (c >= 0x20 && c <= 0x21) || (c >= 0x23 && c <= 0x5b) || (c >= 0x5d && c <= 0x7e)
1252                }),
1253                utf8_non_ascii,
1254            ))),
1255            tag("\""),
1256        )),
1257    )
1258    .parse(input)
1259}
1260
1261fn string(input: Span) -> IResult<Span, Span> {
1262    context("string", alt((atom, quoted_string))).parse(input)
1263}
1264
1265// ---------------------------------------------------------------------------
1266// MAIL FROM helpers
1267// ---------------------------------------------------------------------------
1268
1269/// `dot-string = atom *("." atom)`
1270fn dot_string(input: Span) -> IResult<Span, Span> {
1271    context("dot-string", recognize((atom, many0(pair(tag("."), atom))))).parse(input)
1272}
1273
1274/// `local-part = dot-string / quoted-string`
1275fn local_part(input: Span) -> IResult<Span, Span> {
1276    context("local-part", alt((dot_string, quoted_string))).parse(input)
1277}
1278
1279/// `dcontent = %d33-90 / %d94-126`  — printable US-ASCII excluding `[`, `\`, `]`
1280fn dcontent(input: Span) -> IResult<Span, Span> {
1281    take_while1(|c: u8| (c >= 33 && c <= 90) || (c >= 94 && c <= 126)).parse(input)
1282}
1283
1284/// The content inside an address literal `[...]`.
1285///
1286/// The IPv6 prefix is checked first so that `[IPv6:bad]` produces a
1287/// recoverable parse error rather than falling through to the general
1288/// address-literal branch (which would silently accept `IPv6` as a tag).
1289fn address_literal_content(input: Span) -> IResult<Span, Domain> {
1290    let is_ipv6 = input
1291        .fragment()
1292        .get(..5)
1293        .map(|b| b.eq_ignore_ascii_case(b"IPv6:"))
1294        .unwrap_or(false);
1295
1296    if is_ipv6 {
1297        // Strictly parse as IPv6; no fallthrough to general literal on failure
1298        context(
1299            "ipv6-address-literal",
1300            map((tag_no_case("IPv6:"), ipv6_address), |(_, ip)| {
1301                Domain::V6(ip)
1302            }),
1303        )
1304        .parse(input)
1305    } else {
1306        alt((
1307            map(ipv4_address, Domain::V4),
1308            map_res(
1309                (
1310                    recognize(take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-')),
1311                    tag(":"),
1312                    recognize(many1(dcontent)),
1313                ),
1314                |(tag_s, _colon, lit): (Span, _, Span)| -> Result<Domain, String> {
1315                    // Store the original "tag:literal" string as-is
1316                    let mut s = String::from_utf8(tag_s.fragment().to_vec())
1317                        .map_err(|_| "address_literal: invalid UTF-8 in tag".to_string())?;
1318                    s.push(':');
1319                    let lit_str = std::str::from_utf8(lit.fragment())
1320                        .map_err(|_| "address_literal: invalid UTF-8 in literal".to_string())?;
1321                    s.push_str(lit_str);
1322                    Ok(Domain::Tagged(s))
1323                },
1324            ),
1325        ))
1326        .parse(input)
1327    }
1328}
1329
1330/// `address-literal = "[" ( IPv4 / "IPv6:" IPv6 / tag ":" dcontent ) "]"`
1331fn address_literal(input: Span) -> IResult<Span, Domain> {
1332    context(
1333        "address-literal",
1334        map((tag("["), address_literal_content, tag("]")), |(_, d, _)| d),
1335    )
1336    .parse(input)
1337}
1338
1339/// `mailbox-domain = address-literal / domain-name`
1340fn mailbox_domain(input: Span) -> IResult<Span, Domain> {
1341    context(
1342        "mailbox-domain",
1343        alt((address_literal, map(domain_name, Domain::DomainName))),
1344    )
1345    .parse(input)
1346}
1347
1348/// `mailbox = local-part "@" mailbox-domain`
1349fn mailbox(input: Span) -> IResult<Span, Mailbox> {
1350    context(
1351        "mailbox",
1352        map_res(
1353            (local_part, tag("@"), mailbox_domain),
1354            |(lp, _, dom): (Span, _, Domain)| -> Result<Mailbox, String> {
1355                // Convert the local_part bytes to a String
1356                // If the bytes are not valid UTF-8, return an error
1357                let local_part = String::from_utf8(lp.fragment().to_vec())
1358                    .map_err(|_| "invalid UTF-8 in local-part".to_string())?;
1359                Ok(Mailbox {
1360                    local_part,
1361                    domain: dom,
1362                })
1363            },
1364        ),
1365    )
1366    .parse(input)
1367}
1368
1369/// `at-domain = "@" domain-name`  — returns the domain string (without the `@`)
1370fn at_domain(input: Span) -> IResult<Span, String> {
1371    map_res(
1372        (tag("@"), recognize(domain_name)),
1373        |(_, d): (Span, Span)| -> Result<String, String> {
1374            String::from_utf8(d.fragment().to_vec())
1375                .map_err(|_| "at_domain: invalid UTF-8 in domain".to_string())
1376        },
1377    )
1378    .parse(input)
1379}
1380
1381/// `at-domain-list = at-domain *("," at-domain) ":"`
1382fn at_domain_list(input: Span) -> IResult<Span, Vec<String>> {
1383    context(
1384        "at-domain-list",
1385        map(
1386            (at_domain, many0((tag(","), at_domain)), tag(":")),
1387            |(first, rest, _)| {
1388                let mut v = vec![first];
1389                v.extend(rest.into_iter().map(|(_, d)| d));
1390                v
1391            },
1392        ),
1393    )
1394    .parse(input)
1395}
1396
1397/// `null-sender = "<>"`
1398fn null_sender(input: Span) -> IResult<Span, ReversePath> {
1399    context("null-sender", map(tag("<>"), |_| ReversePath::NullSender)).parse(input)
1400}
1401
1402/// `path = "<" [ at-domain-list ] mailbox ">"`
1403///
1404/// This is the core grammar element shared by both reverse-path and
1405/// forward-path. It parses the content between angle brackets and returns
1406/// a MailPath (optional source route + mailbox).
1407fn path(input: Span) -> IResult<Span, MailPath> {
1408    context(
1409        "path",
1410        map(
1411            (tag("<"), opt(at_domain_list), mailbox, tag(">")),
1412            |(_, domains, mb, _)| MailPath {
1413                at_domain_list: domains.unwrap_or_default(),
1414                mailbox: mb,
1415            },
1416        ),
1417    )
1418    .parse(input)
1419}
1420
1421/// `reverse-path = null-sender / path / bare-mailbox`
1422///
1423/// Null sender is tried first so that `<>` is not consumed as the opening
1424/// `<` of a path.  Bare mailbox (no angle brackets) is accepted as a
1425/// leniency for non-conforming senders.
1426fn reverse_path(input: Span) -> IResult<Span, ReversePath> {
1427    context(
1428        "reverse-path",
1429        alt((
1430            null_sender,
1431            map(path, ReversePath::Path),
1432            map(mailbox, ReversePath::from),
1433        )),
1434    )
1435    .parse(input)
1436}
1437
1438// ---------------------------------------------------------------------------
1439// RCPT TO helpers
1440// ---------------------------------------------------------------------------
1441
1442/// `<Postmaster>` — the special no-domain postmaster address (RFC 5321 §4.1.1.3)
1443fn postmaster_path(input: Span) -> IResult<Span, ForwardPath> {
1444    context(
1445        "postmaster",
1446        map(tag_no_case("<Postmaster>"), |_| ForwardPath::Postmaster),
1447    )
1448    .parse(input)
1449}
1450
1451/// `forward-path = "<Postmaster>" / "<" path-content ">" / bare-mailbox`
1452///
1453/// `<Postmaster>` is tried first so the literal string is not consumed as
1454/// the opening `<` of a regular path.  Bare mailbox (no angle brackets) is
1455/// accepted as a leniency for non-conforming senders.
1456fn forward_path(input: Span) -> IResult<Span, ForwardPath> {
1457    context(
1458        "forward-path",
1459        alt((
1460            postmaster_path,
1461            map(path, ForwardPath::Path),
1462            map(mailbox, ForwardPath::from),
1463        )),
1464    )
1465    .parse(input)
1466}
1467
1468/// `esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-")`
1469fn esmtp_keyword(input: Span) -> IResult<Span, Span> {
1470    context(
1471        "esmtp-keyword",
1472        recognize((
1473            take_while_m_n(1, 1, |c: u8| c.is_ascii_alphanumeric()),
1474            many0(take_while_m_n(1, 1, |c: u8| {
1475                c.is_ascii_alphanumeric() || c == b'-'
1476            })),
1477        )),
1478    )
1479    .parse(input)
1480}
1481
1482/// `esmtp-value = 1*(%d33-60 / %d62-126 / UTF8-non-ASCII)`
1483///
1484/// RFC 5321 defines the base character range (printable ASCII excluding `=`,
1485/// SP, and controls).  RFC 6531 §3.3 extends this with `UTF8-non-ASCII` to
1486/// support internationalized ESMTP parameter values.
1487///
1488/// `many1(alt(ascii_run, utf8_non_ascii))` lets the `take_while1` arm greedily
1489/// consume consecutive ASCII bytes while `utf8_non_ascii` handles each
1490/// multi-byte codepoint.
1491fn esmtp_value(input: Span) -> IResult<Span, Span> {
1492    context(
1493        "esmtp-value",
1494        recognize(many1(alt((
1495            take_while1(|c: u8| (c >= 33 && c <= 60) || (c >= 62 && c <= 126)),
1496            utf8_non_ascii,
1497        )))),
1498    )
1499    .parse(input)
1500}
1501
1502/// `esmtp-param = esmtp-keyword ["=" esmtp-value]`
1503fn esmtp_param(input: Span) -> IResult<Span, EsmtpParameter> {
1504    context(
1505        "esmtp-param",
1506        map_res(
1507            (esmtp_keyword, opt((tag("="), esmtp_value))),
1508            |(name, value): (Span, Option<(Span, Span)>)| -> Result<EsmtpParameter, String> {
1509                let name = String::from_utf8(name.fragment().to_vec())
1510                    .map_err(|_| "esmtp_param: invalid UTF-8 in name".to_string())?;
1511                let value = value
1512                    .map(|(_, v)| {
1513                        String::from_utf8(v.fragment().to_vec())
1514                            .map_err(|_| "esmtp_param: invalid UTF-8 in value".to_string())
1515                    })
1516                    .transpose()?;
1517                Ok(EsmtpParameter { name, value })
1518            },
1519        ),
1520    )
1521    .parse(input)
1522}
1523
1524/// `mail-parameters = esmtp-param *(SP esmtp-param)`
1525fn mail_parameters(input: Span) -> IResult<Span, Vec<EsmtpParameter>> {
1526    context(
1527        "mail-parameters",
1528        map((esmtp_param, many0((wsp, esmtp_param))), |(first, rest)| {
1529            let mut params = vec![first];
1530            params.extend(rest.into_iter().map(|(_, p)| p));
1531            params
1532        }),
1533    )
1534    .parse(input)
1535}
1536
1537// ---------------------------------------------------------------------------
1538// EHLO / HELO / LHLO parsers
1539// ---------------------------------------------------------------------------
1540
1541/// `ehlo = "EHLO" SP ( Domain / address-literal ) CRLF`
1542///
1543/// Both HELO and LHLO reuse the same domain parser (permissive: address
1544/// literals are accepted for all three greeting commands).
1545/// Any failure in `mailbox_domain` (e.g. bad IP) falls through to Partial.
1546fn parse_ehlo(input: Span) -> IResult<Span, MaybePartialCommand> {
1547    context(
1548        "ehlo",
1549        alt((
1550            map(
1551                all_consuming((tag_no_case("EHLO"), wsp, mailbox_domain)),
1552                |(_, _, domain)| MaybePartialCommand::Full(Command::Ehlo(domain)),
1553            ),
1554            map(
1555                all_consuming((tag_no_case("EHLO"), wsp, anything)),
1556                |(_, _, remainder)| MaybePartialCommand::Partial {
1557                    verb: CommandVerb::Ehlo,
1558                    remainder: (*remainder).into(),
1559                    reason: PartialReason::Syntax,
1560                },
1561            ),
1562            map(all_consuming(tag_no_case("EHLO")), |_| {
1563                MaybePartialCommand::Partial {
1564                    verb: CommandVerb::Ehlo,
1565                    remainder: BString::default(),
1566                    reason: PartialReason::Syntax,
1567                }
1568            }),
1569        )),
1570    )
1571    .parse(input)
1572}
1573
1574/// `helo = "HELO" SP Domain CRLF`
1575///
1576/// Permissive: also accepts address literals (matches parser.rs behaviour).
1577fn parse_helo(input: Span) -> IResult<Span, MaybePartialCommand> {
1578    context(
1579        "helo",
1580        alt((
1581            map(
1582                all_consuming((tag_no_case("HELO"), wsp, mailbox_domain)),
1583                |(_, _, domain)| MaybePartialCommand::Full(Command::Helo(domain)),
1584            ),
1585            map(
1586                all_consuming((tag_no_case("HELO"), wsp, anything)),
1587                |(_, _, remainder)| MaybePartialCommand::Partial {
1588                    verb: CommandVerb::Helo,
1589                    remainder: (*remainder).into(),
1590                    reason: PartialReason::Syntax,
1591                },
1592            ),
1593            map(all_consuming(tag_no_case("HELO")), |_| {
1594                MaybePartialCommand::Partial {
1595                    verb: CommandVerb::Helo,
1596                    remainder: BString::default(),
1597                    reason: PartialReason::Syntax,
1598                }
1599            }),
1600        )),
1601    )
1602    .parse(input)
1603}
1604
1605/// `lhlo = "LHLO" SP ( Domain / address-literal ) CRLF`  (RFC 2033 LMTP)
1606fn parse_lhlo(input: Span) -> IResult<Span, MaybePartialCommand> {
1607    context(
1608        "lhlo",
1609        alt((
1610            map(
1611                all_consuming((tag_no_case("LHLO"), wsp, mailbox_domain)),
1612                |(_, _, domain)| MaybePartialCommand::Full(Command::Lhlo(domain)),
1613            ),
1614            map(
1615                all_consuming((tag_no_case("LHLO"), wsp, anything)),
1616                |(_, _, remainder)| MaybePartialCommand::Partial {
1617                    verb: CommandVerb::Lhlo,
1618                    remainder: (*remainder).into(),
1619                    reason: PartialReason::Syntax,
1620                },
1621            ),
1622            map(all_consuming(tag_no_case("LHLO")), |_| {
1623                MaybePartialCommand::Partial {
1624                    verb: CommandVerb::Lhlo,
1625                    remainder: BString::default(),
1626                    reason: PartialReason::Syntax,
1627                }
1628            }),
1629        )),
1630    )
1631    .parse(input)
1632}
1633
1634// ---------------------------------------------------------------------------
1635// AUTH parser
1636// ---------------------------------------------------------------------------
1637
1638/// `sasl-mechanism = 1*(ALPHA / DIGIT / "-")`
1639fn sasl_mechanism(input: Span) -> IResult<Span, String> {
1640    context(
1641        "sasl-mechanism",
1642        map(
1643            take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-'),
1644            |s: Span| {
1645                // sasl_mechanism only accepts ASCII bytes, so UTF-8 conversion always succeeds
1646                String::from_utf8(s.fragment().to_vec()).expect("sasl_mechanism guaranteed ASCII")
1647            },
1648        ),
1649    )
1650    .parse(input)
1651}
1652
1653/// `auth-initial-response = base64 / "="`
1654///
1655/// Matches base64 characters `[A-Za-z0-9+/=]+`.  The single `"="` (empty
1656/// initial response) is a subset of this pattern, so no special case is
1657/// needed.
1658fn auth_initial_response(input: Span) -> IResult<Span, String> {
1659    context(
1660        "auth-initial-response",
1661        map(
1662            take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'+' || c == b'/' || c == b'='),
1663            |s: Span| {
1664                // auth_initial_response only accepts ASCII bytes, so UTF-8 conversion always succeeds
1665                String::from_utf8(s.fragment().to_vec())
1666                    .expect("auth_initial_response guaranteed ASCII")
1667            },
1668        ),
1669    )
1670    .parse(input)
1671}
1672
1673/// `auth = "AUTH" SP mechanism [SP initial-response]`
1674fn parse_auth(input: Span) -> IResult<Span, MaybePartialCommand> {
1675    context(
1676        "auth",
1677        alt((
1678            // Arm 1: complete successful parse
1679            map(
1680                all_consuming((
1681                    tag_no_case("AUTH"),
1682                    wsp,
1683                    sasl_mechanism,
1684                    opt((wsp, auth_initial_response)),
1685                )),
1686                |(_, _, sasl_mech, resp)| match resp {
1687                    Some((_, r)) => MaybePartialCommand::Full(Command::Auth {
1688                        sasl_mech,
1689                        initial_response: Some(r),
1690                    }),
1691                    None => MaybePartialCommand::Full(Command::Auth {
1692                        sasl_mech,
1693                        initial_response: None,
1694                    }),
1695                },
1696            ),
1697            // Arm 2: "AUTH" + whitespace + anything → Partial
1698            map(
1699                all_consuming((tag_no_case("AUTH"), wsp, anything)),
1700                |(_, _, remainder)| MaybePartialCommand::Partial {
1701                    verb: CommandVerb::Auth,
1702                    remainder: (*remainder).into(),
1703                    reason: PartialReason::Syntax,
1704                },
1705            ),
1706            // Arm 3: "AUTH" alone → Partial
1707            map(all_consuming(tag_no_case("AUTH")), |_| {
1708                MaybePartialCommand::Partial {
1709                    verb: CommandVerb::Auth,
1710                    remainder: BString::default(),
1711                    reason: PartialReason::Syntax,
1712                }
1713            }),
1714        )),
1715    )
1716    .parse(input)
1717}
1718
1719// ---------------------------------------------------------------------------
1720// XCLIENT parser
1721// ---------------------------------------------------------------------------
1722
1723/// Decode an xtext-encoded byte slice (RFC 3461 §4).
1724///
1725/// xtext characters are printable ASCII in `\x21`–`\x7e` where `+XX`
1726/// introduces a hex-encoded byte.  Returns an error on a truncated or
1727/// invalid hex escape.
1728fn xtext_decode(encoded: &[u8]) -> Result<String, String> {
1729    let mut result: Vec<u8> = Vec::with_capacity(encoded.len());
1730    let mut i = 0;
1731    while i < encoded.len() {
1732        if encoded[i] == b'+' {
1733            if i + 2 >= encoded.len() {
1734                return Err(format!("xtext_decode: truncated hex escape at byte {i}"));
1735            }
1736            let hi = hex_nibble(encoded[i + 1]).map_err(|e| format!("xtext_decode: {e}"))?;
1737            let lo = hex_nibble(encoded[i + 2]).map_err(|e| format!("xtext_decode: {e}"))?;
1738            result.push((hi << 4) | lo);
1739            i += 3;
1740        } else {
1741            result.push(encoded[i]);
1742            i += 1;
1743        }
1744    }
1745    String::from_utf8(result)
1746        .map_err(|_| "xtext_decode: invalid UTF-8 in decoded value".to_string())
1747}
1748
1749fn hex_nibble(b: u8) -> Result<u8, String> {
1750    match b {
1751        b'0'..=b'9' => Ok(b - b'0'),
1752        b'a'..=b'f' => Ok(b - b'a' + 10),
1753        b'A'..=b'F' => Ok(b - b'A' + 10),
1754        _ => Err(format!("invalid hex digit '{}'", b as char)),
1755    }
1756}
1757
1758/// Raw xtext value: printable non-space ASCII (`\x21`–`\x7e`), which
1759/// includes the `+` that introduces a hex escape.  Space terminates the
1760/// value in the XCLIENT parameter list.
1761fn xclient_xtext_value(input: Span) -> IResult<Span, Span> {
1762    context(
1763        "xclient-xtext-value",
1764        take_while1(|c: u8| c >= 33 && c <= 126),
1765    )
1766    .parse(input)
1767}
1768
1769/// One XCLIENT parameter: `name "=" xtext-value`
1770///
1771/// The value is xtext-decoded via `map_res`; a malformed escape sequence
1772/// produces a recoverable nom error so `alt` can fall through to Partial.
1773fn xclient_param(input: Span) -> IResult<Span, XClientParameter> {
1774    context(
1775        "xclient-param",
1776        map_res(
1777            (esmtp_keyword, tag("="), xclient_xtext_value),
1778            |(name, _, value): (Span, _, Span)| -> Result<XClientParameter, String> {
1779                let name = String::from_utf8(name.fragment().to_vec())
1780                    .map_err(|_| "xclient_param: invalid UTF-8 in name".to_string())?;
1781                let value = xtext_decode(value.fragment())?;
1782                Ok(XClientParameter { name, value })
1783            },
1784        ),
1785    )
1786    .parse(input)
1787}
1788
1789/// `xclient-params = xclient-param *(SP xclient-param)`
1790fn xclient_params(input: Span) -> IResult<Span, Vec<XClientParameter>> {
1791    context(
1792        "xclient-params",
1793        map(
1794            (xclient_param, many0((wsp, xclient_param))),
1795            |(first, rest)| {
1796                let mut params = vec![first];
1797                params.extend(rest.into_iter().map(|(_, p)| p));
1798                params
1799            },
1800        ),
1801    )
1802    .parse(input)
1803}
1804
1805/// `xclient = "XCLIENT" SP xclient-params`
1806fn parse_xclient(input: Span) -> IResult<Span, MaybePartialCommand> {
1807    context(
1808        "xclient",
1809        alt((
1810            // Arm 1: complete successful parse
1811            map(
1812                all_consuming((tag_no_case("XCLIENT"), wsp, xclient_params)),
1813                |(_, _, params)| MaybePartialCommand::Full(Command::XClient(params)),
1814            ),
1815            // Arm 2: "XCLIENT" + whitespace + anything → Partial
1816            map(
1817                all_consuming((tag_no_case("XCLIENT"), wsp, anything)),
1818                |(_, _, remainder)| MaybePartialCommand::Partial {
1819                    verb: CommandVerb::XClient,
1820                    remainder: (*remainder).into(),
1821                    reason: PartialReason::Syntax,
1822                },
1823            ),
1824            // Arm 3: "XCLIENT" alone → Partial
1825            map(all_consuming(tag_no_case("XCLIENT")), |_| {
1826                MaybePartialCommand::Partial {
1827                    verb: CommandVerb::XClient,
1828                    remainder: BString::default(),
1829                    reason: PartialReason::Syntax,
1830                }
1831            }),
1832        )),
1833    )
1834    .parse(input)
1835}
1836
1837/// `mail = "MAIL FROM:" [WSP] reverse-path [SP mail-parameters]`
1838///
1839/// The `[WSP]` after the colon is a leniency for non-conforming clients;
1840/// RFC 5321 places the reverse-path immediately after the colon.
1841///
1842/// Returns `Full(Command::MailFrom { … })` on a complete successful parse.
1843///
1844/// Any failure after the `MAIL` keyword — including an invalid IPv4/IPv6
1845/// address or domain name in the sender address — falls through to a
1846/// `Partial { verb: CommandVerb::Mail, … }` result instead of a hard error.
1847/// No `cut` is used anywhere in the full-parse arm so that all sub-parser
1848/// failures remain recoverable and `alt` can try the fallback arms.
1849fn parse_mail_from(input: Span) -> IResult<Span, MaybePartialCommand> {
1850    context(
1851        "mail-from",
1852        alt((
1853            // Arm 1: complete successful parse
1854            map(
1855                all_consuming((
1856                    tag_no_case("MAIL"),
1857                    wsp,
1858                    tag_no_case("FROM:"),
1859                    // RFC 5321 has no space between the colon and the reverse-path,
1860                    // but many legacy clients emit one (`MAIL FROM: <addr>`). Tolerate
1861                    // and discard it rather than rejecting with 501 5.1.7.
1862                    opt(wsp),
1863                    reverse_path,
1864                    opt(map((wsp, mail_parameters), |(_, p)| p)),
1865                )),
1866                |(_, _, _, _, address, parameters)| {
1867                    MaybePartialCommand::Full(Command::MailFrom {
1868                        address,
1869                        parameters: parameters.unwrap_or_default(),
1870                    })
1871                },
1872            ),
1873            // Arm 2: "MAIL FROM:" + valid address + space + unparseable params → Syntax
1874            map(
1875                all_consuming((
1876                    tag_no_case("MAIL"),
1877                    wsp,
1878                    tag_no_case("FROM:"),
1879                    opt(wsp),
1880                    reverse_path,
1881                    wsp,
1882                    anything,
1883                )),
1884                |(_, _, _, _, _, _, remainder)| MaybePartialCommand::Partial {
1885                    verb: CommandVerb::Mail,
1886                    remainder: (*remainder).into(),
1887                    reason: PartialReason::Syntax,
1888                },
1889            ),
1890            // Arm 3: "MAIL FROM:" prefix matched but address failed to parse
1891            map(
1892                all_consuming((tag_no_case("MAIL"), wsp, tag_no_case("FROM:"), anything)),
1893                |(_, _, _, remainder)| MaybePartialCommand::Partial {
1894                    verb: CommandVerb::Mail,
1895                    remainder: (*remainder).into(),
1896                    reason: PartialReason::InvalidSenderAddress,
1897                },
1898            ),
1899            // Arm 4: "MAIL" + whitespace + anything → Partial (bad prefix, e.g. not "FROM:")
1900            map(
1901                all_consuming((tag_no_case("MAIL"), wsp, anything)),
1902                |(_, _, remainder)| MaybePartialCommand::Partial {
1903                    verb: CommandVerb::Mail,
1904                    remainder: (*remainder).into(),
1905                    reason: PartialReason::Syntax,
1906                },
1907            ),
1908            // Arm 5: "MAIL" alone → Partial (incomplete command)
1909            map(all_consuming(tag_no_case("MAIL")), |_| {
1910                MaybePartialCommand::Partial {
1911                    verb: CommandVerb::Mail,
1912                    remainder: BString::default(),
1913                    reason: PartialReason::Syntax,
1914                }
1915            }),
1916        )),
1917    )
1918    .parse(input)
1919}
1920
1921/// `rcpt = "RCPT TO:" [WSP] forward-path [SP mail-parameters]`
1922///
1923/// The `[WSP]` after the colon is a leniency for non-conforming clients;
1924/// RFC 5321 places the forward-path immediately after the colon.
1925///
1926/// Returns `Full(Command::RcptTo { … })` on a complete successful parse.
1927///
1928/// Any failure after the `RCPT` keyword — including an invalid IPv4/IPv6
1929/// address or domain name in the recipient address — falls through to a
1930/// `Partial { verb: CommandVerb::Rcpt, … }` result instead of a hard error.
1931/// No `cut` is used anywhere in the full-parse arm so that all sub-parser
1932/// failures remain recoverable and `alt` can try the fallback arms.
1933fn parse_rcpt_to(input: Span) -> IResult<Span, MaybePartialCommand> {
1934    context(
1935        "rcpt-to",
1936        alt((
1937            // Arm 1: complete successful parse
1938            map(
1939                all_consuming((
1940                    tag_no_case("RCPT"),
1941                    wsp,
1942                    tag_no_case("TO:"),
1943                    // Tolerate a stray space after the colon (`RCPT TO: <addr>`),
1944                    // as emitted by some legacy clients; see MAIL FROM above.
1945                    opt(wsp),
1946                    forward_path,
1947                    opt(map((wsp, mail_parameters), |(_, p)| p)),
1948                )),
1949                |(_, _, _, _, address, parameters)| {
1950                    MaybePartialCommand::Full(Command::RcptTo {
1951                        address,
1952                        parameters: parameters.unwrap_or_default(),
1953                    })
1954                },
1955            ),
1956            // Arm 2: "RCPT TO:" + valid address + space + unparseable params → Syntax
1957            map(
1958                all_consuming((
1959                    tag_no_case("RCPT"),
1960                    wsp,
1961                    tag_no_case("TO:"),
1962                    opt(wsp),
1963                    forward_path,
1964                    wsp,
1965                    anything,
1966                )),
1967                |(_, _, _, _, _, _, remainder)| MaybePartialCommand::Partial {
1968                    verb: CommandVerb::Rcpt,
1969                    remainder: (*remainder).into(),
1970                    reason: PartialReason::Syntax,
1971                },
1972            ),
1973            // Arm 3: "RCPT TO:" prefix matched but address failed to parse
1974            map(
1975                all_consuming((tag_no_case("RCPT"), wsp, tag_no_case("TO:"), anything)),
1976                |(_, _, _, remainder)| MaybePartialCommand::Partial {
1977                    verb: CommandVerb::Rcpt,
1978                    remainder: (*remainder).into(),
1979                    reason: PartialReason::InvalidRecipientAddress,
1980                },
1981            ),
1982            // Arm 4: "RCPT" + whitespace + anything → Partial (bad prefix, e.g. not "TO:")
1983            map(
1984                all_consuming((tag_no_case("RCPT"), wsp, anything)),
1985                |(_, _, remainder)| MaybePartialCommand::Partial {
1986                    verb: CommandVerb::Rcpt,
1987                    remainder: (*remainder).into(),
1988                    reason: PartialReason::Syntax,
1989                },
1990            ),
1991            // Arm 5: "RCPT" alone → Partial (incomplete command)
1992            map(all_consuming(tag_no_case("RCPT")), |_| {
1993                MaybePartialCommand::Partial {
1994                    verb: CommandVerb::Rcpt,
1995                    remainder: BString::default(),
1996                    reason: PartialReason::Syntax,
1997                }
1998            }),
1999        )),
2000    )
2001    .parse(input)
2002}
2003
2004// ---------------------------------------------------------------------------
2005// Encoding helpers
2006// ---------------------------------------------------------------------------
2007
2008/// Return the lowercase hex digit character for a nibble value (0–15).
2009fn hex_nibble_lower(n: u8) -> u8 {
2010    if n < 10 {
2011        b'0' + n
2012    } else {
2013        b'a' + n - 10
2014    }
2015}
2016
2017/// Xtext-encode a byte slice (RFC 3461 §4).
2018///
2019/// Bytes in the xchar range (`\x21`–`\x7e` except `+` and `=`) are copied
2020/// unchanged.  All other byte values are encoded as `+XX` where `XX` is two
2021/// lowercase hex digits.
2022fn xtext_encode_bytes(value: &[u8]) -> Vec<u8> {
2023    let mut result = Vec::with_capacity(value.len());
2024    for &b in value {
2025        if b >= 33 && b <= 126 && b != b'+' && b != b'=' {
2026            result.push(b);
2027        } else {
2028            result.push(b'+');
2029            result.push(hex_nibble_lower(b >> 4));
2030            result.push(hex_nibble_lower(b & 0x0f));
2031        }
2032    }
2033    result
2034}
2035
2036/// Encode a `Domain` as the ASCII text that appears in a command line.
2037///
2038/// - `DomainName` → ASCII/punycode-normalised domain string
2039/// - `V4` → `[{ip}]`
2040/// - `V6` → `[IPv6:{ip}]`
2041/// - `Tagged` → `[{tag:literal}]`
2042fn encode_domain(domain: &Domain) -> BString {
2043    match domain {
2044        Domain::DomainName(s) => BString::from(s.to_string()),
2045        Domain::V4(ip) => BString::from(format!("[{}]", ip)),
2046        Domain::V6(ip) => BString::from(format!("[IPv6:{}]", ip)),
2047        Domain::Tagged(s) => BString::from(format!("[{}]", s)),
2048    }
2049}
2050
2051/// Encode a `MailPath` as `local-part "@" domain` bytes.
2052///
2053/// The `at_domain_list` (source route) is intentionally **not** re-encoded:
2054/// RFC 5321 §4.1.2 says source routes MUST be accepted, SHOULD NOT be
2055/// generated, and SHOULD be ignored.
2056fn encode_mail_path(path: &MailPath) -> Vec<u8> {
2057    let mut buf = path.mailbox.local_part.as_bytes().to_vec();
2058    buf.push(b'@');
2059    buf.extend_from_slice(encode_domain(&path.mailbox.domain).as_ref());
2060    buf
2061}
2062
2063/// Encode the content that goes **between** the angle brackets of
2064/// `MAIL FROM:<…>`.
2065///
2066/// `NullSender` → empty (produces `MAIL FROM:<>`).
2067/// `Path` → `encode_mail_path` result.
2068fn encode_reverse_path(rp: &ReversePath) -> Vec<u8> {
2069    match rp {
2070        ReversePath::NullSender => vec![],
2071        ReversePath::Path(path) => encode_mail_path(path),
2072    }
2073}
2074
2075/// Encode the content that goes **between** the angle brackets of
2076/// `RCPT TO:<…>`.
2077///
2078/// `Postmaster` → `b"Postmaster"`.
2079/// `Path` → `encode_mail_path` result.
2080fn encode_forward_path(fp: &ForwardPath) -> Vec<u8> {
2081    match fp {
2082        ForwardPath::Postmaster => b"Postmaster".to_vec(),
2083        ForwardPath::Path(path) => encode_mail_path(path),
2084    }
2085}
2086
2087/// Encode a slice of `EsmtpParameter` as the optional suffix of a
2088/// `MAIL FROM` or `RCPT TO` command: `*(SP keyword ["=" value])`.
2089///
2090/// Returns an empty `Vec` when `params` is empty.
2091fn encode_esmtp_params(params: &[EsmtpParameter]) -> Vec<u8> {
2092    let mut buf = Vec::new();
2093    for p in params {
2094        buf.push(b' ');
2095        buf.extend_from_slice(p.name.as_bytes());
2096        if let Some(v) = &p.value {
2097            buf.push(b'=');
2098            buf.extend_from_slice(v.as_bytes());
2099        }
2100    }
2101    buf
2102}
2103
2104/// Encode a slice of `XClientParameter` as `*(SP name "=" xtext-value)`.
2105///
2106/// Each parameter value is xtext-encoded before writing.
2107fn encode_xclient_params(params: &[XClientParameter]) -> Vec<u8> {
2108    let mut buf = Vec::new();
2109    for p in params {
2110        buf.push(b' ');
2111        buf.extend_from_slice(p.name.as_bytes());
2112        buf.push(b'=');
2113        buf.extend(xtext_encode_bytes(p.value.as_bytes()));
2114    }
2115    buf
2116}
2117
2118#[cfg(test)]
2119mod test {
2120    use super::*;
2121
2122    #[test]
2123    fn test_string() {
2124        k9::snapshot!(
2125            BStr::new(&parse_with("hello".as_bytes(), string).unwrap()),
2126            "hello"
2127        );
2128        k9::snapshot!(
2129            BStr::new(&parse_with("\"hello\"".as_bytes(), string).unwrap()),
2130            "\"hello\""
2131        );
2132        k9::snapshot!(
2133            BStr::new(&parse_with("\"hello world\"".as_bytes(), string).unwrap()),
2134            "\"hello world\""
2135        );
2136        k9::snapshot!(
2137            parse_with("hello world".as_bytes(), string),
2138            r#"
2139Err(
2140    "Error at line 1, in Eof:
2141hello world
2142     ^_____
2143
2144",
2145)
2146"#
2147        );
2148    }
2149
2150    #[test]
2151    fn test_bogus() {
2152        k9::snapshot!(
2153            Command::parse("bogus"),
2154            r#"
2155Ok(
2156    Full(Command("bogus\r
2157")),
2158)
2159"#
2160        );
2161    }
2162
2163    // ------------------------------------------------------------------
2164    // EHLO tests
2165    // ------------------------------------------------------------------
2166
2167    #[test]
2168    fn test_ehlo_domain_name() {
2169        k9::assert_equal!(
2170            unwrapper(Command::parse("EHLO example.com")),
2171            MaybePartialCommand::Full(Command::Ehlo(Domain::DomainName(
2172                "example.com".parse().unwrap()
2173            )))
2174        );
2175    }
2176
2177    #[test]
2178    fn test_ehlo_case_insensitive() {
2179        k9::assert_equal!(
2180            unwrapper(Command::parse("ehlo example.com")),
2181            MaybePartialCommand::Full(Command::Ehlo(Domain::DomainName(
2182                "example.com".parse().unwrap()
2183            )))
2184        );
2185    }
2186
2187    #[test]
2188    fn test_ehlo_ipv4_literal() {
2189        k9::assert_equal!(
2190            unwrapper(Command::parse("EHLO [10.0.0.1]")),
2191            MaybePartialCommand::Full(Command::Ehlo(Domain::V4("10.0.0.1".parse().unwrap())))
2192        );
2193    }
2194
2195    #[test]
2196    fn test_ehlo_ipv6_literal() {
2197        k9::assert_equal!(
2198            unwrapper(Command::parse("EHLO [IPv6:::1]")),
2199            MaybePartialCommand::Full(Command::Ehlo(Domain::V6("::1".parse().unwrap())))
2200        );
2201    }
2202
2203    #[test]
2204    fn test_ehlo_tagged_literal() {
2205        k9::assert_equal!(
2206            unwrapper(Command::parse("EHLO [future:something]")),
2207            MaybePartialCommand::Full(Command::Ehlo(Domain::Tagged("future:something".into(),)))
2208        );
2209    }
2210
2211    #[test]
2212    fn test_ehlo_invalid_ipv4_is_partial() {
2213        k9::assert_equal!(
2214            unwrapper(Command::parse("EHLO [999.999.999.999]")),
2215            MaybePartialCommand::Partial {
2216                verb: CommandVerb::Ehlo,
2217                remainder: "[999.999.999.999]".into(),
2218                reason: PartialReason::Syntax,
2219            }
2220        );
2221    }
2222
2223    #[test]
2224    fn test_ehlo_invalid_ipv6_is_partial() {
2225        k9::assert_equal!(
2226            unwrapper(Command::parse("EHLO [IPv6:not-an-ipv6]")),
2227            MaybePartialCommand::Partial {
2228                verb: CommandVerb::Ehlo,
2229                remainder: "[IPv6:not-an-ipv6]".into(),
2230                reason: PartialReason::Syntax,
2231            }
2232        );
2233    }
2234
2235    #[test]
2236    fn test_ehlo_alone_is_partial() {
2237        k9::assert_equal!(
2238            unwrapper(Command::parse("EHLO")),
2239            MaybePartialCommand::Partial {
2240                verb: CommandVerb::Ehlo,
2241                remainder: "".into(),
2242                reason: PartialReason::Syntax,
2243            }
2244        );
2245    }
2246
2247    #[test]
2248    fn test_ehlo_with_garbage_is_partial() {
2249        k9::assert_equal!(
2250            unwrapper(Command::parse("EHLO !!invalid!!")),
2251            MaybePartialCommand::Partial {
2252                verb: CommandVerb::Ehlo,
2253                remainder: "!!invalid!!".into(),
2254                reason: PartialReason::Syntax,
2255            }
2256        );
2257    }
2258
2259    // ------------------------------------------------------------------
2260    // HELO tests
2261    // ------------------------------------------------------------------
2262
2263    #[test]
2264    fn test_helo_domain_name() {
2265        k9::assert_equal!(
2266            unwrapper(Command::parse("HELO example.com")),
2267            MaybePartialCommand::Full(Command::Helo(Domain::DomainName(
2268                "example.com".parse().unwrap()
2269            )))
2270        );
2271    }
2272
2273    #[test]
2274    fn test_helo_case_insensitive() {
2275        k9::assert_equal!(
2276            unwrapper(Command::parse("helo example.com")),
2277            MaybePartialCommand::Full(Command::Helo(Domain::DomainName(
2278                "example.com".parse().unwrap()
2279            )))
2280        );
2281    }
2282
2283    #[test]
2284    fn test_helo_ipv4_literal() {
2285        // Permissive: address literals accepted for HELO
2286        k9::assert_equal!(
2287            unwrapper(Command::parse("HELO [10.0.0.1]")),
2288            MaybePartialCommand::Full(Command::Helo(Domain::V4("10.0.0.1".parse().unwrap())))
2289        );
2290    }
2291
2292    #[test]
2293    fn test_helo_alone_is_partial() {
2294        k9::assert_equal!(
2295            unwrapper(Command::parse("HELO")),
2296            MaybePartialCommand::Partial {
2297                verb: CommandVerb::Helo,
2298                remainder: "".into(),
2299                reason: PartialReason::Syntax,
2300            }
2301        );
2302    }
2303
2304    #[test]
2305    fn test_helo_with_garbage_is_partial() {
2306        k9::assert_equal!(
2307            unwrapper(Command::parse("HELO !!invalid!!")),
2308            MaybePartialCommand::Partial {
2309                verb: CommandVerb::Helo,
2310                remainder: "!!invalid!!".into(),
2311                reason: PartialReason::Syntax,
2312            }
2313        );
2314    }
2315
2316    // ------------------------------------------------------------------
2317    // LHLO tests
2318    // ------------------------------------------------------------------
2319
2320    #[test]
2321    fn test_lhlo_domain_name() {
2322        k9::assert_equal!(
2323            unwrapper(Command::parse("LHLO example.com")),
2324            MaybePartialCommand::Full(Command::Lhlo(Domain::DomainName(
2325                "example.com".parse().unwrap()
2326            )))
2327        );
2328    }
2329
2330    #[test]
2331    fn test_lhlo_case_insensitive() {
2332        k9::assert_equal!(
2333            unwrapper(Command::parse("lhlo example.com")),
2334            MaybePartialCommand::Full(Command::Lhlo(Domain::DomainName(
2335                "example.com".parse().unwrap()
2336            )))
2337        );
2338    }
2339
2340    #[test]
2341    fn test_lhlo_ipv4_literal() {
2342        k9::assert_equal!(
2343            unwrapper(Command::parse("LHLO [10.0.0.1]")),
2344            MaybePartialCommand::Full(Command::Lhlo(Domain::V4("10.0.0.1".parse().unwrap())))
2345        );
2346    }
2347
2348    #[test]
2349    fn test_lhlo_ipv6_literal() {
2350        k9::assert_equal!(
2351            unwrapper(Command::parse("LHLO [IPv6:::1]")),
2352            MaybePartialCommand::Full(Command::Lhlo(Domain::V6("::1".parse().unwrap())))
2353        );
2354    }
2355
2356    #[test]
2357    fn test_lhlo_invalid_ipv4_is_partial() {
2358        k9::assert_equal!(
2359            unwrapper(Command::parse("LHLO [999.999.999.999]")),
2360            MaybePartialCommand::Partial {
2361                verb: CommandVerb::Lhlo,
2362                remainder: "[999.999.999.999]".into(),
2363                reason: PartialReason::Syntax,
2364            }
2365        );
2366    }
2367
2368    #[test]
2369    fn test_lhlo_alone_is_partial() {
2370        k9::assert_equal!(
2371            unwrapper(Command::parse("LHLO")),
2372            MaybePartialCommand::Partial {
2373                verb: CommandVerb::Lhlo,
2374                remainder: "".into(),
2375                reason: PartialReason::Syntax,
2376            }
2377        );
2378    }
2379
2380    // ------------------------------------------------------------------
2381    // AUTH tests
2382    // ------------------------------------------------------------------
2383
2384    #[test]
2385    fn test_auth_mechanism_only() {
2386        k9::assert_equal!(
2387            unwrapper(Command::parse("AUTH PLAIN")),
2388            MaybePartialCommand::Full(Command::Auth {
2389                sasl_mech: "PLAIN".into(),
2390                initial_response: None,
2391            })
2392        );
2393    }
2394
2395    #[test]
2396    fn test_auth_with_initial_response() {
2397        k9::assert_equal!(
2398            unwrapper(Command::parse("AUTH PLAIN dXNlcjpwYXNz")),
2399            MaybePartialCommand::Full(Command::Auth {
2400                sasl_mech: "PLAIN".into(),
2401                initial_response: Some("dXNlcjpwYXNz".into()),
2402            })
2403        );
2404    }
2405
2406    #[test]
2407    fn test_auth_trailing_garbage_is_partial_auth() {
2408        // A malformed AUTH line still parses as Partial with the Auth verb
2409        // and a non-empty `remainder`.
2410        match Command::parse("AUTH PLAIN dXNlcjpwYXNz garbage") {
2411            Ok(MaybePartialCommand::Partial {
2412                verb, remainder, ..
2413            }) => {
2414                k9::assert_equal!(verb, CommandVerb::Auth);
2415                assert!(!remainder.is_empty());
2416            }
2417            other => panic!("expected Partial AUTH, got {other:?}"),
2418        }
2419    }
2420
2421    #[test]
2422    fn test_auth_empty_initial_response() {
2423        // "=" signals an empty initial response (RFC 4954)
2424        k9::assert_equal!(
2425            unwrapper(Command::parse("AUTH PLAIN =")),
2426            MaybePartialCommand::Full(Command::Auth {
2427                sasl_mech: "PLAIN".into(),
2428                initial_response: Some("=".into()),
2429            })
2430        );
2431    }
2432
2433    #[test]
2434    fn test_auth_hyphenated_mechanism() {
2435        k9::assert_equal!(
2436            unwrapper(Command::parse("AUTH CRAM-MD5")),
2437            MaybePartialCommand::Full(Command::Auth {
2438                sasl_mech: "CRAM-MD5".into(),
2439                initial_response: None,
2440            })
2441        );
2442    }
2443
2444    #[test]
2445    fn test_auth_case_insensitive() {
2446        k9::assert_equal!(
2447            unwrapper(Command::parse("auth plain")),
2448            MaybePartialCommand::Full(Command::Auth {
2449                sasl_mech: "plain".into(),
2450                initial_response: None,
2451            })
2452        );
2453    }
2454
2455    #[test]
2456    fn test_auth_alone_is_partial() {
2457        k9::assert_equal!(
2458            unwrapper(Command::parse("AUTH")),
2459            MaybePartialCommand::Partial {
2460                verb: CommandVerb::Auth,
2461                remainder: "".into(),
2462                reason: PartialReason::Syntax,
2463            }
2464        );
2465    }
2466
2467    #[test]
2468    fn test_auth_with_garbage_is_partial() {
2469        // Mechanism contains invalid characters → Partial
2470        k9::assert_equal!(
2471            unwrapper(Command::parse("AUTH !!bad!!")),
2472            MaybePartialCommand::Partial {
2473                verb: CommandVerb::Auth,
2474                remainder: "!!bad!!".into(),
2475                reason: PartialReason::Syntax,
2476            }
2477        );
2478    }
2479
2480    // ------------------------------------------------------------------
2481    // XCLIENT tests
2482    // ------------------------------------------------------------------
2483
2484    #[test]
2485    fn test_xclient_single_param() {
2486        k9::assert_equal!(
2487            unwrapper(Command::parse("XCLIENT NAME=foo.example.com")),
2488            MaybePartialCommand::Full(Command::XClient(vec![XClientParameter {
2489                name: "NAME".into(),
2490                value: "foo.example.com".into(),
2491            }]))
2492        );
2493    }
2494
2495    #[test]
2496    fn test_xclient_multiple_params() {
2497        k9::assert_equal!(
2498            unwrapper(Command::parse("XCLIENT NAME=foo.example.com ADDR=10.0.0.1")),
2499            MaybePartialCommand::Full(Command::XClient(vec![
2500                XClientParameter {
2501                    name: "NAME".into(),
2502                    value: "foo.example.com".into(),
2503                },
2504                XClientParameter {
2505                    name: "ADDR".into(),
2506                    value: "10.0.0.1".into(),
2507                },
2508            ]))
2509        );
2510    }
2511
2512    #[test]
2513    fn test_xclient_xtext_hex_escape() {
2514        // '+40' decodes to '@'
2515        k9::assert_equal!(
2516            unwrapper(Command::parse("XCLIENT NAME=user+40example.com")),
2517            MaybePartialCommand::Full(Command::XClient(vec![XClientParameter {
2518                name: "NAME".into(),
2519                value: "user@example.com".into(),
2520            }]))
2521        );
2522    }
2523
2524    #[test]
2525    fn test_xclient_case_insensitive() {
2526        k9::assert_equal!(
2527            unwrapper(Command::parse("xclient NAME=host")),
2528            MaybePartialCommand::Full(Command::XClient(vec![XClientParameter {
2529                name: "NAME".into(),
2530                value: "host".into(),
2531            }]))
2532        );
2533    }
2534
2535    #[test]
2536    fn test_xclient_alone_is_partial() {
2537        k9::assert_equal!(
2538            unwrapper(Command::parse("XCLIENT")),
2539            MaybePartialCommand::Partial {
2540                verb: CommandVerb::XClient,
2541                remainder: "".into(),
2542                reason: PartialReason::Syntax,
2543            }
2544        );
2545    }
2546
2547    #[test]
2548    fn test_xclient_invalid_xtext_is_partial() {
2549        // '+' not followed by two hex digits → xtext_decode fails → Partial
2550        k9::assert_equal!(
2551            unwrapper(Command::parse("XCLIENT NAME=bad+ZZ")),
2552            MaybePartialCommand::Partial {
2553                verb: CommandVerb::XClient,
2554                remainder: "NAME=bad+ZZ".into(),
2555                reason: PartialReason::Syntax,
2556            }
2557        );
2558    }
2559
2560    #[test]
2561    fn test_xclient_garbage_is_partial() {
2562        // No '=' separator → xclient_param fails → Partial
2563        k9::assert_equal!(
2564            unwrapper(Command::parse("XCLIENT noequals")),
2565            MaybePartialCommand::Partial {
2566                verb: CommandVerb::XClient,
2567                remainder: "noequals".into(),
2568                reason: PartialReason::Syntax,
2569            }
2570        );
2571    }
2572
2573    #[test]
2574    fn test_xclient_parameter_methods() {
2575        // Parse an XCLIENT command with an IP address
2576        let cmd = unwrapper(Command::parse("XCLIENT ADDR=192.168.1.1"));
2577        let params = match cmd {
2578            MaybePartialCommand::Full(Command::XClient(params)) => params,
2579            _ => panic!("Expected XCLIENT command"),
2580        };
2581
2582        // Test is_name method
2583        k9::assert_equal!(params[0].is_name("ADDR"), true);
2584        k9::assert_equal!(params[0].is_name("addr"), true);
2585        k9::assert_equal!(params[0].is_name("NAME"), false);
2586
2587        // Test parse method with IpAddr
2588        let ip: std::net::IpAddr = params[0].parse().expect("Failed to parse IP address");
2589        k9::assert_equal!(
2590            ip,
2591            std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 1))
2592        );
2593    }
2594
2595    #[test]
2596    fn test_xclient_parameter_parse_invalid_ip() {
2597        // Test parsing with an invalid IP address string
2598        let cmd = unwrapper(Command::parse("XCLIENT ADDR=not-an-ip"));
2599        let params = match cmd {
2600            MaybePartialCommand::Full(Command::XClient(params)) => params,
2601            _ => panic!("Expected XCLIENT command"),
2602        };
2603
2604        let result: Result<std::net::IpAddr, _> = params[0].parse();
2605        k9::assert_equal!(result.unwrap_err(), "invalid IP address syntax");
2606    }
2607
2608    // ------------------------------------------------------------------
2609    // MAIL FROM tests
2610    // ------------------------------------------------------------------
2611
2612    fn mail_path(local: &str, domain: Domain) -> ReversePath {
2613        Mailbox {
2614            local_part: local.into(),
2615            domain,
2616        }
2617        .into()
2618    }
2619
2620    #[test]
2621    fn test_mail_from_domain_name() {
2622        k9::assert_equal!(
2623            unwrapper(Command::parse("MAIL FROM:<user@host>")),
2624            MaybePartialCommand::Full(Command::MailFrom {
2625                address: mail_path("user", Domain::DomainName("host".parse().unwrap())),
2626                parameters: vec![],
2627            })
2628        );
2629        // Case-insensitive verb and keyword
2630        k9::assert_equal!(
2631            unwrapper(Command::parse("mail from:<user@host>")),
2632            MaybePartialCommand::Full(Command::MailFrom {
2633                address: mail_path("user", Domain::DomainName("host".parse().unwrap())),
2634                parameters: vec![],
2635            })
2636        );
2637    }
2638
2639    #[test]
2640    fn test_mail_from_null_sender() {
2641        k9::assert_equal!(
2642            unwrapper(Command::parse("MAIL FROM:<>")),
2643            MaybePartialCommand::Full(Command::MailFrom {
2644                address: ReversePath::NullSender,
2645                parameters: vec![],
2646            })
2647        );
2648    }
2649
2650    #[test]
2651    fn test_mail_from_ipv4() {
2652        k9::assert_equal!(
2653            unwrapper(Command::parse("MAIL FROM:<user@[10.0.0.1]>")),
2654            MaybePartialCommand::Full(Command::MailFrom {
2655                address: mail_path("user", Domain::V4("10.0.0.1".parse().unwrap())),
2656                parameters: vec![],
2657            })
2658        );
2659    }
2660
2661    #[test]
2662    fn test_mail_from_ipv6() {
2663        k9::assert_equal!(
2664            unwrapper(Command::parse("MAIL FROM:<user@[IPv6:::1]>")),
2665            MaybePartialCommand::Full(Command::MailFrom {
2666                address: mail_path("user", Domain::V6("::1".parse().unwrap())),
2667                parameters: vec![],
2668            })
2669        );
2670    }
2671
2672    #[test]
2673    fn test_mail_from_tagged_literal() {
2674        k9::assert_equal!(
2675            unwrapper(Command::parse("MAIL FROM:<user@[future:something]>")),
2676            MaybePartialCommand::Full(Command::MailFrom {
2677                address: mail_path("user", Domain::Tagged("future:something".into())),
2678                parameters: vec![],
2679            })
2680        );
2681    }
2682
2683    #[test]
2684    fn test_mail_from_esmtp_params() {
2685        k9::assert_equal!(
2686            unwrapper(Command::parse("MAIL FROM:<user@host> foo bar=baz")),
2687            MaybePartialCommand::Full(Command::MailFrom {
2688                address: mail_path("user", Domain::DomainName("host".parse().unwrap())),
2689                parameters: vec![
2690                    EsmtpParameter {
2691                        name: "foo".into(),
2692                        value: None,
2693                    },
2694                    EsmtpParameter {
2695                        name: "bar".into(),
2696                        value: Some("baz".into()),
2697                    },
2698                ],
2699            })
2700        );
2701    }
2702
2703    #[test]
2704    fn test_mail_from_bare_address() {
2705        // No angle brackets — accepted as a leniency
2706        k9::assert_equal!(
2707            unwrapper(Command::parse("MAIL FROM:user@host")),
2708            MaybePartialCommand::Full(Command::MailFrom {
2709                address: mail_path("user", Domain::DomainName("host".parse().unwrap())),
2710                parameters: vec![],
2711            })
2712        );
2713    }
2714
2715    #[test]
2716    fn test_mail_from_space_before_path() {
2717        // RFC 5321 forbids a space between the colon and the reverse-path, but
2718        // some legacy clients emit one. We tolerate and discard it rather than
2719        // rejecting with 501 5.1.7.
2720        for cmd in [
2721            "MAIL FROM: <user@host>",
2722            "MAIL FROM:\t<user@host>",
2723            "MAIL FROM:   <user@host>",
2724        ] {
2725            k9::assert_equal!(
2726                unwrapper(Command::parse(cmd)),
2727                MaybePartialCommand::Full(Command::MailFrom {
2728                    address: mail_path("user", Domain::DomainName("host".parse().unwrap())),
2729                    parameters: vec![],
2730                })
2731            );
2732        }
2733
2734        // Null sender with a stray space.
2735        k9::assert_equal!(
2736            unwrapper(Command::parse("MAIL FROM: <>")),
2737            MaybePartialCommand::Full(Command::MailFrom {
2738                address: ReversePath::NullSender,
2739                parameters: vec![],
2740            })
2741        );
2742
2743        // ESMTP parameters still parse after the relaxed leading space.
2744        k9::assert_equal!(
2745            unwrapper(Command::parse("MAIL FROM: <user@host> BODY=8BITMIME")),
2746            MaybePartialCommand::Full(Command::MailFrom {
2747                address: mail_path("user", Domain::DomainName("host".parse().unwrap())),
2748                parameters: vec![EsmtpParameter {
2749                    name: "BODY".into(),
2750                    value: Some("8BITMIME".into()),
2751                }],
2752            })
2753        );
2754    }
2755
2756    #[test]
2757    fn test_mail_from_at_domain_list() {
2758        k9::assert_equal!(
2759            unwrapper(Command::parse(
2760                "MAIL FROM:<@hosta.int,@jkl.org:userc@d.bar.org>"
2761            )),
2762            MaybePartialCommand::Full(Command::MailFrom {
2763                address: ReversePath::Path(MailPath {
2764                    at_domain_list: vec!["hosta.int".into(), "jkl.org".into()],
2765                    mailbox: Mailbox {
2766                        local_part: "userc".into(),
2767                        domain: Domain::DomainName("d.bar.org".parse().unwrap()),
2768                    },
2769                }),
2770                parameters: vec![],
2771            })
2772        );
2773    }
2774
2775    #[test]
2776    fn test_mail_from_invalid_ipv4_is_partial() {
2777        // Bad IPv4 inside brackets → InvalidSenderAddress, not a hard error
2778        k9::assert_equal!(
2779            unwrapper(Command::parse("MAIL FROM:<user@[999.999.999.999]>")),
2780            MaybePartialCommand::Partial {
2781                verb: CommandVerb::Mail,
2782                remainder: "<user@[999.999.999.999]>".into(),
2783                reason: PartialReason::InvalidSenderAddress,
2784            }
2785        );
2786    }
2787
2788    #[test]
2789    fn test_mail_from_invalid_ipv6_is_partial() {
2790        // Bad IPv6 after "IPv6:" prefix → InvalidSenderAddress, not a hard error
2791        k9::assert_equal!(
2792            unwrapper(Command::parse("MAIL FROM:<user@[IPv6:not-an-ipv6]>")),
2793            MaybePartialCommand::Partial {
2794                verb: CommandVerb::Mail,
2795                remainder: "<user@[IPv6:not-an-ipv6]>".into(),
2796                reason: PartialReason::InvalidSenderAddress,
2797            }
2798        );
2799    }
2800
2801    #[test]
2802    fn test_mail_alone_is_partial() {
2803        k9::assert_equal!(
2804            unwrapper(Command::parse("MAIL")),
2805            MaybePartialCommand::Partial {
2806                verb: CommandVerb::Mail,
2807                remainder: "".into(),
2808                reason: PartialReason::Syntax,
2809            }
2810        );
2811    }
2812
2813    #[test]
2814    fn test_mail_with_garbage_is_partial() {
2815        k9::assert_equal!(
2816            unwrapper(Command::parse("MAIL garbage")),
2817            MaybePartialCommand::Partial {
2818                verb: CommandVerb::Mail,
2819                remainder: "garbage".into(),
2820                reason: PartialReason::Syntax,
2821            }
2822        );
2823    }
2824
2825    // ------------------------------------------------------------------
2826    // RCPT TO tests
2827    // ------------------------------------------------------------------
2828
2829    fn rcpt_path(local: &str, domain: Domain) -> ForwardPath {
2830        Mailbox {
2831            local_part: local.into(),
2832            domain,
2833        }
2834        .into()
2835    }
2836
2837    #[test]
2838    fn test_rcpt_to_domain_name() {
2839        k9::assert_equal!(
2840            unwrapper(Command::parse("RCPT TO:<user@host>")),
2841            MaybePartialCommand::Full(Command::RcptTo {
2842                address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())),
2843                parameters: vec![],
2844            })
2845        );
2846    }
2847
2848    #[test]
2849    fn test_rcpt_to_case_insensitive() {
2850        // Verb and keyword are case-insensitive
2851        k9::assert_equal!(
2852            unwrapper(Command::parse("rcpt to:<user@host>")),
2853            MaybePartialCommand::Full(Command::RcptTo {
2854                address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())),
2855                parameters: vec![],
2856            })
2857        );
2858    }
2859
2860    #[test]
2861    fn test_rcpt_to_postmaster() {
2862        k9::assert_equal!(
2863            unwrapper(Command::parse("RCPT TO:<Postmaster>")),
2864            MaybePartialCommand::Full(Command::RcptTo {
2865                address: ForwardPath::Postmaster,
2866                parameters: vec![],
2867            })
2868        );
2869    }
2870
2871    #[test]
2872    fn test_rcpt_to_postmaster_lowercase() {
2873        // <Postmaster> is case-insensitive per RFC 5321
2874        k9::assert_equal!(
2875            unwrapper(Command::parse("RCPT TO:<postmaster>")),
2876            MaybePartialCommand::Full(Command::RcptTo {
2877                address: ForwardPath::Postmaster,
2878                parameters: vec![],
2879            })
2880        );
2881    }
2882
2883    #[test]
2884    fn test_rcpt_to_ipv4() {
2885        k9::assert_equal!(
2886            unwrapper(Command::parse("RCPT TO:<user@[10.0.0.1]>")),
2887            MaybePartialCommand::Full(Command::RcptTo {
2888                address: rcpt_path("user", Domain::V4("10.0.0.1".parse().unwrap())),
2889                parameters: vec![],
2890            })
2891        );
2892    }
2893
2894    #[test]
2895    fn test_rcpt_to_ipv6() {
2896        k9::assert_equal!(
2897            unwrapper(Command::parse("RCPT TO:<user@[IPv6:::1]>")),
2898            MaybePartialCommand::Full(Command::RcptTo {
2899                address: rcpt_path("user", Domain::V6("::1".parse().unwrap())),
2900                parameters: vec![],
2901            })
2902        );
2903    }
2904
2905    #[test]
2906    fn test_rcpt_to_tagged_literal() {
2907        k9::assert_equal!(
2908            unwrapper(Command::parse("RCPT TO:<user@[future:something]>")),
2909            MaybePartialCommand::Full(Command::RcptTo {
2910                address: rcpt_path("user", Domain::Tagged("future:something".into())),
2911                parameters: vec![],
2912            })
2913        );
2914    }
2915
2916    #[test]
2917    fn test_rcpt_to_esmtp_params() {
2918        k9::assert_equal!(
2919            unwrapper(Command::parse("RCPT TO:<user@host> foo bar=baz")),
2920            MaybePartialCommand::Full(Command::RcptTo {
2921                address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())),
2922                parameters: vec![
2923                    EsmtpParameter {
2924                        name: "foo".into(),
2925                        value: None,
2926                    },
2927                    EsmtpParameter {
2928                        name: "bar".into(),
2929                        value: Some("baz".into()),
2930                    },
2931                ],
2932            })
2933        );
2934    }
2935
2936    #[test]
2937    fn test_rcpt_to_bare_address() {
2938        // No angle brackets — accepted as a leniency
2939        k9::assert_equal!(
2940            unwrapper(Command::parse("RCPT TO:user@host")),
2941            MaybePartialCommand::Full(Command::RcptTo {
2942                address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())),
2943                parameters: vec![],
2944            })
2945        );
2946    }
2947
2948    #[test]
2949    fn test_rcpt_to_space_before_path() {
2950        // Tolerate a stray space after the colon (`RCPT TO: <addr>`), as emitted
2951        // by some legacy clients.
2952        for cmd in [
2953            "RCPT TO: <user@host>",
2954            "RCPT TO:\t<user@host>",
2955            "RCPT TO:   <user@host>",
2956        ] {
2957            k9::assert_equal!(
2958                unwrapper(Command::parse(cmd)),
2959                MaybePartialCommand::Full(Command::RcptTo {
2960                    address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())),
2961                    parameters: vec![],
2962                })
2963            );
2964        }
2965
2966        // Postmaster with a stray space.
2967        k9::assert_equal!(
2968            unwrapper(Command::parse("RCPT TO: <Postmaster>")),
2969            MaybePartialCommand::Full(Command::RcptTo {
2970                address: ForwardPath::Postmaster,
2971                parameters: vec![],
2972            })
2973        );
2974
2975        // ESMTP parameters still parse after the relaxed leading space.
2976        k9::assert_equal!(
2977            unwrapper(Command::parse("RCPT TO: <user@host> NOTIFY=SUCCESS")),
2978            MaybePartialCommand::Full(Command::RcptTo {
2979                address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())),
2980                parameters: vec![EsmtpParameter {
2981                    name: "NOTIFY".into(),
2982                    value: Some("SUCCESS".into()),
2983                }],
2984            })
2985        );
2986    }
2987
2988    #[test]
2989    fn test_rcpt_to_at_domain_list() {
2990        k9::assert_equal!(
2991            unwrapper(Command::parse(
2992                "RCPT TO:<@hosta.int,@jkl.org:userc@d.bar.org>"
2993            )),
2994            MaybePartialCommand::Full(Command::RcptTo {
2995                address: ForwardPath::Path(MailPath {
2996                    at_domain_list: vec!["hosta.int".into(), "jkl.org".into()],
2997                    mailbox: Mailbox {
2998                        local_part: "userc".into(),
2999                        domain: Domain::DomainName("d.bar.org".parse().unwrap()),
3000                    },
3001                }),
3002                parameters: vec![],
3003            })
3004        );
3005    }
3006
3007    #[test]
3008    fn test_rcpt_to_invalid_ipv4_is_partial() {
3009        // Bad IPv4 inside brackets → InvalidRecipientAddress, not a hard error
3010        k9::assert_equal!(
3011            unwrapper(Command::parse("RCPT TO:<user@[999.999.999.999]>")),
3012            MaybePartialCommand::Partial {
3013                verb: CommandVerb::Rcpt,
3014                remainder: "<user@[999.999.999.999]>".into(),
3015                reason: PartialReason::InvalidRecipientAddress,
3016            }
3017        );
3018    }
3019
3020    #[test]
3021    fn test_rcpt_to_invalid_ipv6_is_partial() {
3022        // Bad IPv6 after "IPv6:" prefix → InvalidRecipientAddress, not a hard error
3023        k9::assert_equal!(
3024            unwrapper(Command::parse("RCPT TO:<user@[IPv6:not-an-ipv6]>")),
3025            MaybePartialCommand::Partial {
3026                verb: CommandVerb::Rcpt,
3027                remainder: "<user@[IPv6:not-an-ipv6]>".into(),
3028                reason: PartialReason::InvalidRecipientAddress,
3029            }
3030        );
3031    }
3032
3033    #[test]
3034    fn test_rcpt_alone_is_partial() {
3035        k9::assert_equal!(
3036            unwrapper(Command::parse("RCPT")),
3037            MaybePartialCommand::Partial {
3038                verb: CommandVerb::Rcpt,
3039                remainder: "".into(),
3040                reason: PartialReason::Syntax,
3041            }
3042        );
3043    }
3044
3045    #[test]
3046    fn test_rcpt_with_garbage_is_partial() {
3047        k9::assert_equal!(
3048            unwrapper(Command::parse("RCPT garbage")),
3049            MaybePartialCommand::Partial {
3050                verb: CommandVerb::Rcpt,
3051                remainder: "garbage".into(),
3052                reason: PartialReason::Syntax,
3053            }
3054        );
3055    }
3056
3057    #[test]
3058    fn test_rcpt_to_invalid_address_syntax() {
3059        // Structurally valid "RCPT TO:" prefix but unparseable address
3060        k9::assert_equal!(
3061            unwrapper(Command::parse("RCPT TO:<not an address>")),
3062            MaybePartialCommand::Partial {
3063                verb: CommandVerb::Rcpt,
3064                remainder: "<not an address>".into(),
3065                reason: PartialReason::InvalidRecipientAddress,
3066            }
3067        );
3068    }
3069
3070    #[test]
3071    fn test_rcpt_to_valid_address_bad_params() {
3072        // Valid address but syntactically invalid ESMTP parameters → Syntax, not InvalidRecipientAddress
3073        k9::assert_equal!(
3074            unwrapper(Command::parse("RCPT TO:<valid@example.com> !!!garbage")),
3075            MaybePartialCommand::Partial {
3076                verb: CommandVerb::Rcpt,
3077                remainder: "!!!garbage".into(),
3078                reason: PartialReason::Syntax,
3079            }
3080        );
3081    }
3082
3083    #[test]
3084    fn test_rcpt_to_bad_address_with_params_is_invalid_address() {
3085        // Address is bad regardless of any trailing content → InvalidRecipientAddress
3086        k9::assert_equal!(
3087            unwrapper(Command::parse("RCPT TO:<bad address> NOTIFY=SUCCESS")),
3088            MaybePartialCommand::Partial {
3089                verb: CommandVerb::Rcpt,
3090                remainder: "<bad address> NOTIFY=SUCCESS".into(),
3091                reason: PartialReason::InvalidRecipientAddress,
3092            }
3093        );
3094    }
3095
3096    #[test]
3097    fn test_mail_from_invalid_address_syntax() {
3098        // Structurally valid "MAIL FROM:" prefix but unparseable address
3099        k9::assert_equal!(
3100            unwrapper(Command::parse("MAIL FROM:<not an address>")),
3101            MaybePartialCommand::Partial {
3102                verb: CommandVerb::Mail,
3103                remainder: "<not an address>".into(),
3104                reason: PartialReason::InvalidSenderAddress,
3105            }
3106        );
3107    }
3108
3109    #[test]
3110    fn test_mail_from_valid_address_bad_params() {
3111        // Valid address but syntactically invalid ESMTP parameters → Syntax, not InvalidSenderAddress
3112        k9::assert_equal!(
3113            unwrapper(Command::parse("MAIL FROM:<valid@example.com> !!!garbage")),
3114            MaybePartialCommand::Partial {
3115                verb: CommandVerb::Mail,
3116                remainder: "!!!garbage".into(),
3117                reason: PartialReason::Syntax,
3118            }
3119        );
3120    }
3121
3122    #[test]
3123    fn test_mail_from_null_sender_bad_params() {
3124        // Null sender is a valid reverse-path; bad params following it → Syntax
3125        k9::assert_equal!(
3126            unwrapper(Command::parse("MAIL FROM:<> !!!garbage")),
3127            MaybePartialCommand::Partial {
3128                verb: CommandVerb::Mail,
3129                remainder: "!!!garbage".into(),
3130                reason: PartialReason::Syntax,
3131            }
3132        );
3133    }
3134
3135    #[test]
3136    fn test_mail_from_bad_address_with_params_is_invalid_address() {
3137        // Address is bad regardless of any trailing content → InvalidSenderAddress
3138        k9::assert_equal!(
3139            unwrapper(Command::parse("MAIL FROM:<bad address> SIZE=1000")),
3140            MaybePartialCommand::Partial {
3141                verb: CommandVerb::Mail,
3142                remainder: "<bad address> SIZE=1000".into(),
3143                reason: PartialReason::InvalidSenderAddress,
3144            }
3145        );
3146    }
3147
3148    // ------------------------------------------------------------------
3149    // encode / encode_str tests
3150    // ------------------------------------------------------------------
3151
3152    /// Parse a command from a string, encode it, and assert the encoded
3153    /// output equals `expected`.  Then parse the encoded output and assert
3154    /// the result equals the original parsed command (round-trip).
3155    fn assert_encode(input: &str, expected: &str) {
3156        let cmd = match unwrapper(Command::parse(input)) {
3157            MaybePartialCommand::Full(c) => c,
3158            other => panic!("expected Full, got {other:?}"),
3159        };
3160        let encoded = cmd.encode();
3161        k9::assert_equal!(encoded, BString::from(expected));
3162        // Round-trip: parsing the encoded form must reproduce the command
3163        k9::assert_equal!(
3164            unwrapper(Command::parse(encoded.clone())),
3165            MaybePartialCommand::Full(cmd)
3166        );
3167    }
3168
3169    #[test]
3170    fn test_encode_ehlo_domain() {
3171        assert_encode("EHLO example.com", "EHLO example.com\r\n");
3172    }
3173
3174    #[test]
3175    fn test_encode_ehlo_ipv4() {
3176        assert_encode("EHLO [10.0.0.1]", "EHLO [10.0.0.1]\r\n");
3177    }
3178
3179    #[test]
3180    fn test_encode_ehlo_ipv6() {
3181        assert_encode("EHLO [IPv6:::1]", "EHLO [IPv6:::1]\r\n");
3182    }
3183
3184    #[test]
3185    fn test_encode_ehlo_tagged() {
3186        assert_encode("EHLO [future:something]", "EHLO [future:something]\r\n");
3187    }
3188
3189    #[test]
3190    fn test_encode_helo() {
3191        assert_encode("HELO mail.example.com", "HELO mail.example.com\r\n");
3192    }
3193
3194    #[test]
3195    fn test_encode_lhlo() {
3196        assert_encode("LHLO mail.example.com", "LHLO mail.example.com\r\n");
3197    }
3198
3199    #[test]
3200    fn test_encode_noop_none() {
3201        assert_encode("NOOP", "NOOP\r\n");
3202    }
3203
3204    #[test]
3205    fn test_encode_noop_some() {
3206        assert_encode("NOOP something", "NOOP something\r\n");
3207    }
3208
3209    #[test]
3210    fn test_encode_help_none() {
3211        assert_encode("HELP", "HELP\r\n");
3212    }
3213
3214    #[test]
3215    fn test_encode_help_some() {
3216        assert_encode("HELP MAIL", "HELP MAIL\r\n");
3217    }
3218
3219    #[test]
3220    fn test_encode_vrfy_none() {
3221        assert_encode("VRFY", "VRFY\r\n");
3222    }
3223
3224    #[test]
3225    fn test_encode_vrfy_some() {
3226        assert_encode("VRFY user", "VRFY user\r\n");
3227    }
3228
3229    #[test]
3230    fn test_encode_expn_none() {
3231        assert_encode("EXPN", "EXPN\r\n");
3232    }
3233
3234    #[test]
3235    fn test_encode_expn_some() {
3236        assert_encode("EXPN list", "EXPN list\r\n");
3237    }
3238
3239    #[test]
3240    fn test_encode_data() {
3241        assert_encode("DATA", "DATA\r\n");
3242    }
3243
3244    #[test]
3245    fn test_encode_data_dot() {
3246        // DataDot is never parsed — it is constructed programmatically.
3247        // Verify it encodes to exactly ".\r\n" (not ".\r\n\r\n").
3248        k9::assert_equal!(Command::DataDot.encode(), BString::from(".\r\n"));
3249    }
3250
3251    #[test]
3252    fn test_encode_rset() {
3253        assert_encode("RSET", "RSET\r\n");
3254    }
3255
3256    #[test]
3257    fn test_encode_quit() {
3258        assert_encode("QUIT", "QUIT\r\n");
3259    }
3260
3261    #[test]
3262    fn test_encode_starttls() {
3263        assert_encode("STARTTLS", "STARTTLS\r\n");
3264    }
3265
3266    #[test]
3267    fn test_encode_mail_from_path() {
3268        assert_encode(
3269            "MAIL FROM:<user@example.com>",
3270            "MAIL FROM:<user@example.com>\r\n",
3271        );
3272    }
3273
3274    #[test]
3275    fn test_encode_mail_from_null_sender() {
3276        assert_encode("MAIL FROM:<>", "MAIL FROM:<>\r\n");
3277    }
3278
3279    #[test]
3280    fn test_encode_mail_from_params() {
3281        assert_encode(
3282            "MAIL FROM:<user@example.com> SIZE=1000 BODY=8BITMIME",
3283            "MAIL FROM:<user@example.com> SIZE=1000 BODY=8BITMIME\r\n",
3284        );
3285    }
3286
3287    #[test]
3288    fn test_encode_mail_from_ipv4() {
3289        assert_encode(
3290            "MAIL FROM:<user@[10.0.0.1]>",
3291            "MAIL FROM:<user@[10.0.0.1]>\r\n",
3292        );
3293    }
3294
3295    #[test]
3296    fn test_encode_mail_from_ipv6() {
3297        assert_encode(
3298            "MAIL FROM:<user@[IPv6:::1]>",
3299            "MAIL FROM:<user@[IPv6:::1]>\r\n",
3300        );
3301    }
3302
3303    #[test]
3304    fn test_encode_rcpt_to_path() {
3305        assert_encode(
3306            "RCPT TO:<user@example.com>",
3307            "RCPT TO:<user@example.com>\r\n",
3308        );
3309    }
3310
3311    #[test]
3312    fn test_encode_rcpt_to_postmaster() {
3313        // Canonical encoding is capital-P Postmaster; parsing is case-insensitive
3314        let cmd = Command::RcptTo {
3315            address: ForwardPath::Postmaster,
3316            parameters: vec![],
3317        };
3318        k9::assert_equal!(cmd.encode(), BString::from("RCPT TO:<Postmaster>\r\n"));
3319        k9::assert_equal!(
3320            unwrapper(Command::parse(cmd.encode())),
3321            MaybePartialCommand::Full(cmd)
3322        );
3323    }
3324
3325    #[test]
3326    fn test_encode_rcpt_to_params() {
3327        assert_encode(
3328            "RCPT TO:<user@example.com> NOTIFY=SUCCESS",
3329            "RCPT TO:<user@example.com> NOTIFY=SUCCESS\r\n",
3330        );
3331    }
3332
3333    #[test]
3334    fn test_encode_auth_no_response() {
3335        assert_encode("AUTH PLAIN", "AUTH PLAIN\r\n");
3336    }
3337
3338    #[test]
3339    fn test_encode_auth_with_response() {
3340        assert_encode("AUTH PLAIN dXNlcjpwYXNz", "AUTH PLAIN dXNlcjpwYXNz\r\n");
3341    }
3342
3343    #[test]
3344    fn test_auth_redacts_for_logging_not_wire() {
3345        let cmd = Command::Auth {
3346            sasl_mech: "PLAIN".to_string(),
3347            initial_response: Some("dXNlcjpwYXNz".to_string()),
3348        };
3349        // encode is the wire form and must contain the real credential.
3350        k9::assert_equal!(cmd.encode(), BString::from("AUTH PLAIN dXNlcjpwYXNz\r\n"));
3351        // Logging and Debug must not.
3352        k9::assert_equal!(
3353            cmd.encode_for_logging(),
3354            BString::from("AUTH PLAIN [REDACTED]\r\n")
3355        );
3356        k9::assert_equal!(
3357            format!("{cmd:?}"),
3358            r#"Command("AUTH\x20PLAIN\x20[REDACTED]\r\n")"#
3359        );
3360    }
3361
3362    #[test]
3363    fn test_auth_no_response_has_nothing_to_redact() {
3364        let cmd = Command::Auth {
3365            sasl_mech: "PLAIN".to_string(),
3366            initial_response: None,
3367        };
3368        k9::assert_equal!(cmd.encode_for_logging(), BString::from("AUTH PLAIN\r\n"));
3369    }
3370
3371    #[test]
3372    fn test_non_auth_logging_matches_wire() {
3373        let cmd = Command::RcptTo {
3374            address: ForwardPath::Postmaster,
3375            parameters: vec![],
3376        };
3377        k9::assert_equal!(cmd.encode_for_logging(), cmd.encode());
3378    }
3379
3380    #[test]
3381    fn test_encode_xclient_single() {
3382        assert_encode(
3383            "XCLIENT NAME=foo.example.com",
3384            "XCLIENT NAME=foo.example.com\r\n",
3385        );
3386    }
3387
3388    #[test]
3389    fn test_encode_xclient_multiple() {
3390        assert_encode(
3391            "XCLIENT NAME=foo.example.com ADDR=10.0.0.1",
3392            "XCLIENT NAME=foo.example.com ADDR=10.0.0.1\r\n",
3393        );
3394    }
3395
3396    #[test]
3397    fn test_encode_xclient_xtext_roundtrip() {
3398        // '+40' in wire form decodes to '@'.
3399        // '@' (ASCII 64) is a valid xchar (range 33-126, excl. '+' and '='),
3400        // so it is NOT re-encoded as '+40' — it passes through unchanged.
3401        assert_encode(
3402            "XCLIENT NAME=user+40example.com",
3403            "XCLIENT NAME=user@example.com\r\n",
3404        );
3405    }
3406
3407    #[test]
3408    fn test_encode_unknown() {
3409        assert_encode("FOOBAR some args", "FOOBAR some args\r\n");
3410    }
3411
3412    #[test]
3413    fn test_encode_unknown_bare_verb() {
3414        assert_encode("FOOBAR", "FOOBAR\r\n");
3415    }
3416
3417    #[test]
3418    fn test_encode_mail_from_source_route_dropped() {
3419        // A command parsed with a non-empty at_domain_list encodes without the
3420        // source route (RFC 5321 says SHOULD NOT generate).  The re-parsed
3421        // result therefore has an empty at_domain_list.
3422        let parsed = unwrapper(Command::parse("MAIL FROM:<@route.example.com:user@host>"));
3423        let cmd = match parsed {
3424            MaybePartialCommand::Full(c) => c,
3425            other => panic!("expected Full, got {other:?}"),
3426        };
3427        let encoded = cmd.encode();
3428        k9::assert_equal!(encoded, BString::from("MAIL FROM:<user@host>\r\n"));
3429        // Re-parsing gives back the same mailbox but with an empty source route
3430        let expected = Mailbox {
3431            local_part: "user".into(),
3432            domain: Domain::DomainName("host".parse().unwrap()),
3433        };
3434        k9::assert_equal!(
3435            unwrapper(Command::parse(encoded)),
3436            MaybePartialCommand::Full(Command::MailFrom {
3437                address: expected.into(),
3438                parameters: vec![],
3439            })
3440        );
3441    }
3442
3443    // ------------------------------------------------------------------
3444    // RFC 6531 / non-ASCII tests
3445    // ------------------------------------------------------------------
3446
3447    /// Helper: parse a Full command from a known-good string.
3448    fn parse_full(input: &str) -> Command {
3449        match unwrapper(Command::parse(input)) {
3450            MaybePartialCommand::Full(c) => c,
3451            other => panic!("expected Full, got {other:?}"),
3452        }
3453    }
3454
3455    // --- Non-ASCII local parts ---
3456
3457    #[test]
3458    fn test_mail_from_utf8_local_part() {
3459        // ü = U+00FC, UTF-8 encoding [0xc3, 0xbc]
3460        let cmd = parse_full("MAIL FROM:<ü@example.com>");
3461        let mailbox = match cmd {
3462            Command::MailFrom {
3463                address: ReversePath::Path(path),
3464                ..
3465            } => path.mailbox,
3466            other => panic!("unexpected {other:?}"),
3467        };
3468        k9::assert_equal!(mailbox.local_part, String::from("ü"));
3469        k9::assert_equal!(
3470            mailbox.domain,
3471            Domain::DomainName("example.com".parse().unwrap())
3472        );
3473    }
3474
3475    #[test]
3476    fn test_mail_from_utf8_local_part_roundtrip() {
3477        // Non-ASCII local parts survive encode → parse unchanged because
3478        // encode_mailbox copies the raw bytes and parse stores them verbatim.
3479        let input = "MAIL FROM:<ü@example.com>";
3480        let cmd = parse_full(input);
3481        k9::assert_equal!(
3482            unwrapper(Command::parse(cmd.encode())),
3483            MaybePartialCommand::Full(cmd)
3484        );
3485    }
3486
3487    #[test]
3488    fn test_mail_from_quoted_utf8_local_part() {
3489        // UTF-8 characters are valid inside a quoted-string local part (RFC 6532).
3490        let cmd = parse_full("MAIL FROM:<\"ü\"@example.com>");
3491        let mailbox = match cmd {
3492            Command::MailFrom {
3493                address: ReversePath::Path(path),
3494                ..
3495            } => path.mailbox,
3496            other => panic!("unexpected {other:?}"),
3497        };
3498        // local_part stores the string including the surrounding quotes
3499        k9::assert_equal!(mailbox.local_part, String::from("\"ü\""));
3500    }
3501
3502    #[test]
3503    fn test_rcpt_to_utf8_local_part() {
3504        // CJK characters in the local part (RFC 6531 EAI)
3505        let cmd = parse_full("RCPT TO:<用户@example.com>");
3506        let mailbox = match cmd {
3507            Command::RcptTo {
3508                address: ForwardPath::Path(path),
3509                ..
3510            } => path.mailbox,
3511            other => panic!("unexpected {other:?}"),
3512        };
3513        k9::assert_equal!(mailbox.local_part, String::from("用户"));
3514    }
3515
3516    // --- U-label (non-ASCII) domain names ---
3517
3518    #[test]
3519    fn test_mail_from_u_label_domain() {
3520        // münchen.de — stored as normalized ASCII/punycode form
3521        let cmd = parse_full("MAIL FROM:<user@münchen.de>");
3522        let domain = match cmd {
3523            Command::MailFrom {
3524                address: ReversePath::Path(path),
3525                ..
3526            } => path.mailbox.domain,
3527            other => panic!("unexpected {other:?}"),
3528        };
3529        match domain {
3530            Domain::DomainName(ref s) => {
3531                k9::assert_equal!(s.as_str(), "xn--mnchen-3ya.de");
3532            }
3533            other => panic!("unexpected domain variant {other:?}"),
3534        }
3535    }
3536
3537    #[test]
3538    fn test_mail_from_u_label_domain_encode() {
3539        // encode_domain normalises U-labels to their ASCII/punycode form.
3540        // This is intentional: punycode is always safe for wire transmission.
3541        let cmd = parse_full("MAIL FROM:<user@münchen.de>");
3542        k9::assert_equal!(
3543            cmd.encode(),
3544            BString::from("MAIL FROM:<user@xn--mnchen-3ya.de>\r\n")
3545        );
3546    }
3547
3548    #[test]
3549    fn test_ehlo_u_label_domain() {
3550        // DomainString::as_str() returns the normalized ASCII/punycode form
3551        let cmd = parse_full("EHLO münchen.de");
3552        match cmd {
3553            Command::Ehlo(Domain::DomainName(ref s)) => {
3554                k9::assert_equal!(s.as_str(), "xn--mnchen-3ya.de");
3555            }
3556            other => panic!("unexpected {other:?}"),
3557        }
3558    }
3559
3560    #[test]
3561    fn test_ehlo_u_label_domain_encode() {
3562        // encode_domain normalises to punycode for EHLO as well
3563        let cmd = parse_full("EHLO münchen.de");
3564        k9::assert_equal!(cmd.encode(), BString::from("EHLO xn--mnchen-3ya.de\r\n"));
3565    }
3566
3567    // --- Non-ASCII ESMTP values (RFC 6531 §3.3: esmtp-value =/ UTF8-non-ASCII) ---
3568
3569    #[test]
3570    fn test_esmtp_value_utf8() {
3571        // A parameter value containing a non-ASCII UTF-8 character (ü = U+00FC)
3572        let cmd = parse_full("MAIL FROM:<u@h> PARAM=valüe");
3573        let params = match cmd {
3574            Command::MailFrom { parameters, .. } => parameters,
3575            other => panic!("unexpected {other:?}"),
3576        };
3577        k9::assert_equal!(params.len(), 1);
3578        k9::assert_equal!(params[0].name, "PARAM");
3579        k9::assert_equal!(params[0].value, Some("valüe".to_string()));
3580    }
3581
3582    #[test]
3583    fn test_esmtp_value_utf8_roundtrip() {
3584        // Non-ASCII ESMTP values are stored and re-encoded verbatim (raw bytes).
3585        let input = "MAIL FROM:<u@h> PARAM=valüe";
3586        let cmd = parse_full(input);
3587        k9::assert_equal!(
3588            cmd.encode(),
3589            BString::from("MAIL FROM:<u@h> PARAM=valüe\r\n")
3590        );
3591        k9::assert_equal!(
3592            unwrapper(Command::parse(cmd.encode())),
3593            MaybePartialCommand::Full(parse_full(input))
3594        );
3595    }
3596
3597    #[test]
3598    fn test_esmtp_value_utf8_only() {
3599        // A value consisting entirely of non-ASCII UTF-8 characters parses successfully
3600        let cmd = parse_full("MAIL FROM:<u@h> X=ünïcödé");
3601        let params = match cmd {
3602            Command::MailFrom { parameters, .. } => parameters,
3603            other => panic!("unexpected {other:?}"),
3604        };
3605        k9::assert_equal!(params[0].name, "X");
3606        k9::assert_equal!(params[0].value, Some("ünïcödé".to_string()));
3607    }
3608
3609    // --- Fallible conversion tests for MailPath ---
3610
3611    #[test]
3612    fn test_reverse_path_null_sender_try_into_mailpath_err() {
3613        use core::convert::TryInto;
3614        let null_sender = ReversePath::NullSender;
3615        let err: &'static str =
3616            <ReversePath as TryInto<MailPath>>::try_into(null_sender).unwrap_err();
3617        k9::assert_equal!(err, "Cannot convert NullSender to MailPath");
3618    }
3619
3620    #[test]
3621    fn test_forward_path_postmaster_try_into_mailpath_err() {
3622        use core::convert::TryInto;
3623        let postmaster = ForwardPath::Postmaster;
3624        let err: &'static str =
3625            <ForwardPath as TryInto<MailPath>>::try_into(postmaster).unwrap_err();
3626        k9::assert_equal!(err, "Cannot convert Postmaster to MailPath");
3627    }
3628
3629    #[test]
3630    fn test_reverse_path_try_from_null_sender_err() {
3631        let null_sender = ReversePath::NullSender;
3632        let err = MailPath::try_from(null_sender).unwrap_err();
3633        k9::assert_equal!(err, "Cannot convert NullSender to MailPath");
3634    }
3635
3636    #[test]
3637    fn test_forward_path_try_from_postmaster_err() {
3638        let postmaster = ForwardPath::Postmaster;
3639        let err = MailPath::try_from(postmaster).unwrap_err();
3640        k9::assert_equal!(err, "Cannot convert Postmaster to MailPath");
3641    }
3642
3643    // --- Fallible conversion tests for EnvelopeAddress ---
3644
3645    #[test]
3646    fn test_envelope_address_try_from_mailbox() {
3647        let mailbox = Mailbox {
3648            local_part: String::from("user"),
3649            domain: Domain::DomainName("example.com".parse().unwrap()),
3650        };
3651        let addr = EnvelopeAddress::from(mailbox);
3652        match addr {
3653            EnvelopeAddress::Path(path) => {
3654                k9::assert_equal!(path.mailbox.local_part(), "user");
3655                k9::assert_equal!(
3656                    path.mailbox.domain,
3657                    Domain::DomainName("example.com".parse().unwrap())
3658                );
3659            }
3660            _ => panic!("Expected Path variant"),
3661        }
3662    }
3663
3664    #[test]
3665    fn test_envelope_address_try_from_null_err() {
3666        let addr = EnvelopeAddress::Null;
3667        let err = Mailbox::try_from(addr).unwrap_err();
3668        k9::assert_equal!(err, "Cannot convert Null to Mailbox");
3669    }
3670
3671    #[test]
3672    fn test_envelope_address_try_from_postmaster_err() {
3673        let addr = EnvelopeAddress::Postmaster;
3674        let err = Mailbox::try_from(addr).unwrap_err();
3675        k9::assert_equal!(err, "Cannot convert Postmaster to Mailbox");
3676    }
3677
3678    #[test]
3679    fn test_envelope_address_try_from_null_to_reverse_path() {
3680        let addr = EnvelopeAddress::Null;
3681        let result = ReversePath::try_from(addr).unwrap();
3682        k9::assert_equal!(result, ReversePath::NullSender);
3683    }
3684
3685    #[test]
3686    fn test_envelope_address_try_from_postmaster_to_forward_path() {
3687        let addr = EnvelopeAddress::Postmaster;
3688        let result = ForwardPath::try_from(addr).unwrap();
3689        k9::assert_equal!(result, ForwardPath::Postmaster);
3690    }
3691
3692    // --- Fallible conversion tests for MailPath ---
3693
3694    #[test]
3695    fn test_envelope_address_try_into_mailpath_null_err() {
3696        let addr = EnvelopeAddress::Null;
3697        let err = MailPath::try_from(addr).unwrap_err();
3698        k9::assert_equal!(err, "Cannot convert Null to MailPath");
3699    }
3700
3701    #[test]
3702    fn test_envelope_address_try_into_mailpath_postmaster_err() {
3703        let addr = EnvelopeAddress::Postmaster;
3704        let err = MailPath::try_from(addr).unwrap_err();
3705        k9::assert_equal!(err, "Cannot convert Postmaster to MailPath");
3706    }
3707
3708    #[test]
3709    fn test_reverse_path_try_into_forward_path_null_sender_err() {
3710        let rp = ReversePath::NullSender;
3711        let err = ForwardPath::try_from(rp).unwrap_err();
3712        k9::assert_equal!(err, "Cannot convert NullSender to ForwardPath");
3713    }
3714
3715    #[test]
3716    fn test_forward_path_try_into_reverse_path_postmaster_err() {
3717        let fp = ForwardPath::Postmaster;
3718        let err = ReversePath::try_from(fp).unwrap_err();
3719        k9::assert_equal!(err, "Cannot convert Postmaster to ReversePath");
3720    }
3721
3722    // --- Infallible conversion tests for Mailbox ---
3723
3724    #[test]
3725    fn test_mailbox_into_mailpath() {
3726        let mailbox = Mailbox {
3727            local_part: String::from("user"),
3728            domain: Domain::DomainName("example.com".parse().unwrap()),
3729        };
3730        let path: MailPath = mailbox.into();
3731        assert!(path.at_domain_list.is_empty());
3732        k9::assert_equal!(path.mailbox.local_part(), "user");
3733    }
3734
3735    #[test]
3736    fn test_mailbox_into_envelope_address() {
3737        let mailbox = Mailbox {
3738            local_part: String::from("user"),
3739            domain: Domain::DomainName("example.com".parse().unwrap()),
3740        };
3741        let addr: EnvelopeAddress = mailbox.into();
3742        match addr {
3743            EnvelopeAddress::Path(path) => {
3744                k9::assert_equal!(path.mailbox.local_part(), "user");
3745            }
3746            _ => panic!("Expected Path variant"),
3747        }
3748    }
3749
3750    #[test]
3751    fn test_mailbox_into_reverse_path() {
3752        let mailbox = Mailbox {
3753            local_part: String::from("user"),
3754            domain: Domain::DomainName("example.com".parse().unwrap()),
3755        };
3756        let path: ReversePath = mailbox.into();
3757        match path {
3758            ReversePath::Path(p) => {
3759                k9::assert_equal!(p.mailbox.local_part(), "user");
3760            }
3761            _ => panic!("Expected Path variant"),
3762        }
3763    }
3764
3765    #[test]
3766    fn test_mailbox_into_forward_path() {
3767        let mailbox = Mailbox {
3768            local_part: String::from("user"),
3769            domain: Domain::DomainName("example.com".parse().unwrap()),
3770        };
3771        let path: ForwardPath = mailbox.into();
3772        match path {
3773            ForwardPath::Path(p) => {
3774                k9::assert_equal!(p.mailbox.local_part(), "user");
3775            }
3776            _ => panic!("Expected Path variant"),
3777        }
3778    }
3779
3780    // --- Round-trip conversion tests ---
3781
3782    #[test]
3783    fn test_mailbox_roundtrip() {
3784        let original = Mailbox {
3785            local_part: String::from("user"),
3786            domain: Domain::DomainName("example.com".parse().unwrap()),
3787        };
3788        let path: MailPath = original.clone().into();
3789        let mailbox: Mailbox = path.mailbox.into();
3790        k9::assert_equal!(original.local_part(), mailbox.local_part());
3791        k9::assert_equal!(original.domain, mailbox.domain);
3792    }
3793
3794    #[test]
3795    fn test_reverse_path_to_forward_path() {
3796        let path = MailPath {
3797            at_domain_list: vec!["route.com".into()],
3798            mailbox: Mailbox {
3799                local_part: String::from("user"),
3800                domain: Domain::DomainName("example.com".parse().unwrap()),
3801            },
3802        };
3803        let rp = ReversePath::Path(path.clone());
3804        let fp: ForwardPath = rp.try_into().unwrap();
3805        match fp {
3806            ForwardPath::Path(p) => {
3807                k9::assert_equal!(p.mailbox.local_part(), "user");
3808            }
3809            _ => panic!("Expected Path variant"),
3810        }
3811    }
3812
3813    // --- Debug tests for MailPath ---
3814
3815    #[test]
3816    fn test_mailpath_debug_simple() {
3817        let mailbox = Mailbox {
3818            local_part: "someone".into(),
3819            domain: Domain::DomainName("example.com".parse().unwrap()),
3820        };
3821        let path: MailPath = mailbox.into();
3822        let debug_str = format!("{:?}", path);
3823        k9::assert_equal!(debug_str, r#"MailPath("someone@example.com")"#);
3824    }
3825
3826    #[test]
3827    fn test_mailpath_debug_with_at_domain_list() {
3828        let path = MailPath {
3829            at_domain_list: vec!["route.example.com".into()],
3830            mailbox: Mailbox {
3831                local_part: "user".into(),
3832                domain: Domain::DomainName("host".parse().unwrap()),
3833            },
3834        };
3835        let debug_str = format!("{:?}", path);
3836        k9::assert_equal!(debug_str, r#"MailPath("@route.example.com:user@host")"#);
3837    }
3838
3839    #[test]
3840    fn test_mailpath_debug_multiple_at_domains() {
3841        let path = MailPath {
3842            at_domain_list: vec!["hosta.int".into(), "jkl.org".into()],
3843            mailbox: Mailbox {
3844                local_part: "userc".into(),
3845                domain: Domain::DomainName("d.bar.org".parse().unwrap()),
3846            },
3847        };
3848        let debug_str = format!("{:?}", path);
3849        k9::assert_equal!(
3850            debug_str,
3851            r#"MailPath("@hosta.int,@jkl.org:userc@d.bar.org")"#
3852        );
3853    }
3854
3855    #[test]
3856    fn test_mailpath_debug_non_ascii_utf8() {
3857        // UTF-8 character ü (U+00FC) which is non-ASCII but valid UTF-8
3858        // should be preserved as-is in debug output
3859        let mailbox = Mailbox {
3860            local_part: "üser".into(),
3861            domain: Domain::DomainName("example.com".parse().unwrap()),
3862        };
3863        let path: MailPath = mailbox.into();
3864        let debug_str = format!("{:?}", path);
3865        // ü is valid UTF-8, should appear as-is
3866        k9::assert_equal!(debug_str, r#"MailPath("üser@example.com")"#);
3867    }
3868
3869    #[test]
3870    fn test_mailpath_debug_with_ipv4() {
3871        let mailbox = Mailbox {
3872            local_part: "user".into(),
3873            domain: Domain::V4("10.0.0.1".parse().unwrap()),
3874        };
3875        let path: MailPath = mailbox.into();
3876        let debug_str = format!("{:?}", path);
3877        // IPv4 literals must be in brackets per RFC 5321
3878        k9::assert_equal!(debug_str, r#"MailPath("user@[10.0.0.1]")"#);
3879    }
3880
3881    #[test]
3882    fn test_mailpath_debug_with_ipv6() {
3883        let mailbox = Mailbox {
3884            local_part: "user".into(),
3885            domain: Domain::V6("::1".parse().unwrap()),
3886        };
3887        let path: MailPath = mailbox.into();
3888        let debug_str = format!("{:?}", path);
3889        // IPv6 literals must be in brackets with IPv6: prefix per RFC 5321
3890        k9::assert_equal!(debug_str, r#"MailPath("user@[IPv6:::1]")"#);
3891    }
3892
3893    #[test]
3894    fn test_mailpath_debug_with_tagged_literal() {
3895        let mailbox = Mailbox {
3896            local_part: "user".into(),
3897            domain: Domain::Tagged("future:something".into()),
3898        };
3899        let path: MailPath = mailbox.into();
3900        let debug_str = format!("{:?}", path);
3901        // Tagged literals must be in brackets per RFC 5321
3902        k9::assert_equal!(debug_str, r#"MailPath("user@[future:something]")"#);
3903    }
3904    #[test]
3905    fn test_envelope_address_debug_null() {
3906        let addr = EnvelopeAddress::Null;
3907        let debug_str = format!("{:?}", addr);
3908        k9::assert_equal!(debug_str, "<>");
3909    }
3910
3911    #[test]
3912    fn test_envelope_address_debug_postmaster() {
3913        let addr = EnvelopeAddress::Postmaster;
3914        let debug_str = format!("{:?}", addr);
3915        k9::assert_equal!(debug_str, "<Postmaster>");
3916    }
3917
3918    #[test]
3919    fn test_envelope_address_postmaster_full_address() {
3920        let postmaster = EnvelopeAddress::parse(r#"postmaster@example.com"#).unwrap();
3921        k9::assert_equal!(postmaster.to_string(), r#"postmaster@example.com"#);
3922    }
3923
3924    #[test]
3925    fn test_envelope_address_debug_path_with_non_ascii_utf8() {
3926        // UTF-8 character ü (U+00FC) which is non-ASCII but valid UTF-8
3927        // should be preserved as-is in debug output
3928        let path = MailPath {
3929            at_domain_list: vec!["example.com".into()],
3930            mailbox: Mailbox {
3931                local_part: String::from("üser"),
3932                domain: Domain::DomainName("example.com".parse().unwrap()),
3933            },
3934        };
3935        let addr = EnvelopeAddress::Path(path);
3936        let debug_str = format!("{:?}", addr);
3937        // ü is valid UTF-8, should appear as-is
3938        // EnvelopeAddress::Display drops the source route per RFC 5321 §4.4,
3939        // so the Debug output also omits it
3940        k9::assert_equal!(debug_str, r#"<üser@example.com>"#);
3941    }
3942
3943    #[test]
3944    fn test_envelope_address_display_drops_source_route() {
3945        let path = MailPath {
3946            at_domain_list: vec!["hosta.int".into(), "jkl.org".into()],
3947            mailbox: Mailbox {
3948                local_part: String::from("userc"),
3949                domain: Domain::DomainName("d.bar.org".parse().unwrap()),
3950            },
3951        };
3952        let addr = EnvelopeAddress::Path(path);
3953        // Display drops the source route
3954        k9::assert_equal!(addr.to_string(), "userc@d.bar.org");
3955        // MailPath::Display still includes it (for diagnostics)
3956        if let EnvelopeAddress::Path(ref p) = addr {
3957            k9::assert_equal!(p.to_string(), "@hosta.int,@jkl.org:userc@d.bar.org");
3958        }
3959
3960        // Quoted local part containing @
3961        let path2 = MailPath {
3962            at_domain_list: vec!["relay.example".into()],
3963            mailbox: Mailbox {
3964                local_part: String::from(r#""info@""#),
3965                domain: Domain::DomainName("example.com".parse().unwrap()),
3966            },
3967        };
3968        let addr2 = EnvelopeAddress::Path(path2);
3969        k9::assert_equal!(addr2.to_string(), r#""info@"@example.com"#);
3970        if let EnvelopeAddress::Path(ref p) = addr2 {
3971            k9::assert_equal!(p.to_string(), r#"@relay.example:"info@"@example.com"#);
3972        }
3973    }
3974
3975    #[test]
3976    fn test_envelope_address_serde_roundtrip_with_source_route() {
3977        // Parse a MAIL FROM with source route
3978        let cmd = unwrapper(Command::parse(
3979            "MAIL FROM:<@hosta.int,@jkl.org:userc@d.bar.org>",
3980        ));
3981        if let MaybePartialCommand::Full(Command::MailFrom { address, .. }) = cmd {
3982            if let ReversePath::Path(mail_path) = address {
3983                let addr = EnvelopeAddress::Path(mail_path);
3984                // Serialize (via Into<String> which uses Display)
3985                let serialized = serde_json::to_string(&addr).unwrap();
3986                k9::assert_equal!(serialized, r#""userc@d.bar.org""#);
3987                // Deserialize back
3988                let deserialized: EnvelopeAddress = serde_json::from_str(&serialized).unwrap();
3989                // Roundtrip succeeds (source route is dropped)
3990                k9::assert_equal!(deserialized.to_string(), "userc@d.bar.org");
3991            } else {
3992                panic!("Expected Path variant");
3993            }
3994        } else {
3995            panic!("Expected Full MailFrom");
3996        }
3997
3998        // Quoted local part with @ inside
3999        let cmd2 = unwrapper(Command::parse(
4000            r#"MAIL FROM:<@relay.example:"info@"@example.com>"#,
4001        ));
4002        if let MaybePartialCommand::Full(Command::MailFrom { address, .. }) = cmd2 {
4003            if let ReversePath::Path(mail_path) = address {
4004                let addr = EnvelopeAddress::Path(mail_path);
4005                let serialized = serde_json::to_string(&addr).unwrap();
4006                k9::assert_equal!(serialized, r#""\"info@\"@example.com""#);
4007                let deserialized: EnvelopeAddress = serde_json::from_str(&serialized).unwrap();
4008                k9::assert_equal!(deserialized.to_string(), r#""info@"@example.com"#);
4009            } else {
4010                panic!("Expected Path variant");
4011            }
4012        } else {
4013            panic!("Expected Full MailFrom");
4014        }
4015    }
4016
4017    #[test]
4018    fn test_envelope_address_rejects_obs_local_part() {
4019        // obs-local-part (RFC 5322 §4.4) mixes quoted-strings and atoms
4020        // separated by dots. This is not valid in RFC 5321 envelope addresses.
4021        EnvelopeAddress::parse(r#""first".last@example.com"#).unwrap_err();
4022        EnvelopeAddress::parse(r#"first."last"@example.com"#).unwrap_err();
4023        EnvelopeAddress::parse(r#""first"."last"@example.com"#).unwrap_err();
4024    }
4025
4026    #[test]
4027    fn test_envelope_address_null_sender_roundtrip() {
4028        // Regression test for <https://github.com/KumoCorp/kumomta/issues/511>
4029        // The Display impl for EnvelopeAddress::Null produces "",
4030        // and parse("") must return EnvelopeAddress::Null to round-trip correctly.
4031        let null = EnvelopeAddress::Null;
4032
4033        // Display produces empty string
4034        k9::assert_equal!(null.to_string(), "");
4035
4036        // Parsing empty string returns Null
4037        let parsed = EnvelopeAddress::parse("").unwrap();
4038        k9::assert_equal!(parsed, EnvelopeAddress::Null);
4039
4040        // Verify serde round-trip works
4041        let json = serde_json::to_string(&null).unwrap();
4042        k9::assert_equal!(json, "\"\"");
4043        let deserialized: EnvelopeAddress = serde_json::from_str(&json).unwrap();
4044        k9::assert_equal!(deserialized, EnvelopeAddress::Null);
4045    }
4046}