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