rfc5321/
parser.rs

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