mailparsing/
rfc5322_parser.rs

1use crate::headermap::EncodeHeaderValue;
2use crate::{MailParsingError, Result, SharedString};
3use bstr::{BStr, BString, ByteSlice, ByteVec};
4use charset_normalizer_rs::Encoding;
5use nom::branch::alt;
6use nom::bytes::complete::{take_while, take_while1, take_while_m_n};
7use nom::combinator::{all_consuming, map, opt, recognize};
8use nom::error::context;
9use nom::multi::{many0, many1, separated_list1};
10use nom::sequence::{delimited, preceded, separated_pair, terminated};
11use nom::Parser as _;
12use nom_utils::{
13    explain_nom, make_context_error, make_span, tag, utf8_non_ascii, IResult, ParseError, Span,
14};
15use serde::{Deserialize, Serialize};
16use serde_with::{serde_as, DeserializeAs, SerializeAs};
17use std::collections::BTreeMap;
18use std::fmt::Debug;
19
20/// A `serde_with` adapter that serializes `BString` as a JSON string when
21/// the value is valid UTF-8, falling back to the default byte-array
22/// representation otherwise.
23pub struct BStringUtf8;
24
25impl SerializeAs<BString> for BStringUtf8 {
26    fn serialize_as<S>(value: &BString, serializer: S) -> std::result::Result<S::Ok, S::Error>
27    where
28        S: serde::Serializer,
29    {
30        match std::str::from_utf8(value.as_bytes()) {
31            Ok(s) => serializer.serialize_str(s),
32            Err(_) => value.serialize(serializer),
33        }
34    }
35}
36
37impl<'de> DeserializeAs<'de, BString> for BStringUtf8 {
38    fn deserialize_as<D>(deserializer: D) -> std::result::Result<BString, D::Error>
39    where
40        D: serde::Deserializer<'de>,
41    {
42        BString::deserialize(deserializer)
43    }
44}
45
46impl MailParsingError {
47    pub fn from_nom(input: Span, err: nom::Err<ParseError<Span<'_>>>) -> Self {
48        MailParsingError::HeaderParse(explain_nom(input, err))
49    }
50}
51
52// ctl = { '\u{00}'..'\u{1f}' | "\u{7f}" }
53fn is_ctl(c: u8) -> bool {
54    match c {
55        b'\x00'..=b'\x1f' | b'\x7f' => true,
56        _ => false,
57    }
58}
59
60fn not_angle(c: u8) -> bool {
61    match c {
62        b'<' | b'>' => false,
63        _ => true,
64    }
65}
66
67// char = { '\u{01}'..'\u{7f}' }
68fn is_char(c: u8) -> bool {
69    match c {
70        0x01..=0x7f => true,
71        _ => false,
72    }
73}
74
75fn is_especial(c: u8) -> bool {
76    match c {
77        b'(' | b')' | b'<' | b'>' | b'@' | b',' | b';' | b':' | b'/' | b'[' | b']' | b'?'
78        | b'.' | b'=' => true,
79        _ => false,
80    }
81}
82
83fn is_token(c: u8) -> bool {
84    is_char(c) && c != b' ' && !is_especial(c) && !is_ctl(c)
85}
86
87// vchar = { '\u{21}'..'\u{7e}' | utf8_non_ascii }
88fn is_vchar_ascii(c: u8) -> bool {
89    (0x21..=0x7e).contains(&c)
90}
91
92fn is_atext_ascii(c: u8) -> bool {
93    match c {
94        b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'/' | b'=' | b'?'
95        | b'^' | b'_' | b'`' | b'{' | b'|' | b'}' | b'~' => true,
96        c => c.is_ascii_alphanumeric(),
97    }
98}
99
100/// Byte-level predicate for atext, including UTF-8 continuation/leading bytes.
101/// Used for non-parser checks (e.g., needs_quoting). For parsing, use the
102/// `atext` parser which properly validates UTF-8 via `utf8_non_ascii`.
103fn is_atext(c: u8) -> bool {
104    is_atext_ascii(c) || c >= 0x80
105}
106
107fn atext(input: Span) -> IResult<Span, Span> {
108    context(
109        "atext",
110        recognize(many1(alt((take_while1(is_atext_ascii), utf8_non_ascii)))),
111    )
112    .parse(input)
113}
114
115fn is_obs_no_ws_ctl(c: u8) -> bool {
116    match c {
117        0x01..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f => true,
118        _ => false,
119    }
120}
121
122fn is_obs_ctext(c: u8) -> bool {
123    is_obs_no_ws_ctl(c)
124}
125
126// ctext = { '\u{21}'..'\u{27}' | '\u{2a}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | obs_ctext | utf8_non_ascii }
127fn is_ctext_ascii(c: u8) -> bool {
128    match c {
129        0x21..=0x27 | 0x2a..=0x5b | 0x5d..=0x7e => true,
130        c => is_obs_ctext(c),
131    }
132}
133
134// dtext = { '\u{21}'..'\u{5a}' | '\u{5e}'..'\u{7e}' | obs_dtext | utf8_non_ascii }
135// obs_dtext = { obs_no_ws_ctl | quoted_pair }
136fn is_dtext_ascii(c: u8) -> bool {
137    match c {
138        0x21..=0x5a | 0x5e..=0x7e => true,
139        c => is_obs_no_ws_ctl(c),
140    }
141}
142
143// qtext = { "\u{21}" | '\u{23}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | obs_qtext | utf8_non_ascii }
144// obs_qtext = { obs_no_ws_ctl }
145fn is_qtext_ascii(c: u8) -> bool {
146    match c {
147        0x21 | 0x23..=0x5b | 0x5d..=0x7e => true,
148        c => is_obs_no_ws_ctl(c),
149    }
150}
151
152/// Byte-level predicate for qtext, including UTF-8 continuation/leading bytes.
153/// Used for non-parser checks. For parsing, use `qcontent` which validates
154/// UTF-8 via `utf8_non_ascii`.
155fn is_qtext(c: u8) -> bool {
156    is_qtext_ascii(c) || c >= 0x80
157}
158
159fn is_tspecial(c: u8) -> bool {
160    match c {
161        b'(' | b')' | b'<' | b'>' | b'@' | b',' | b';' | b':' | b'\\' | b'"' | b'/' | b'['
162        | b']' | b'?' | b'=' => true,
163        _ => false,
164    }
165}
166
167fn is_attribute_char(c: u8) -> bool {
168    match c {
169        b' ' | b'*' | b'\'' | b'%' => false,
170        _ => is_char(c) && !is_ctl(c) && !is_tspecial(c),
171    }
172}
173
174fn wsp(input: Span) -> IResult<Span, Span> {
175    context("wsp", take_while1(|c| c == b' ' || c == b'\t')).parse(input)
176}
177
178fn newline(input: Span) -> IResult<Span, Span> {
179    context("newline", recognize(preceded(opt(tag("\r")), tag("\n")))).parse(input)
180}
181
182// fws = { ((wsp* ~ "\r"? ~ "\n")* ~ wsp+) | obs_fws }
183fn fws(input: Span) -> IResult<Span, Span> {
184    context(
185        "fws",
186        alt((
187            recognize(preceded(many0(preceded(many0(wsp), newline)), many1(wsp))),
188            obs_fws,
189        )),
190    )
191    .parse(input)
192}
193
194// obs_fws = { wsp+ ~ ("\r"? ~ "\n" ~ wsp+)* }
195fn obs_fws(input: Span) -> IResult<Span, Span> {
196    context(
197        "obs_fws",
198        recognize(preceded(many1(wsp), preceded(newline, many1(wsp)))),
199    )
200    .parse(input)
201}
202
203// mailbox_list = { (mailbox ~ ("," ~ mailbox)*) | obs_mbox_list }
204fn mailbox_list(input: Span) -> IResult<Span, MailboxList> {
205    let (loc, mailboxes) = context(
206        "mailbox_list",
207        alt((separated_list1(tag(","), mailbox), obs_mbox_list)),
208    )
209    .parse(input)?;
210    Ok((loc, MailboxList(mailboxes)))
211}
212
213// obs_mbox_list = {  ((cfws? ~ ",")* ~ mailbox ~ ("," ~ (mailbox | cfws))*)+ }
214fn obs_mbox_list(input: Span) -> IResult<Span, Vec<Mailbox>> {
215    let (loc, entries) = context(
216        "obs_mbox_list",
217        many1(preceded(
218            many0(preceded(opt(cfws), tag(","))),
219            (
220                mailbox,
221                many0(preceded(
222                    tag(","),
223                    alt((map(mailbox, Some), map(cfws, |_| None))),
224                )),
225            ),
226        )),
227    )
228    .parse(input)?;
229
230    let mut result: Vec<Mailbox> = vec![];
231
232    for (first, boxes) in entries {
233        result.push(first);
234        for b in boxes {
235            if let Some(m) = b {
236                result.push(m);
237            }
238        }
239    }
240
241    Ok((loc, result))
242}
243
244// mailbox = { name_addr | addr_spec }
245fn mailbox(input: Span) -> IResult<Span, Mailbox> {
246    if let Ok(res) = name_addr(input) {
247        Ok(res)
248    } else {
249        let (loc, address) = context("mailbox", addr_spec).parse(input)?;
250        Ok((
251            loc,
252            Mailbox {
253                name: None,
254                address,
255            },
256        ))
257    }
258}
259
260// address_list = { (address ~ ("," ~ address)*) | obs_addr_list }
261fn address_list(input: Span) -> IResult<Span, AddressList> {
262    context(
263        "address_list",
264        alt((
265            map(separated_list1(tag(","), address), AddressList),
266            obs_address_list,
267        )),
268    )
269    .parse(input)
270}
271
272// obs_addr_list = {  ((cfws? ~ ",")* ~ address ~ ("," ~ (address | cfws))*)+ }
273fn obs_address_list(input: Span) -> IResult<Span, AddressList> {
274    let (loc, entries) = context(
275        "obs_address_list",
276        many1(preceded(
277            many0(preceded(opt(cfws), tag(","))),
278            (
279                address,
280                many0(preceded(
281                    tag(","),
282                    alt((map(address, Some), map(cfws, |_| None))),
283                )),
284            ),
285        )),
286    )
287    .parse(input)?;
288
289    let mut result: Vec<Address> = vec![];
290
291    for (first, boxes) in entries {
292        result.push(first);
293        for b in boxes {
294            if let Some(m) = b {
295                result.push(m);
296            }
297        }
298    }
299
300    Ok((loc, AddressList(result)))
301}
302
303// address = { mailbox | group }
304fn address(input: Span) -> IResult<Span, Address> {
305    context("address", alt((map(mailbox, Address::Mailbox), group))).parse(input)
306}
307
308// group = { display_name ~ ":" ~ group_list? ~ ";" ~ cfws? }
309fn group(input: Span) -> IResult<Span, Address> {
310    let (loc, (name, _, group_list, _)) = context(
311        "group",
312        terminated(
313            (display_name, tag(":"), opt(group_list), tag(";")),
314            opt(cfws),
315        ),
316    )
317    .parse(input)?;
318    Ok((
319        loc,
320        Address::Group {
321            name,
322            entries: group_list.unwrap_or_else(|| MailboxList(vec![])),
323        },
324    ))
325}
326
327// group_list = { mailbox_list | cfws | obs_group_list }
328fn group_list(input: Span) -> IResult<Span, MailboxList> {
329    context(
330        "group_list",
331        alt((
332            mailbox_list,
333            map(cfws, |_| MailboxList(vec![])),
334            obs_group_list,
335        )),
336    )
337    .parse(input)
338}
339
340// obs_group_list = @{ (cfws? ~ ",")+ ~ cfws? }
341fn obs_group_list(input: Span) -> IResult<Span, MailboxList> {
342    context(
343        "obs_group_list",
344        map(
345            terminated(many1(preceded(opt(cfws), tag(","))), opt(cfws)),
346            |_| MailboxList(vec![]),
347        ),
348    )
349    .parse(input)
350}
351
352// name_addr = { display_name? ~ angle_addr }
353fn name_addr(input: Span) -> IResult<Span, Mailbox> {
354    context(
355        "name_addr",
356        map((opt(display_name), angle_addr), |(name, address)| Mailbox {
357            name,
358            address,
359        }),
360    )
361    .parse(input)
362}
363
364// display_name = { phrase }
365fn display_name(input: Span) -> IResult<Span, String> {
366    context("display_name", phrase).parse(input)
367}
368
369// phrase = { (encoded_word | word)+ | obs_phrase }
370// obs_phrase = { (encoded_word | word) ~ (encoded_word | word | dot | cfws)* }
371fn phrase(input: Span) -> IResult<Span, String> {
372    let (loc, (a, b)): (Span, (BString, Vec<Option<BString>>)) = context(
373        "phrase",
374        (
375            alt((encoded_word, word)),
376            many0(alt((
377                map(cfws, |_| None),
378                map(encoded_word, Option::Some),
379                map(word, Option::Some),
380                map(tag("."), |_dot| Some(BString::from("."))),
381            ))),
382        ),
383    )
384    .parse(input)?;
385    let mut result = a;
386    for item in b {
387        if let Some(item) = item {
388            result.push(b' ');
389            result.push_str(item);
390        }
391    }
392    // SAFETY: all sub-parsers (word, encoded_word) produce only
393    // validated UTF-8 via utf8_non_ascii or charset decoding.
394    Ok((
395        loc,
396        String::from_utf8(result.into())
397            .expect("phrase sub-parsers should only produce valid UTF-8"),
398    ))
399}
400
401// angle_addr = { cfws? ~ "<" ~ addr_spec ~ ">" ~ cfws? | obs_angle_addr }
402fn angle_addr(input: Span) -> IResult<Span, AddrSpec> {
403    context(
404        "angle_addr",
405        alt((
406            delimited(
407                opt(cfws),
408                delimited(tag("<"), addr_spec, tag(">")),
409                opt(cfws),
410            ),
411            obs_angle_addr,
412        )),
413    )
414    .parse(input)
415}
416
417// obs_angle_addr = { cfws? ~ "<" ~ obs_route ~ addr_spec ~ ">" ~ cfws? }
418fn obs_angle_addr(input: Span) -> IResult<Span, AddrSpec> {
419    context(
420        "obs_angle_addr",
421        delimited(
422            opt(cfws),
423            delimited(tag("<"), preceded(obs_route, addr_spec), tag(">")),
424            opt(cfws),
425        ),
426    )
427    .parse(input)
428}
429
430// obs_route = { obs_domain_list ~ ":" }
431// obs_domain_list = { (cfws | ",")* ~ "@" ~ domain ~ ("," ~ cfws? ~ ("@" ~ domain)?)* }
432fn obs_route(input: Span) -> IResult<Span, Span> {
433    context(
434        "obs_route",
435        recognize(terminated(
436            (
437                many0(alt((cfws, recognize(tag(","))))),
438                recognize(tag("@")),
439                recognize(domain),
440                many0((tag(","), opt(cfws), opt((tag("@"), domain)))),
441            ),
442            tag(":"),
443        )),
444    )
445    .parse(input)
446}
447
448// addr_spec = { local_part ~ "@" ~ domain }
449fn addr_spec(input: Span) -> IResult<Span, AddrSpec> {
450    let (loc, (local_part, domain)) =
451        context("addr_spec", separated_pair(local_part, tag("@"), domain)).parse(input)?;
452
453    // local_part and domain parsers accept only ASCII or validated
454    // UTF-8 (via utf8_non_ascii), so this conversion is infallible.
455    let to_string = |b: BString| -> String {
456        String::from_utf8(b.into())
457            .expect("local_part/domain parsers should only produce valid UTF-8")
458    };
459
460    Ok((
461        loc,
462        AddrSpec {
463            local_part: to_string(local_part),
464            domain: to_string(domain),
465        },
466    ))
467}
468
469fn parse_with<'a, R, F>(text: &'a [u8], parser: F) -> Result<R>
470where
471    F: Fn(Span<'a>) -> IResult<'a, Span<'a>, R>,
472{
473    let input = make_span(text);
474    let (_, result) = all_consuming(parser)
475        .parse(input)
476        .map_err(|err| MailParsingError::from_nom(input, err))?;
477    Ok(result)
478}
479
480#[cfg(test)]
481#[test]
482fn test_addr_spec() {
483    k9::snapshot!(
484        parse_with("darth.vader@a.galaxy.far.far.away".as_bytes(), addr_spec),
485        r#"
486Ok(
487    AddrSpec {
488        local_part: "darth.vader",
489        domain: "a.galaxy.far.far.away",
490    },
491)
492"#
493    );
494
495    k9::snapshot!(
496        parse_with(
497            "\"darth.vader\"@a.galaxy.far.far.away".as_bytes(),
498            addr_spec
499        ),
500        r#"
501Ok(
502    AddrSpec {
503        local_part: "darth.vader",
504        domain: "a.galaxy.far.far.away",
505    },
506)
507"#
508    );
509
510    k9::snapshot!(
511        parse_with(
512            "\"darth\".vader@a.galaxy.far.far.away".as_bytes(),
513            addr_spec
514        ),
515        r#"
516Ok(
517    AddrSpec {
518        local_part: "darth.vader",
519        domain: "a.galaxy.far.far.away",
520    },
521)
522"#
523    );
524
525    k9::snapshot!(
526        parse_with("a@[127.0.0.1]".as_bytes(), addr_spec),
527        r#"
528Ok(
529    AddrSpec {
530        local_part: "a",
531        domain: "[127.0.0.1]",
532    },
533)
534"#
535    );
536
537    k9::snapshot!(
538        parse_with("a@[IPv6::1]".as_bytes(), addr_spec),
539        r#"
540Ok(
541    AddrSpec {
542        local_part: "a",
543        domain: "[IPv6::1]",
544    },
545)
546"#
547    );
548}
549
550#[cfg(test)]
551#[test]
552fn test_obs_local_part_in_addr_spec() {
553    // obs-local-part = word *("." word) where word = atom / quoted-string
554    // This mixed form is defined in RFC 5322 §4.4 and is correctly parsed
555    // via obs_local_part which is tried first in the local_part alternation.
556    k9::snapshot!(
557        parse_with(r#""first".last@example.com"#.as_bytes(), addr_spec),
558        r#"
559Ok(
560    AddrSpec {
561        local_part: "first.last",
562        domain: "example.com",
563    },
564)
565"#
566    );
567    k9::snapshot!(
568        parse_with(r#"first."last"@example.com"#.as_bytes(), addr_spec),
569        r#"
570Ok(
571    AddrSpec {
572        local_part: "first.last",
573        domain: "example.com",
574    },
575)
576"#
577    );
578    k9::snapshot!(
579        parse_with(r#""first"."last"@example.com"#.as_bytes(), addr_spec),
580        r#"
581Ok(
582    AddrSpec {
583        local_part: "first.last",
584        domain: "example.com",
585    },
586)
587"#
588    );
589}
590
591#[cfg(test)]
592#[test]
593fn test_obs_local_part_encode_roundtrip() {
594    // When an obs-local-part is resolved to its semantic content and stored
595    // in an AddrSpec, encode_value should produce a valid RFC 5321 address.
596
597    // "first".last -> semantic content "first.last" -> encodes as dot-string
598    let addr = AddrSpec::new("first.last", "example.com");
599    k9::assert_equal!(addr.encode_value(), "first.last@example.com");
600
601    // "first second".last -> semantic content "first second.last" -> needs quoting
602    let addr = AddrSpec::new("first second.last", "example.com");
603    k9::assert_equal!(addr.encode_value(), r#""first second.last"@example.com"#);
604
605    // "first\"".last -> semantic content "first\".last" -> needs quoting with escaping
606    let addr = AddrSpec::new("first\".last", "example.com");
607    k9::assert_equal!(addr.encode_value(), r#""first\".last"@example.com"#);
608}
609
610#[cfg(test)]
611#[test]
612fn test_obs_local_part_with_special_chars() {
613    // obs-local-part where the quoted-string word contains characters
614    // that require quoting (space, specials)
615    k9::snapshot!(
616        parse_with(r#""hello world".user@example.com"#.as_bytes(), addr_spec),
617        r#"
618Ok(
619    AddrSpec {
620        local_part: "hello world.user",
621        domain: "example.com",
622    },
623)
624"#
625    );
626    // Verify the round-trip encodes as a valid RFC 5321 quoted-string
627    let addr = AddrSpec::new("hello world.user", "example.com");
628    k9::assert_equal!(addr.encode_value(), r#""hello world.user"@example.com"#);
629}
630
631#[cfg(test)]
632#[test]
633fn test_utf8_non_ascii_in_local_part() {
634    // RFC 6531/6532: internationalized local-part with non-ASCII characters
635    k9::snapshot!(
636        parse_with("用户@example.com".as_bytes(), addr_spec),
637        r#"
638Ok(
639    AddrSpec {
640        local_part: "用户",
641        domain: "example.com",
642    },
643)
644"#
645    );
646    k9::snapshot!(
647        parse_with("münchen@example.com".as_bytes(), addr_spec),
648        r#"
649Ok(
650    AddrSpec {
651        local_part: "münchen",
652        domain: "example.com",
653    },
654)
655"#
656    );
657}
658
659#[cfg(test)]
660#[test]
661fn test_utf8_non_ascii_in_domain() {
662    // RFC 6531: internationalized domain in header address
663    k9::snapshot!(
664        parse_with("user@例え.jp".as_bytes(), addr_spec),
665        r#"
666Ok(
667    AddrSpec {
668        local_part: "user",
669        domain: "例え.jp",
670    },
671)
672"#
673    );
674}
675
676#[cfg(test)]
677#[test]
678fn test_quoted_pair_non_ascii() {
679    // quoted_pair with utf8_non_ascii: backslash followed by a non-ASCII char
680    k9::snapshot!(
681        parse_with(r#""\München"@example.com"#.as_bytes(), addr_spec),
682        r#"
683Ok(
684    AddrSpec {
685        local_part: "München",
686        domain: "example.com",
687    },
688)
689"#
690    );
691}
692
693#[cfg(test)]
694#[test]
695fn test_invalid_utf8_rejected() {
696    // Lone continuation byte (0x80) is not valid UTF-8 and should be rejected
697    // in atext position
698    let input = b"user\x80@example.com";
699    parse_with(input, addr_spec).unwrap_err();
700
701    // Overlong encoding of '/' (U+002F): 0xC0 0xAF is invalid UTF-8
702    let input = b"user\xC0\xAF@example.com";
703    parse_with(input, addr_spec).unwrap_err();
704
705    // Truncated multi-byte sequence: 0xC3 without continuation
706    let input = b"user\xC3@example.com";
707    parse_with(input, addr_spec).unwrap_err();
708
709    // Invalid byte in quoted-string qtext position
710    let input = b"\"user\x80\"@example.com";
711    parse_with(input, addr_spec).unwrap_err();
712
713    // Invalid byte in comment ctext position
714    let input = b"(comment\x80) user@example.com";
715    parse_with(input, mailbox).unwrap_err();
716}
717
718// atom = { cfws? ~ atext ~ cfws? }
719fn atom(input: Span) -> IResult<Span, BString> {
720    let (loc, text) = context("atom", delimited(opt(cfws), atext, opt(cfws))).parse(input)?;
721    Ok((loc, (*text).into()))
722}
723
724// word = { atom | quoted_string }
725fn word(input: Span) -> IResult<Span, BString> {
726    context("word", alt((atom, quoted_string))).parse(input)
727}
728
729// obs_local_part = { word ~ (dot ~ word)* }
730fn obs_local_part(input: Span) -> IResult<Span, BString> {
731    let (loc, (word, dotted_words)) =
732        context("obs_local_part", (word, many0((tag("."), word)))).parse(input)?;
733    let mut result = word;
734
735    for (_dot, w) in dotted_words {
736        result.push(b'.');
737        result.push_str(&w);
738    }
739
740    Ok((loc, result))
741}
742
743// local_part = { dot_atom | quoted_string | obs_local_part }
744// obs_local_part (word *("." word)) is a superset of both dot_atom and
745// quoted_string: a dot-separated run of atoms is an obs_local_part where
746// every word is an atom, and a bare quoted-string is an obs_local_part
747// with no dot continuations. It must be tried first because dot_atom can
748// partially match (e.g. consuming "first" from "first.\"last\"@domain")
749// and then fail in the wider addr_spec context with no backtracking.
750fn local_part(input: Span) -> IResult<Span, BString> {
751    context("local_part", alt((obs_local_part, dot_atom, quoted_string))).parse(input)
752}
753
754// domain = { dot_atom | domain_literal | obs_domain }
755fn domain(input: Span) -> IResult<Span, BString> {
756    context("domain", alt((dot_atom, domain_literal, obs_domain))).parse(input)
757}
758
759// obs_domain = { atom ~ ( dot ~ atom)* }
760fn obs_domain(input: Span) -> IResult<Span, BString> {
761    let (loc, (atom, dotted_atoms)) =
762        context("obs_domain", (atom, many0((tag("."), atom)))).parse(input)?;
763    let mut result = atom;
764
765    for (_dot, w) in dotted_atoms {
766        result.push(b'.');
767        result.push_str(&w);
768    }
769
770    Ok((loc, result))
771}
772
773// domain_literal = { cfws? ~ "[" ~ (fws? ~ dtext)* ~ fws? ~ "]" ~ cfws? }
774fn domain_literal(input: Span) -> IResult<Span, BString> {
775    let (loc, (bits, trailer)) = context(
776        "domain_literal",
777        delimited(
778            opt(cfws),
779            delimited(
780                tag("["),
781                (
782                    many0((
783                        opt(fws),
784                        alt((
785                            take_while_m_n(1, 1, is_dtext_ascii),
786                            utf8_non_ascii,
787                            quoted_pair,
788                        )),
789                    )),
790                    opt(fws),
791                ),
792                tag("]"),
793            ),
794            opt(cfws),
795        ),
796    )
797    .parse(input)?;
798
799    let mut result = BString::default();
800    result.push(b'[');
801    for (a, b) in bits {
802        if let Some(a) = a {
803            result.push_str(&a);
804        }
805        result.push_str(b);
806    }
807    if let Some(t) = trailer {
808        result.push_str(&t);
809    }
810    result.push(b']');
811    Ok((loc, result))
812}
813
814// dot_atom_text = @{ atext ~ ("." ~ atext)* }
815fn dot_atom_text(input: Span) -> IResult<Span, BString> {
816    let (loc, (a, b)) =
817        context("dot_atom_text", (atext, many0(preceded(tag("."), atext)))).parse(input)?;
818    let mut result: BString = (*a).into();
819    for item in b {
820        result.push(b'.');
821        result.push_str(&item);
822    }
823
824    Ok((loc, result))
825}
826
827// dot_atom = { cfws? ~ dot_atom_text ~ cfws? }
828fn dot_atom(input: Span) -> IResult<Span, BString> {
829    context("dot_atom", delimited(opt(cfws), dot_atom_text, opt(cfws))).parse(input)
830}
831
832#[cfg(test)]
833#[test]
834fn test_dot_atom() {
835    k9::snapshot!(
836        parse_with("hello".as_bytes(), dot_atom),
837        r#"
838Ok(
839    "hello",
840)
841"#
842    );
843
844    k9::snapshot!(
845        parse_with("hello.there".as_bytes(), dot_atom),
846        r#"
847Ok(
848    "hello.there",
849)
850"#
851    );
852
853    k9::snapshot!(
854        parse_with("hello.".as_bytes(), dot_atom),
855        r#"
856Err(
857    HeaderParse(
858        "Error at line 1, in Eof:
859hello.
860     ^
861
862",
863    ),
864)
865"#
866    );
867
868    k9::snapshot!(
869        parse_with("(wat)hello".as_bytes(), dot_atom),
870        r#"
871Ok(
872    "hello",
873)
874"#
875    );
876}
877
878// cfws = { ( (fws? ~ comment)+ ~ fws?) | fws }
879fn cfws(input: Span) -> IResult<Span, Span> {
880    context(
881        "cfws",
882        recognize(alt((
883            recognize((many1((opt(fws), comment)), opt(fws))),
884            fws,
885        ))),
886    )
887    .parse(input)
888}
889
890// comment = { "(" ~ (fws? ~ ccontent)* ~ fws? ~ ")" }
891fn comment(input: Span) -> IResult<Span, Span> {
892    context(
893        "comment",
894        recognize((tag("("), many0((opt(fws), ccontent)), opt(fws), tag(")"))),
895    )
896    .parse(input)
897}
898
899#[cfg(test)]
900#[test]
901fn test_comment() {
902    k9::snapshot!(
903        BStr::new(&parse_with("(wat)".as_bytes(), comment).unwrap()),
904        "(wat)"
905    );
906}
907
908// ccontent = { ctext | quoted_pair | comment | encoded_word }
909fn ccontent(input: Span) -> IResult<Span, Span> {
910    context(
911        "ccontent",
912        recognize(alt((
913            recognize(alt((take_while_m_n(1, 1, is_ctext_ascii), utf8_non_ascii))),
914            recognize(quoted_pair),
915            comment,
916            recognize(encoded_word),
917        ))),
918    )
919    .parse(input)
920}
921
922fn is_quoted_pair_ascii(c: u8) -> bool {
923    match c {
924        0x00 | b'\r' | b'\n' | b' ' => true,
925        c => is_obs_no_ws_ctl(c) || is_vchar_ascii(c),
926    }
927}
928
929/// Byte-level predicate for quoted_pair, including UTF-8 continuation/leading
930/// bytes. Used for non-parser checks. For parsing, use `quoted_pair` which
931/// validates UTF-8 via `utf8_non_ascii`.
932fn is_quoted_pair(c: u8) -> bool {
933    is_quoted_pair_ascii(c) || c >= 0x80
934}
935
936// quoted_pair = { ( "\\"  ~ (vchar | wsp)) | obs_qp }
937// obs_qp = { "\\" ~ ( "\u{00}" | obs_no_ws_ctl | "\r" | "\n") }
938fn quoted_pair(input: Span) -> IResult<Span, Span> {
939    context(
940        "quoted_pair",
941        preceded(
942            tag("\\"),
943            alt((take_while_m_n(1, 1, is_quoted_pair_ascii), utf8_non_ascii)),
944        ),
945    )
946    .parse(input)
947}
948
949// encoded_word = { "=?" ~ charset ~ ("*" ~ language)? ~ "?" ~ encoding ~ "?" ~ encoded_text ~ "?=" }
950fn encoded_word(input: Span) -> IResult<Span, BString> {
951    let (loc, (charset, _language, _, encoding, _, text)) = context(
952        "encoded_word",
953        delimited(
954            tag("=?"),
955            (
956                charset,
957                opt(preceded(tag("*"), language)),
958                tag("?"),
959                encoding,
960                tag("?"),
961                encoded_text,
962            ),
963            tag("?="),
964        ),
965    )
966    .parse(input)?;
967
968    let bytes = match *encoding.fragment() {
969        b"B" | b"b" => data_encoding::BASE64_MIME
970            .decode(text.as_bytes())
971            .map_err(|err| {
972                make_context_error(
973                    input,
974                    format!("encoded_word: base64 decode failed: {err:#}"),
975                )
976            })?,
977        b"Q" | b"q" => {
978            // for rfc2047 header encoding, _ can be used to represent a space
979            let munged = text.replace("_", " ");
980            // The quoted_printable crate will unhelpfully strip trailing space
981            // from the decoded input string, and we must track and restore it
982            let had_trailing_space = munged.ends_with_str(" ");
983            let mut decoded = quoted_printable::decode(munged, quoted_printable::ParseMode::Robust)
984                .map_err(|err| {
985                    make_context_error(
986                        input,
987                        format!("encoded_word: quoted printable decode failed: {err:#}"),
988                    )
989                })?;
990            if had_trailing_space && !decoded.ends_with(b" ") {
991                decoded.push(b' ');
992            }
993            decoded
994        }
995        encoding => {
996            let encoding = BStr::new(encoding);
997            return Err(make_context_error(
998                input,
999                format!(
1000                    "encoded_word: invalid encoding '{encoding}', expected one of b, B, q or Q"
1001                ),
1002            ));
1003        }
1004    };
1005
1006    let charset_name = charset.to_str().map_err(|err| {
1007        make_context_error(
1008            input,
1009            format!(
1010                "encoded_word: charset {} is not UTF-8: {err}",
1011                BStr::new(*charset)
1012            ),
1013        )
1014    })?;
1015
1016    let charset = Encoding::by_name(&*charset_name).ok_or_else(|| {
1017        make_context_error(
1018            input,
1019            format!("encoded_word: unsupported charset '{charset_name}'"),
1020        )
1021    })?;
1022
1023    let decoded = charset.decode_simple(&bytes).map_err(|err| {
1024        make_context_error(
1025            input,
1026            format!("encoded_word: failed to decode as '{charset_name}': {err}"),
1027        )
1028    })?;
1029
1030    Ok((loc, decoded.into()))
1031}
1032
1033// charset = @{ (!"*" ~ token)+ }
1034fn charset(input: Span) -> IResult<Span, Span> {
1035    context("charset", take_while1(|c| c != b'*' && is_token(c))).parse(input)
1036}
1037
1038// language = @{ token+ }
1039fn language(input: Span) -> IResult<Span, Span> {
1040    context("language", take_while1(|c| c != b'*' && is_token(c))).parse(input)
1041}
1042
1043// encoding = @{ token+ }
1044fn encoding(input: Span) -> IResult<Span, Span> {
1045    context("encoding", take_while1(|c| c != b'*' && is_token(c))).parse(input)
1046}
1047
1048// encoded_text = @{ (!( " " | "?") ~ vchar)+ }
1049fn encoded_text(input: Span) -> IResult<Span, Span> {
1050    context(
1051        "encoded_text",
1052        recognize(many1(alt((
1053            take_while1(|c| is_vchar_ascii(c) && c != b' ' && c != b'?'),
1054            utf8_non_ascii,
1055        )))),
1056    )
1057    .parse(input)
1058}
1059
1060// quoted_string = { cfws? ~ "\"" ~ (fws? ~ qcontent)* ~ fws? ~ "\"" ~ cfws? }
1061fn quoted_string(input: Span) -> IResult<Span, BString> {
1062    let (loc, (bits, trailer)) = context(
1063        "quoted_string",
1064        delimited(
1065            opt(cfws),
1066            delimited(
1067                tag("\""),
1068                (many0((opt(fws), qcontent)), opt(fws)),
1069                tag("\""),
1070            ),
1071            opt(cfws),
1072        ),
1073    )
1074    .parse(input)?;
1075
1076    let mut result = BString::default();
1077    for (a, b) in bits {
1078        if let Some(a) = a {
1079            result.push_str(&a);
1080        }
1081        result.push_str(b);
1082    }
1083    if let Some(t) = trailer {
1084        result.push_str(&t);
1085    }
1086    Ok((loc, result))
1087}
1088
1089// qcontent = { qtext | quoted_pair }
1090fn qcontent(input: Span) -> IResult<Span, Span> {
1091    context(
1092        "qcontent",
1093        alt((
1094            take_while_m_n(1, 1, is_qtext_ascii),
1095            utf8_non_ascii,
1096            quoted_pair,
1097        )),
1098    )
1099    .parse(input)
1100}
1101
1102fn content_id(input: Span) -> IResult<Span, MessageID> {
1103    let (loc, id) = context("content_id", msg_id).parse(input)?;
1104    Ok((loc, id))
1105}
1106
1107fn msg_id(input: Span) -> IResult<Span, MessageID> {
1108    let (loc, id) = context("msg_id", alt((strict_msg_id, relaxed_msg_id))).parse(input)?;
1109    Ok((loc, id))
1110}
1111
1112fn relaxed_msg_id(input: Span) -> IResult<Span, MessageID> {
1113    let (loc, id) = context(
1114        "msg_id",
1115        delimited(
1116            preceded(opt(cfws), tag("<")),
1117            many0(take_while_m_n(1, 1, not_angle)),
1118            preceded(tag(">"), opt(cfws)),
1119        ),
1120    )
1121    .parse(input)?;
1122
1123    let mut result = BString::default();
1124    for item in id.into_iter() {
1125        result.push_str(*item);
1126    }
1127
1128    Ok((loc, MessageID(result)))
1129}
1130
1131// msg_id_list = { msg_id+ }
1132fn msg_id_list(input: Span) -> IResult<Span, Vec<MessageID>> {
1133    context("msg_id_list", many1(msg_id)).parse(input)
1134}
1135
1136// id_left = { dot_atom_text | obs_id_left }
1137// obs_id_left = { local_part }
1138fn id_left(input: Span) -> IResult<Span, BString> {
1139    context("id_left", alt((dot_atom_text, local_part))).parse(input)
1140}
1141
1142// id_right = { dot_atom_text | no_fold_literal | obs_id_right }
1143// obs_id_right = { domain }
1144fn id_right(input: Span) -> IResult<Span, BString> {
1145    context("id_right", alt((dot_atom_text, no_fold_literal, domain))).parse(input)
1146}
1147
1148// no_fold_literal = { "[" ~ dtext* ~ "]" }
1149fn no_fold_literal(input: Span) -> IResult<Span, BString> {
1150    context(
1151        "no_fold_literal",
1152        map(
1153            recognize((
1154                tag("["),
1155                recognize(many0(alt((take_while1(is_dtext_ascii), utf8_non_ascii)))),
1156                tag("]"),
1157            )),
1158            |s: Span| (*s).into(),
1159        ),
1160    )
1161    .parse(input)
1162}
1163
1164// msg_id = { cfws? ~ "<" ~ id_left ~ "@" ~ id_right ~ ">" ~ cfws? }
1165fn strict_msg_id(input: Span) -> IResult<Span, MessageID> {
1166    let (loc, (left, _, right)) = context(
1167        "msg_id",
1168        delimited(
1169            preceded(opt(cfws), tag("<")),
1170            (id_left, tag("@"), id_right),
1171            preceded(tag(">"), opt(cfws)),
1172        ),
1173    )
1174    .parse(input)?;
1175
1176    let mut result: BString = left.into();
1177    result.push_char('@');
1178    result.push_str(right);
1179
1180    Ok((loc, MessageID(result)))
1181}
1182
1183// obs_unstruct = { (( "\r"* ~ "\n"* ~ ((encoded_word | obs_utext)~ "\r"* ~ "\n"*)+) | fws)+ }
1184fn unstructured(input: Span) -> IResult<Span, BString> {
1185    #[derive(Debug)]
1186    enum Word {
1187        Encoded(BString),
1188        UText(BString),
1189        Fws,
1190    }
1191
1192    let (loc, words) = context(
1193        "unstructured",
1194        many0(alt((
1195            preceded(
1196                map(take_while(|c| c == b'\r' || c == b'\n'), |_| Word::Fws),
1197                terminated(
1198                    alt((
1199                        map(encoded_word, Word::Encoded),
1200                        map(obs_utext, |s| Word::UText((*s).into())),
1201                    )),
1202                    map(take_while(|c| c == b'\r' || c == b'\n'), |_| Word::Fws),
1203                ),
1204            ),
1205            map(fws, |_| Word::Fws),
1206        ))),
1207    )
1208    .parse(input)?;
1209
1210    #[derive(Debug)]
1211    enum ProcessedWord {
1212        Encoded(BString),
1213        Text(BString),
1214        Fws,
1215    }
1216    let mut processed = vec![];
1217    for w in words {
1218        match w {
1219            Word::Encoded(p) => {
1220                if processed.len() >= 2
1221                    && matches!(processed.last(), Some(ProcessedWord::Fws))
1222                    && matches!(processed[processed.len() - 2], ProcessedWord::Encoded(_))
1223                {
1224                    // Fws between encoded words is elided
1225                    processed.pop();
1226                }
1227                processed.push(ProcessedWord::Encoded(p));
1228            }
1229            Word::Fws => {
1230                // Collapse runs of Fws/newline to a single Fws
1231                if !matches!(processed.last(), Some(ProcessedWord::Fws)) {
1232                    processed.push(ProcessedWord::Fws);
1233                }
1234            }
1235            Word::UText(c) => match processed.last_mut() {
1236                Some(ProcessedWord::Text(prior)) => prior.push_str(c),
1237                _ => processed.push(ProcessedWord::Text(c)),
1238            },
1239        }
1240    }
1241
1242    let mut result = BString::default();
1243    for word in processed {
1244        match word {
1245            ProcessedWord::Encoded(s) | ProcessedWord::Text(s) => {
1246                result.push_str(&s);
1247            }
1248            ProcessedWord::Fws => {
1249                result.push(b' ');
1250            }
1251        }
1252    }
1253
1254    Ok((loc, result))
1255}
1256
1257fn arc_authentication_results(input: Span) -> IResult<Span, ARCAuthenticationResults> {
1258    context(
1259        "arc_authentication_results",
1260        map(
1261            (
1262                preceded(opt(cfws), tag("i")),
1263                preceded(opt(cfws), tag("=")),
1264                preceded(opt(cfws), nom::character::complete::u8),
1265                preceded(opt(cfws), tag(";")),
1266                preceded(opt(cfws), value),
1267                opt(preceded(cfws, nom::character::complete::u32)),
1268                alt((no_result, many1(resinfo))),
1269                opt(cfws),
1270            ),
1271            |(_i, _eq, instance, _semic, serv_id, version, results, _)| ARCAuthenticationResults {
1272                instance,
1273                serv_id: serv_id.into(),
1274                version,
1275                results,
1276            },
1277        ),
1278    )
1279    .parse(input)
1280}
1281
1282fn authentication_results(input: Span) -> IResult<Span, AuthenticationResults> {
1283    context(
1284        "authentication_results",
1285        map(
1286            (
1287                preceded(opt(cfws), value),
1288                opt(preceded(cfws, nom::character::complete::u32)),
1289                alt((no_result, many1(resinfo))),
1290                opt(cfws),
1291            ),
1292            |(serv_id, version, results, _)| AuthenticationResults {
1293                serv_id: serv_id.into(),
1294                version,
1295                results,
1296            },
1297        ),
1298    )
1299    .parse(input)
1300}
1301
1302fn no_result(input: Span) -> IResult<Span, Vec<AuthenticationResult>> {
1303    context(
1304        "no_result",
1305        map((opt(cfws), tag(";"), opt(cfws), tag("none")), |_| vec![]),
1306    )
1307    .parse(input)
1308}
1309
1310fn resinfo(input: Span) -> IResult<Span, AuthenticationResult> {
1311    context(
1312        "resinfo",
1313        map(
1314            (
1315                opt(cfws),
1316                tag(";"),
1317                methodspec,
1318                opt(preceded(cfws, reasonspec)),
1319                opt(many1(propspec)),
1320            ),
1321            |(_, _, (method, method_version, result), reason, props)| AuthenticationResult {
1322                method,
1323                method_version,
1324                result,
1325                reason: reason.map(Into::into),
1326                props: match props {
1327                    None => BTreeMap::default(),
1328                    Some(props) => props.into_iter().collect(),
1329                },
1330            },
1331        ),
1332    )
1333    .parse(input)
1334}
1335
1336fn methodspec(input: Span) -> IResult<Span, (String, Option<u32>, String)> {
1337    context(
1338        "methodspec",
1339        map(
1340            (
1341                opt(cfws),
1342                (keyword, opt(methodversion)),
1343                opt(cfws),
1344                tag("="),
1345                opt(cfws),
1346                keyword,
1347            ),
1348            |(_, (method, methodversion), _, _, _, result)| (method, methodversion, result),
1349        ),
1350    )
1351    .parse(input)
1352}
1353
1354// Taken from https://datatracker.ietf.org/doc/html/rfc8601 which says
1355// that this is the same as the SMTP Keyword token (RFC 5321 section 4.1.2).
1356// Keyword = Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig
1357// Only matches ASCII alphanumeric and '-'.
1358fn keyword(input: Span) -> IResult<Span, String> {
1359    context(
1360        "keyword",
1361        map(
1362            take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-'),
1363            // SAFETY: predicate only matches ASCII bytes
1364            |s: Span| String::from_utf8((*s).into()).expect("keyword is ASCII-only"),
1365        ),
1366    )
1367    .parse(input)
1368}
1369
1370fn methodversion(input: Span) -> IResult<Span, u32> {
1371    context(
1372        "methodversion",
1373        preceded(
1374            (opt(cfws), tag("/"), opt(cfws)),
1375            nom::character::complete::u32,
1376        ),
1377    )
1378    .parse(input)
1379}
1380
1381fn reasonspec(input: Span) -> IResult<Span, BString> {
1382    context(
1383        "reason",
1384        map(
1385            (tag("reason"), opt(cfws), tag("="), opt(cfws), value),
1386            |(_, _, _, _, value)| value,
1387        ),
1388    )
1389    .parse(input)
1390}
1391
1392fn propspec(input: Span) -> IResult<Span, (String, BString)> {
1393    context(
1394        "propspec",
1395        map(
1396            (
1397                // RFC 8601 resinfo ABNF says CFWS is required before each
1398                // propspec, but we use opt(cfws) here because other parsers
1399                // (notably quoted_string) may have already consumed the
1400                // whitespace.
1401                opt(cfws),
1402                keyword,
1403                opt(cfws),
1404                tag("."),
1405                opt(cfws),
1406                keyword,
1407                opt(cfws),
1408                tag("="),
1409                opt(cfws),
1410                // pvalue = [CFWS] ( value / [ [CFWS] "@" ] domain ) [CFWS]
1411                // Try @domain and local@domain first (distinctive @ marker),
1412                // then quoted_string (distinctive " marker), then domain
1413                // (handles dotted names), then mime_token last (single tokens).
1414                alt((
1415                    map(preceded(tag("@"), domain), |d| {
1416                        let mut at_dom = BString::from("@");
1417                        at_dom.push_str(d);
1418                        at_dom
1419                    }),
1420                    map(separated_pair(local_part, tag("@"), domain), |(u, d)| {
1421                        let mut result: BString = u.into();
1422                        result.push(b'@');
1423                        result.push_str(d);
1424                        result
1425                    }),
1426                    quoted_string,
1427                    domain,
1428                    map(mime_token, |s: Span| (*s).into()),
1429                )),
1430                opt(cfws),
1431            ),
1432            |(_, ptype, _, _, _, property, _, _, _, value, _)| {
1433                (format!("{ptype}.{property}"), value)
1434            },
1435        ),
1436    )
1437    .parse(input)
1438}
1439
1440// obs_utext = @{ "\u{00}" | obs_no_ws_ctl | vchar }
1441fn obs_utext(input: Span) -> IResult<Span, Span> {
1442    context(
1443        "obs_utext",
1444        alt((
1445            take_while_m_n(1, 1, |c| {
1446                c == 0x00 || is_obs_no_ws_ctl(c) || is_vchar_ascii(c)
1447            }),
1448            utf8_non_ascii,
1449        )),
1450    )
1451    .parse(input)
1452}
1453
1454fn is_mime_token(c: u8) -> bool {
1455    is_char(c) && c != b' ' && !is_ctl(c) && !is_tspecial(c)
1456}
1457
1458// mime_token = { (!(" " | ctl | tspecials) ~ char)+ }
1459// Also accepts validated UTF-8 multi-byte sequences per RFC 6532.
1460fn mime_token(input: Span) -> IResult<Span, Span> {
1461    context(
1462        "mime_token",
1463        recognize(many1(alt((take_while1(is_mime_token), utf8_non_ascii)))),
1464    )
1465    .parse(input)
1466}
1467
1468// RFC2045 modified by RFC2231 MIME header fields
1469// content_type = { cfws? ~ mime_type ~ cfws? ~ "/" ~ cfws? ~ subtype ~
1470//  cfws? ~ (";"? ~ cfws? ~ parameter ~ cfws?)*
1471// }
1472fn content_type(input: Span) -> IResult<Span, MimeParameters> {
1473    let (loc, (mime_type, _, _, _, mime_subtype, _, parameters)) = context(
1474        "content_type",
1475        preceded(
1476            opt(cfws),
1477            (
1478                mime_token,
1479                opt(cfws),
1480                tag("/"),
1481                opt(cfws),
1482                mime_token,
1483                opt(cfws),
1484                many0(preceded(
1485                    // Note that RFC 2231 is a bit of a mess, showing examples
1486                    // without `;` as a separator in the original text, but
1487                    // in the errata from several years later, corrects those
1488                    // to show the `;`.
1489                    // In the meantime, there are implementations that assume
1490                    // that the `;` is optional, so we therefore allow them
1491                    // to be optional here in our implementation
1492                    preceded(opt(tag(";")), opt(cfws)),
1493                    terminated(parameter, opt(cfws)),
1494                )),
1495            ),
1496        ),
1497    )
1498    .parse(input)?;
1499
1500    let mut value: BString = (*mime_type).into();
1501    value.push_char('/');
1502    value.push_str(mime_subtype);
1503
1504    Ok((loc, MimeParameters { value, parameters }))
1505}
1506
1507fn content_transfer_encoding(input: Span) -> IResult<Span, MimeParameters> {
1508    let (loc, (value, _, parameters)) = context(
1509        "content_transfer_encoding",
1510        preceded(
1511            opt(cfws),
1512            (
1513                mime_token,
1514                opt(cfws),
1515                many0(preceded(
1516                    // Note that RFC 2231 is a bit of a mess, showing examples
1517                    // without `;` as a separator in the original text, but
1518                    // in the errata from several years later, corrects those
1519                    // to show the `;`.
1520                    // In the meantime, there are implementations that assume
1521                    // that the `;` is optional, so we therefore allow them
1522                    // to be optional here in our implementation
1523                    preceded(opt(tag(";")), opt(cfws)),
1524                    terminated(parameter, opt(cfws)),
1525                )),
1526            ),
1527        ),
1528    )
1529    .parse(input)?;
1530
1531    Ok((
1532        loc,
1533        MimeParameters {
1534            value: value.as_bytes().into(),
1535            parameters,
1536        },
1537    ))
1538}
1539
1540// parameter = { regular_parameter | extended_parameter }
1541fn parameter(input: Span) -> IResult<Span, MimeParameter> {
1542    context(
1543        "parameter",
1544        alt((
1545            // Note that RFC2047 explicitly prohibits both of
1546            // these 2047 cases from appearing here, but that
1547            // major MUAs produce this sort of prohibited content
1548            // and we thus need to accommodate it
1549            param_with_unquoted_rfc2047,
1550            param_with_quoted_rfc2047,
1551            regular_parameter,
1552            extended_param_with_charset,
1553            extended_param_no_charset,
1554        )),
1555    )
1556    .parse(input)
1557}
1558
1559fn param_with_unquoted_rfc2047(input: Span) -> IResult<Span, MimeParameter> {
1560    context(
1561        "param_with_unquoted_rfc2047",
1562        map(
1563            (attribute, opt(cfws), tag("="), opt(cfws), encoded_word),
1564            |(name, _, _, _, value)| MimeParameter {
1565                name: name.as_bytes().into(),
1566                value: value.as_bytes().into(),
1567                section: None,
1568                encoding: MimeParameterEncoding::UnquotedRfc2047,
1569                mime_charset: None,
1570                mime_language: None,
1571            },
1572        ),
1573    )
1574    .parse(input)
1575}
1576
1577fn param_with_quoted_rfc2047(input: Span) -> IResult<Span, MimeParameter> {
1578    context(
1579        "param_with_quoted_rfc2047",
1580        map(
1581            (
1582                attribute,
1583                opt(cfws),
1584                tag("="),
1585                opt(cfws),
1586                delimited(tag("\""), encoded_word, tag("\"")),
1587            ),
1588            |(name, _, _, _, value)| MimeParameter {
1589                name: name.as_bytes().into(),
1590                value: value.as_bytes().into(),
1591                section: None,
1592                encoding: MimeParameterEncoding::QuotedRfc2047,
1593                mime_charset: None,
1594                mime_language: None,
1595            },
1596        ),
1597    )
1598    .parse(input)
1599}
1600
1601fn extended_param_with_charset(input: Span) -> IResult<Span, MimeParameter> {
1602    context(
1603        "extended_param_with_charset",
1604        map(
1605            (
1606                attribute,
1607                opt(section),
1608                tag("*"),
1609                opt(cfws),
1610                tag("="),
1611                opt(cfws),
1612                opt(mime_charset),
1613                tag("'"),
1614                opt(mime_language),
1615                tag("'"),
1616                map(
1617                    recognize(many0(alt((ext_octet, take_while1(is_attribute_char))))),
1618                    |s: Span| (*s).into(),
1619                ),
1620            ),
1621            |(name, section, _, _, _, _, mime_charset, _, mime_language, _, value)| MimeParameter {
1622                name: name.as_bytes().into(),
1623                section,
1624                mime_charset: mime_charset.map(|s| s.as_bytes().into()),
1625                mime_language: mime_language.map(|s| s.as_bytes().into()),
1626                encoding: MimeParameterEncoding::Rfc2231,
1627                value,
1628            },
1629        ),
1630    )
1631    .parse(input)
1632}
1633
1634fn extended_param_no_charset(input: Span) -> IResult<Span, MimeParameter> {
1635    context(
1636        "extended_param_no_charset",
1637        map(
1638            (
1639                attribute,
1640                opt(section),
1641                opt(tag("*")),
1642                opt(cfws),
1643                tag("="),
1644                opt(cfws),
1645                alt((
1646                    quoted_string,
1647                    map(
1648                        recognize(many0(alt((ext_octet, take_while1(is_attribute_char))))),
1649                        |s: Span| (*s).into(),
1650                    ),
1651                )),
1652            ),
1653            |(name, section, star, _, _, _, value)| MimeParameter {
1654                name: name.as_bytes().into(),
1655                section,
1656                mime_charset: None,
1657                mime_language: None,
1658                encoding: if star.is_some() {
1659                    MimeParameterEncoding::Rfc2231
1660                } else {
1661                    MimeParameterEncoding::None
1662                },
1663                value,
1664            },
1665        ),
1666    )
1667    .parse(input)
1668}
1669
1670fn mime_charset(input: Span) -> IResult<Span, Span> {
1671    context(
1672        "mime_charset",
1673        take_while1(|c| is_mime_token(c) && c != b'\''),
1674    )
1675    .parse(input)
1676}
1677
1678fn mime_language(input: Span) -> IResult<Span, Span> {
1679    context(
1680        "mime_language",
1681        take_while1(|c| is_mime_token(c) && c != b'\''),
1682    )
1683    .parse(input)
1684}
1685
1686fn ext_octet(input: Span) -> IResult<Span, Span> {
1687    context(
1688        "ext_octet",
1689        recognize((
1690            tag("%"),
1691            take_while_m_n(2, 2, |b: u8| b.is_ascii_hexdigit()),
1692        )),
1693    )
1694    .parse(input)
1695}
1696
1697// section = { "*" ~ ASCII_DIGIT+ }
1698fn section(input: Span) -> IResult<Span, u32> {
1699    context("section", preceded(tag("*"), nom::character::complete::u32)).parse(input)
1700}
1701
1702// regular_parameter = { attribute ~ cfws? ~ "=" ~ cfws? ~ value }
1703fn regular_parameter(input: Span) -> IResult<Span, MimeParameter> {
1704    context(
1705        "regular_parameter",
1706        map(
1707            (attribute, opt(cfws), tag("="), opt(cfws), value),
1708            |(name, _, _, _, value)| MimeParameter {
1709                name: name.as_bytes().into(),
1710                value: value.as_bytes().into(),
1711                section: None,
1712                encoding: MimeParameterEncoding::None,
1713                mime_charset: None,
1714                mime_language: None,
1715            },
1716        ),
1717    )
1718    .parse(input)
1719}
1720
1721// attribute = { attribute_char+ }
1722// attribute_char = { !(" " | ctl | tspecials | "*" | "'" | "%") ~ char }
1723fn attribute(input: Span) -> IResult<Span, Span> {
1724    context("attribute", take_while1(is_attribute_char)).parse(input)
1725}
1726
1727fn value(input: Span) -> IResult<Span, BString> {
1728    context(
1729        "value",
1730        alt((map(mime_token, |s: Span| (*s).into()), quoted_string)),
1731    )
1732    .parse(input)
1733}
1734
1735pub struct Parser;
1736
1737impl Parser {
1738    pub fn parse_mailbox_list_header(text: &[u8]) -> Result<MailboxList> {
1739        parse_with(text, mailbox_list)
1740    }
1741
1742    pub fn parse_mailbox_header(text: &[u8]) -> Result<Mailbox> {
1743        parse_with(text, mailbox)
1744    }
1745
1746    pub fn parse_address_list_header(text: &[u8]) -> Result<AddressList> {
1747        parse_with(text, address_list)
1748    }
1749
1750    pub fn parse_msg_id_header(text: &[u8]) -> Result<MessageID> {
1751        parse_with(text, msg_id)
1752    }
1753
1754    pub fn parse_msg_id_header_list(text: &[u8]) -> Result<Vec<MessageID>> {
1755        parse_with(text, msg_id_list)
1756    }
1757
1758    pub fn parse_content_id_header(text: &[u8]) -> Result<MessageID> {
1759        parse_with(text, content_id)
1760    }
1761
1762    pub fn parse_content_type_header(text: &[u8]) -> Result<MimeParameters> {
1763        parse_with(text, content_type)
1764    }
1765
1766    pub fn parse_content_transfer_encoding_header(text: &[u8]) -> Result<MimeParameters> {
1767        parse_with(text, content_transfer_encoding)
1768    }
1769
1770    pub fn parse_unstructured_header(text: &[u8]) -> Result<BString> {
1771        parse_with(text, unstructured)
1772    }
1773
1774    pub fn parse_authentication_results_header(text: &[u8]) -> Result<AuthenticationResults> {
1775        parse_with(text, authentication_results)
1776    }
1777
1778    pub fn parse_arc_authentication_results_header(
1779        text: &[u8],
1780    ) -> Result<ARCAuthenticationResults> {
1781        parse_with(text, arc_authentication_results)
1782    }
1783}
1784
1785#[serde_as]
1786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1787#[serde(deny_unknown_fields)]
1788pub struct ARCAuthenticationResults {
1789    pub instance: u8,
1790    #[serde_as(as = "BStringUtf8")]
1791    pub serv_id: BString,
1792    pub version: Option<u32>,
1793    pub results: Vec<AuthenticationResult>,
1794}
1795
1796impl EncodeHeaderValue for ARCAuthenticationResults {
1797    fn encode_value(&self) -> SharedString<'static> {
1798        let mut result = format!("i={}; ", self.instance).into_bytes();
1799
1800        emit_value_token(&self.serv_id, &mut result);
1801        if let Some(v) = self.version {
1802            result.push_str(&format!(" {v}"));
1803        }
1804
1805        if self.results.is_empty() {
1806            result.push_str("; none");
1807        } else {
1808            for res in &self.results {
1809                result.push_str(";\r\n\t");
1810                emit_value_token(res.method.as_bytes(), &mut result);
1811                if let Some(v) = res.method_version {
1812                    result.push_str(&format!("/{v}"));
1813                }
1814                result.push(b'=');
1815                emit_value_token(res.result.as_bytes(), &mut result);
1816                if let Some(reason) = &res.reason {
1817                    result.push_str(" reason=");
1818                    emit_value_token(reason.as_bytes(), &mut result);
1819                }
1820                for (k, v) in &res.props {
1821                    result.push_str(&format!("\r\n\t{k}="));
1822                    emit_value_token(v.as_bytes(), &mut result);
1823                }
1824            }
1825        }
1826
1827        result.into()
1828    }
1829}
1830
1831#[serde_as]
1832#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1833#[serde(deny_unknown_fields)]
1834pub struct AuthenticationResults {
1835    #[serde_as(as = "BStringUtf8")]
1836    pub serv_id: BString,
1837    #[serde(default)]
1838    pub version: Option<u32>,
1839    #[serde(default)]
1840    pub results: Vec<AuthenticationResult>,
1841}
1842
1843/// Emits a value that was parsed by `value`, into target
1844fn emit_value_token(value: &[u8], target: &mut Vec<u8>) {
1845    // Allow '@' bare since the pvalue parser handles @domain and local@domain
1846    let use_quoted_string = !value.iter().all(|&c| is_mime_token(c) || c == b'@');
1847    if use_quoted_string {
1848        target.push(b'"');
1849        for (start, end, c) in value.char_indices() {
1850            if c == '"' || c == '\\' {
1851                target.push(b'\\');
1852            }
1853            target.push_str(&value[start..end]);
1854        }
1855        target.push(b'"');
1856    } else {
1857        target.push_str(value);
1858    }
1859}
1860
1861impl EncodeHeaderValue for AuthenticationResults {
1862    fn encode_value(&self) -> SharedString<'static> {
1863        let mut result = Vec::new();
1864        emit_value_token(&self.serv_id, &mut result);
1865        if let Some(v) = self.version {
1866            result.push_str(&format!(" {v}"));
1867        }
1868        if self.results.is_empty() {
1869            result.push_str("; none");
1870        } else {
1871            for res in &self.results {
1872                result.push_str(";\r\n\t");
1873                emit_value_token(res.method.as_bytes(), &mut result);
1874                if let Some(v) = res.method_version {
1875                    result.push_str(&format!("/{v}"));
1876                }
1877                result.push(b'=');
1878                emit_value_token(res.result.as_bytes(), &mut result);
1879                if let Some(reason) = &res.reason {
1880                    result.push_str(" reason=");
1881                    emit_value_token(reason.as_bytes(), &mut result);
1882                }
1883                for (k, v) in &res.props {
1884                    result.push_str(&format!("\r\n\t{k}="));
1885                    emit_value_token(v.as_bytes(), &mut result);
1886                }
1887            }
1888        }
1889
1890        result.into()
1891    }
1892}
1893
1894#[serde_as]
1895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1896#[serde(deny_unknown_fields)]
1897pub struct AuthenticationResult {
1898    pub method: String,
1899    #[serde(default)]
1900    pub method_version: Option<u32>,
1901    pub result: String,
1902    #[serde_as(as = "Option<BStringUtf8>")]
1903    #[serde(default)]
1904    pub reason: Option<BString>,
1905    #[serde_as(as = "BTreeMap<_, BStringUtf8>")]
1906    #[serde(default)]
1907    pub props: BTreeMap<String, BString>,
1908}
1909
1910#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1911#[serde(deny_unknown_fields)]
1912pub struct AddrSpec {
1913    pub local_part: String,
1914    pub domain: String,
1915}
1916
1917impl AddrSpec {
1918    pub fn new(local_part: &str, domain: &str) -> Self {
1919        Self {
1920            local_part: local_part.into(),
1921            domain: domain.into(),
1922        }
1923    }
1924
1925    pub fn parse(email: &str) -> Result<Self> {
1926        parse_with(email.as_bytes(), addr_spec)
1927    }
1928}
1929
1930impl EncodeHeaderValue for AddrSpec {
1931    fn encode_value(&self) -> SharedString<'static> {
1932        let mut result: Vec<u8> = vec![];
1933
1934        let needs_quoting = !self
1935            .local_part
1936            .as_bytes()
1937            .iter()
1938            .all(|&c| is_atext(c) || c == b'.');
1939        if needs_quoting {
1940            result.push(b'"');
1941            // RFC5321 4.1.2 qtextSMTP:
1942            // within a quoted string, any ASCII graphic or space is permitted without
1943            // blackslash-quoting except double-quote and the backslash itself.
1944
1945            for &c in self.local_part.as_bytes().iter() {
1946                if c == b'"' || c == b'\\' {
1947                    result.push(b'\\');
1948                }
1949                result.push(c);
1950            }
1951            result.push(b'"');
1952        } else {
1953            result.push_str(&self.local_part);
1954        }
1955        result.push(b'@');
1956        result.push_str(&self.domain);
1957
1958        result.into()
1959    }
1960}
1961
1962#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1963#[serde(untagged)]
1964pub enum Address {
1965    Mailbox(Mailbox),
1966    Group { name: String, entries: MailboxList },
1967}
1968
1969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1970#[serde(deny_unknown_fields, transparent)]
1971pub struct AddressList(pub Vec<Address>);
1972
1973impl std::ops::Deref for AddressList {
1974    type Target = Vec<Address>;
1975    fn deref(&self) -> &Vec<Address> {
1976        &self.0
1977    }
1978}
1979
1980impl AddressList {
1981    pub fn extract_first_mailbox(&self) -> Option<&Mailbox> {
1982        let address = self.0.first()?;
1983        match address {
1984            Address::Mailbox(mailbox) => Some(mailbox),
1985            Address::Group { entries, .. } => entries.extract_first_mailbox(),
1986        }
1987    }
1988}
1989
1990#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1991#[serde(deny_unknown_fields, transparent)]
1992pub struct MailboxList(pub Vec<Mailbox>);
1993
1994impl std::ops::Deref for MailboxList {
1995    type Target = Vec<Mailbox>;
1996    fn deref(&self) -> &Vec<Mailbox> {
1997        &self.0
1998    }
1999}
2000
2001impl MailboxList {
2002    pub fn extract_first_mailbox(&self) -> Option<&Mailbox> {
2003        self.0.first()
2004    }
2005}
2006
2007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2008#[serde(deny_unknown_fields)]
2009pub struct Mailbox {
2010    pub name: Option<String>,
2011    pub address: AddrSpec,
2012}
2013
2014#[serde_as]
2015#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2016#[serde(transparent)]
2017pub struct MessageID(#[serde_as(as = "BStringUtf8")] pub BString);
2018
2019impl EncodeHeaderValue for MessageID {
2020    fn encode_value(&self) -> SharedString<'static> {
2021        let mut result = Vec::<u8>::with_capacity(self.0.len() + 2);
2022        result.push(b'<');
2023        result.push_str(&self.0);
2024        result.push(b'>');
2025        result.into()
2026    }
2027}
2028
2029impl EncodeHeaderValue for Vec<MessageID> {
2030    fn encode_value(&self) -> SharedString<'static> {
2031        let mut result = BString::default();
2032        for id in self {
2033            if !result.is_empty() {
2034                result.push_str("\r\n\t");
2035            }
2036            result.push(b'<');
2037            result.push_str(&id.0);
2038            result.push(b'>');
2039        }
2040        result.into()
2041    }
2042}
2043
2044// In theory, everyone would be aware of RFC 2231 and we can stop here,
2045// but in practice, things are messy.  At some point someone started
2046// to emit encoded-words insides quoted-string values, and for the sake
2047// of compatibility what we see now is technically illegal stuff like
2048// Content-Disposition: attachment; filename="=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?="
2049// being used to represent UTF-8 filenames.
2050// As such, in our RFC 2231 handling, we also need to accommodate
2051// these bogus representations, hence their presence in this enum
2052#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2053pub(crate) enum MimeParameterEncoding {
2054    None,
2055    Rfc2231,
2056    UnquotedRfc2047,
2057    QuotedRfc2047,
2058}
2059
2060#[derive(Debug, Clone, PartialEq, Eq)]
2061struct MimeParameter {
2062    pub name: BString,
2063    pub section: Option<u32>,
2064    pub mime_charset: Option<BString>,
2065    pub mime_language: Option<BString>,
2066    pub encoding: MimeParameterEncoding,
2067    pub value: BString,
2068}
2069
2070#[derive(Debug, Clone, PartialEq, Eq)]
2071pub struct MimeParameters {
2072    pub value: BString,
2073    parameters: Vec<MimeParameter>,
2074}
2075
2076impl MimeParameters {
2077    pub fn new(value: impl AsRef<[u8]>) -> Self {
2078        Self {
2079            value: value.as_ref().into(),
2080            parameters: vec![],
2081        }
2082    }
2083
2084    /// Decode all named parameters per RFC 2231 and return a map
2085    /// of the parameter names to parameters values.
2086    /// Incorrectly encoded parameters are silently ignored
2087    /// and are not returned in the resulting map.
2088    pub fn parameter_map(&self) -> BTreeMap<BString, BString> {
2089        let mut map = BTreeMap::new();
2090
2091        fn contains_key_ignore_case(map: &BTreeMap<BString, BString>, key: &[u8]) -> bool {
2092            for k in map.keys() {
2093                if k.eq_ignore_ascii_case(key) {
2094                    return true;
2095                }
2096            }
2097            false
2098        }
2099
2100        for entry in &self.parameters {
2101            let name = entry.name.as_bytes();
2102            if !contains_key_ignore_case(&map, name) {
2103                if let Some(value) = self.get(name) {
2104                    map.insert(name.into(), value);
2105                }
2106            }
2107        }
2108
2109        map
2110    }
2111
2112    /// Retrieve the value for a named parameter.
2113    /// This method will attempt to decode any %-encoded values
2114    /// per RFC 2231 and combine multi-element fields into a single
2115    /// contiguous value.
2116    /// Invalid charsets and encoding will be silently ignored.
2117    pub fn get(&self, name: impl AsRef<[u8]>) -> Option<BString> {
2118        let name = name.as_ref();
2119        let mut elements: Vec<_> = self
2120            .parameters
2121            .iter()
2122            .filter(|p| p.name.eq_ignore_ascii_case(name.as_bytes()))
2123            .collect();
2124        if elements.is_empty() {
2125            return None;
2126        }
2127        elements.sort_by(|a, b| a.section.cmp(&b.section));
2128
2129        let mut mime_charset = None;
2130        let mut result: Vec<u8> = vec![];
2131
2132        for ele in elements {
2133            if let Some(cset) = ele.mime_charset.as_ref().and_then(|b| b.to_str().ok()) {
2134                mime_charset = Encoding::by_name(&*cset);
2135            }
2136
2137            match ele.encoding {
2138                MimeParameterEncoding::Rfc2231 => {
2139                    if let Some(charset) = mime_charset.as_ref() {
2140                        let mut chars = ele.value.chars();
2141                        let mut bytes: Vec<u8> = vec![];
2142
2143                        fn char_to_bytes(c: char, bytes: &mut Vec<u8>) {
2144                            let mut buf = [0u8; 8];
2145                            let s = c.encode_utf8(&mut buf);
2146                            for b in s.bytes() {
2147                                bytes.push(b);
2148                            }
2149                        }
2150
2151                        'next_char: while let Some(c) = chars.next() {
2152                            match c {
2153                                '%' => {
2154                                    let mut value = 0u8;
2155                                    for _ in 0..2 {
2156                                        match chars.next() {
2157                                            Some(n) => match n {
2158                                                '0'..='9' => {
2159                                                    value <<= 4;
2160                                                    value |= n as u32 as u8 - b'0';
2161                                                }
2162                                                'a'..='f' => {
2163                                                    value <<= 4;
2164                                                    value |= (n as u32 as u8 - b'a') + 10;
2165                                                }
2166                                                'A'..='F' => {
2167                                                    value <<= 4;
2168                                                    value |= (n as u32 as u8 - b'A') + 10;
2169                                                }
2170                                                _ => {
2171                                                    char_to_bytes('%', &mut bytes);
2172                                                    char_to_bytes(n, &mut bytes);
2173                                                    break 'next_char;
2174                                                }
2175                                            },
2176                                            None => {
2177                                                char_to_bytes('%', &mut bytes);
2178                                                break 'next_char;
2179                                            }
2180                                        }
2181                                    }
2182
2183                                    bytes.push(value);
2184                                }
2185                                c => {
2186                                    char_to_bytes(c, &mut bytes);
2187                                }
2188                            }
2189                        }
2190
2191                        if let Ok(decoded) = charset.decode_simple(&bytes) {
2192                            result.push_str(&decoded);
2193                        }
2194                    } else {
2195                        result.push_str(&ele.value);
2196                    }
2197                }
2198                MimeParameterEncoding::UnquotedRfc2047
2199                | MimeParameterEncoding::QuotedRfc2047
2200                | MimeParameterEncoding::None => {
2201                    result.push_str(&ele.value);
2202                }
2203            }
2204        }
2205
2206        Some(result.into())
2207    }
2208
2209    /// Remove the named parameter
2210    pub fn remove(&mut self, name: impl AsRef<[u8]>) {
2211        let name = name.as_ref();
2212        self.parameters
2213            .retain(|p| !p.name.eq_ignore_ascii_case(name));
2214    }
2215
2216    pub fn set(&mut self, name: impl AsRef<[u8]>, value: impl AsRef<[u8]>) {
2217        self.set_with_encoding(name, value, MimeParameterEncoding::None)
2218    }
2219
2220    pub(crate) fn set_with_encoding(
2221        &mut self,
2222        name: impl AsRef<[u8]>,
2223        value: impl AsRef<[u8]>,
2224        encoding: MimeParameterEncoding,
2225    ) {
2226        self.remove(name.as_ref());
2227
2228        self.parameters.push(MimeParameter {
2229            name: name.as_ref().into(),
2230            value: value.as_ref().into(),
2231            section: None,
2232            mime_charset: None,
2233            mime_language: None,
2234            encoding,
2235        });
2236    }
2237
2238    pub fn is_multipart(&self) -> bool {
2239        self.value.starts_with_str("message/") || self.value.starts_with_str("multipart/")
2240    }
2241
2242    pub fn is_text(&self) -> bool {
2243        self.value.starts_with_str("text/")
2244    }
2245}
2246
2247impl EncodeHeaderValue for MimeParameters {
2248    fn encode_value(&self) -> SharedString<'static> {
2249        let mut result = self.value.clone();
2250        let names: BTreeMap<&BStr, MimeParameterEncoding> = self
2251            .parameters
2252            .iter()
2253            .map(|p| (p.name.as_bstr(), p.encoding))
2254            .collect();
2255
2256        for (name, stated_encoding) in names {
2257            let value = self.get(name).expect("name to be present");
2258
2259            match stated_encoding {
2260                MimeParameterEncoding::UnquotedRfc2047 => {
2261                    let encoded = qp_encode(&value);
2262                    result.push_str(&format!(";\r\n\t{name}={encoded}"));
2263                }
2264                MimeParameterEncoding::QuotedRfc2047 => {
2265                    let encoded = qp_encode(&value);
2266                    result.push_str(&format!(";\r\n\t{name}=\"{encoded}\""));
2267                }
2268                MimeParameterEncoding::None | MimeParameterEncoding::Rfc2231 => {
2269                    let needs_encoding = value.iter().any(|&c| !is_mime_token(c) || !c.is_ascii());
2270                    // Prefer to use quoted_string representation when possible, as it doesn't
2271                    // require any RFC 2231 encoding
2272                    let use_quoted_string = value
2273                        .iter()
2274                        .all(|&c| (is_qtext(c) || is_quoted_pair(c)) && c.is_ascii());
2275
2276                    let mut params = vec![];
2277                    let mut chars = value.char_indices().peekable();
2278                    while chars.peek().is_some() {
2279                        let count = params.len();
2280                        let is_first = count == 0;
2281                        let prefix = if use_quoted_string {
2282                            "\""
2283                        } else if is_first && needs_encoding {
2284                            "UTF-8''"
2285                        } else {
2286                            ""
2287                        };
2288                        let limit = 74 - (name.len() + 4 + prefix.len());
2289
2290                        let mut encoded: Vec<u8> = vec![];
2291
2292                        while encoded.len() < limit {
2293                            let Some((start, end, c)) = chars.next() else {
2294                                break;
2295                            };
2296                            let s = &value[start..end];
2297
2298                            if use_quoted_string {
2299                                if c == '"' || c == '\\' {
2300                                    encoded.push(b'\\');
2301                                }
2302                                encoded.push_str(s);
2303                            } else if (c as u32) <= 0xff
2304                                && is_mime_token(c as u32 as u8)
2305                                && (!needs_encoding || c != '%')
2306                            {
2307                                encoded.push_str(s);
2308                            } else {
2309                                for b in s.bytes() {
2310                                    encoded.push(b'%');
2311                                    encoded.push(HEX_CHARS[(b as usize) >> 4]);
2312                                    encoded.push(HEX_CHARS[(b as usize) & 0x0f]);
2313                                }
2314                            }
2315                        }
2316
2317                        if use_quoted_string {
2318                            encoded.push(b'"');
2319                        }
2320
2321                        params.push(MimeParameter {
2322                            name: name.into(),
2323                            section: Some(count as u32),
2324                            mime_charset: if is_first { Some("UTF-8".into()) } else { None },
2325                            mime_language: None,
2326                            encoding: if needs_encoding {
2327                                MimeParameterEncoding::Rfc2231
2328                            } else {
2329                                MimeParameterEncoding::None
2330                            },
2331                            value: encoded.into(),
2332                        })
2333                    }
2334                    if params.len() == 1 {
2335                        params.last_mut().map(|p| p.section = None);
2336                    }
2337                    for p in params {
2338                        result.push_str(";\r\n\t");
2339                        let charset_tick = if !use_quoted_string
2340                            && (p.mime_charset.is_some() || p.mime_language.is_some())
2341                        {
2342                            "'"
2343                        } else {
2344                            ""
2345                        };
2346                        let lang_tick = if !use_quoted_string
2347                            && (p.mime_language.is_some() || p.mime_charset.is_some())
2348                        {
2349                            "'"
2350                        } else {
2351                            ""
2352                        };
2353
2354                        let section = p
2355                            .section
2356                            .map(|s| format!("*{s}"))
2357                            .unwrap_or_else(String::new);
2358
2359                        let uses_encoding =
2360                            if !use_quoted_string && p.encoding == MimeParameterEncoding::Rfc2231 {
2361                                "*"
2362                            } else {
2363                                ""
2364                            };
2365                        let charset = if use_quoted_string {
2366                            BStr::new("\"")
2367                        } else {
2368                            p.mime_charset
2369                                .as_ref()
2370                                .map(|b| b.as_bstr())
2371                                .unwrap_or(BStr::new(""))
2372                        };
2373                        let lang = p
2374                            .mime_language
2375                            .as_ref()
2376                            .map(|b| b.as_bstr())
2377                            .unwrap_or(BStr::new(""));
2378
2379                        let line = format!(
2380                            "{name}{section}{uses_encoding}={charset}{charset_tick}{lang}{lang_tick}{value}",
2381                            name = &p.name,
2382                            value = &p.value
2383                        );
2384                        result.push_str(&line);
2385                    }
2386                }
2387            }
2388        }
2389        result.into()
2390    }
2391}
2392
2393static HEX_CHARS: &[u8] = b"0123456789ABCDEF";
2394
2395pub(crate) fn qp_encode(s: &[u8]) -> String {
2396    let prefix = b"=?UTF-8?q?";
2397    let suffix = b"?=";
2398    let limit = 72 - (prefix.len() + suffix.len());
2399
2400    let mut result = Vec::with_capacity(s.len());
2401
2402    result.extend_from_slice(prefix);
2403    let mut line_length = 0;
2404
2405    enum Bytes<'a> {
2406        Passthru(&'a [u8]),
2407        Encode(&'a [u8]),
2408    }
2409
2410    // Iterate by char so that we don't confuse space (0x20) with a
2411    // utf8 subsequence and incorrectly encode the input string.
2412    for (start, end, c) in s.char_indices() {
2413        let bytes = &s[start..end];
2414
2415        let b = if (c.is_ascii_alphanumeric() || c.is_ascii_punctuation())
2416            && c != '?'
2417            && c != '='
2418            && c != ' '
2419            && c != '\t'
2420        {
2421            Bytes::Passthru(bytes)
2422        } else if c == ' ' {
2423            Bytes::Passthru(b"_")
2424        } else {
2425            Bytes::Encode(bytes)
2426        };
2427
2428        let need_len = match b {
2429            Bytes::Passthru(b) => b.len(),
2430            Bytes::Encode(b) => b.len() * 3,
2431        };
2432
2433        if need_len > limit - line_length {
2434            // Need to wrap
2435            result.extend_from_slice(suffix);
2436            result.extend_from_slice(b"\r\n\t");
2437            result.extend_from_slice(prefix);
2438            line_length = 0;
2439        }
2440
2441        match b {
2442            Bytes::Passthru(c) => {
2443                result.extend_from_slice(c);
2444            }
2445            Bytes::Encode(bytes) => {
2446                for &c in bytes {
2447                    result.push(b'=');
2448                    result.push(HEX_CHARS[(c as usize) >> 4]);
2449                    result.push(HEX_CHARS[(c as usize) & 0x0f]);
2450                }
2451            }
2452        }
2453
2454        line_length += need_len;
2455    }
2456
2457    if line_length > 0 {
2458        result.extend_from_slice(suffix);
2459    }
2460
2461    // Safety: we ensured that everything we output is in the ASCII
2462    // range, therefore the string is valid UTF-8
2463    unsafe { String::from_utf8_unchecked(result) }
2464}
2465
2466#[cfg(test)]
2467#[test]
2468fn test_qp_encode() {
2469    let encoded = qp_encode(
2470        b"hello, I am a line that is this long, or maybe a little \
2471        bit longer than this, and that should get wrapped by the encoder",
2472    );
2473    k9::snapshot!(
2474        encoded,
2475        r#"
2476=?UTF-8?q?hello,_I_am_a_line_that_is_this_long,_or_maybe_a_little_bit_?=\r
2477\t=?UTF-8?q?longer_than_this,_and_that_should_get_wrapped_by_the_encoder?=
2478"#
2479    );
2480}
2481
2482/// Quote input string `s`, using a backslash escape, if any
2483/// of the characters is NOT atext.  When quoting, the input
2484/// string is enclosed in quotes.
2485fn quote_string(s: impl AsRef<[u8]>) -> BString {
2486    let s = s.as_ref();
2487
2488    if s.iter().any(|&c| !is_atext(c)) {
2489        let mut result = Vec::<u8>::with_capacity(s.len() + 4);
2490        result.push(b'"');
2491        for (start, end, c) in s.char_indices() {
2492            let c = c as u32;
2493            if c <= 0xff {
2494                let c = c as u8;
2495                if !c.is_ascii_whitespace() && !is_qtext(c) && !is_atext(c) {
2496                    result.push(b'\\');
2497                }
2498            }
2499            result.push_str(&s[start..end]);
2500        }
2501        result.push(b'"');
2502        result.into()
2503    } else {
2504        s.into()
2505    }
2506}
2507
2508#[cfg(test)]
2509#[test]
2510fn test_quote_string() {
2511    k9::snapshot!(
2512        quote_string("TEST [ne_pas_repondre]"),
2513        r#""TEST [ne_pas_repondre]""#
2514    );
2515    k9::snapshot!(quote_string("hello"), "hello");
2516    k9::snapshot!(quote_string("hello there"), r#""hello there""#);
2517    k9::snapshot!(quote_string("hello, there"), "\"hello, there\"");
2518    k9::snapshot!(quote_string("hello \"there\""), r#""hello \\"there\\"""#);
2519    k9::snapshot!(
2520        quote_string("hello c:\\backslash"),
2521        r#""hello c:\\\\backslash""#
2522    );
2523    k9::assert_equal!(quote_string("hello\n there"), "\"hello\n there\"");
2524}
2525
2526impl EncodeHeaderValue for Mailbox {
2527    fn encode_value(&self) -> SharedString<'static> {
2528        match &self.name {
2529            Some(name) => {
2530                let mut value: Vec<u8> = if name.is_ascii() {
2531                    quote_string(name).into()
2532                } else {
2533                    qp_encode(name.as_bytes()).into_bytes()
2534                };
2535
2536                value.push_str(" <");
2537                value.push_str(self.address.encode_value().as_bytes());
2538                value.push(b'>');
2539                value.into()
2540            }
2541            None => {
2542                let mut result: Vec<u8> = vec![];
2543                result.push(b'<');
2544                result.push_str(self.address.encode_value().as_bytes());
2545                result.push(b'>');
2546                result.into()
2547            }
2548        }
2549    }
2550}
2551
2552impl EncodeHeaderValue for MailboxList {
2553    fn encode_value(&self) -> SharedString<'static> {
2554        let mut result: Vec<u8> = vec![];
2555        for mailbox in &self.0 {
2556            if !result.is_empty() {
2557                result.push_str(",\r\n\t");
2558            }
2559            result.push_str(mailbox.encode_value().as_bytes());
2560        }
2561        result.into()
2562    }
2563}
2564
2565impl EncodeHeaderValue for Address {
2566    fn encode_value(&self) -> SharedString<'static> {
2567        match self {
2568            Self::Mailbox(mbox) => mbox.encode_value(),
2569            Self::Group { name, entries } => {
2570                let mut result: Vec<u8> = vec![];
2571                result.push_str(name);
2572                result.push(b':');
2573                result.push_str(entries.encode_value().as_bytes());
2574                result.push(b';');
2575                result.into()
2576            }
2577        }
2578    }
2579}
2580
2581impl EncodeHeaderValue for AddressList {
2582    fn encode_value(&self) -> SharedString<'static> {
2583        let mut result: Vec<u8> = vec![];
2584        for address in &self.0 {
2585            if !result.is_empty() {
2586                result.push_str(",\r\n\t");
2587            }
2588            result.push_str(address.encode_value().as_bytes());
2589        }
2590        result.into()
2591    }
2592}
2593
2594#[cfg(test)]
2595mod test {
2596    use super::*;
2597    use crate::{Header, MessageConformance, MimePart};
2598
2599    #[test]
2600    fn mailbox_encodes_at() {
2601        let mbox = Mailbox {
2602            name: Some("foo@bar.com".into()),
2603            address: AddrSpec {
2604                local_part: "foo".into(),
2605                domain: "bar.com".into(),
2606            },
2607        };
2608        assert_eq!(mbox.encode_value(), "\"foo@bar.com\" <foo@bar.com>");
2609    }
2610
2611    #[test]
2612    fn mailbox_list_singular() {
2613        let message = concat!(
2614            "From:  Someone (hello) <someone@example.com>, other@example.com,\n",
2615            "  \"John \\\"Smith\\\"\" (comment) \"More Quotes\" (more comment) <someone(another comment)@crazy.example.com(woot)>\n",
2616            "\n",
2617            "I am the body"
2618        );
2619        let msg = MimePart::parse(message).unwrap();
2620        let list = match msg.headers().from() {
2621            Err(err) => panic!("Doh.\n{err:#}"),
2622            Ok(list) => list,
2623        };
2624
2625        k9::snapshot!(
2626            list,
2627            r#"
2628Some(
2629    MailboxList(
2630        [
2631            Mailbox {
2632                name: Some(
2633                    "Someone",
2634                ),
2635                address: AddrSpec {
2636                    local_part: "someone",
2637                    domain: "example.com",
2638                },
2639            },
2640            Mailbox {
2641                name: None,
2642                address: AddrSpec {
2643                    local_part: "other",
2644                    domain: "example.com",
2645                },
2646            },
2647            Mailbox {
2648                name: Some(
2649                    "John "Smith" More Quotes",
2650                ),
2651                address: AddrSpec {
2652                    local_part: "someone",
2653                    domain: "crazy.example.com",
2654                },
2655            },
2656        ],
2657    ),
2658)
2659"#
2660        );
2661    }
2662
2663    #[test]
2664    fn docomo_non_compliant_localpart() {
2665        let message = "Sender: hello..there@docomo.ne.jp\n\n\n";
2666        let msg = MimePart::parse(message).unwrap();
2667        let err = msg.headers().sender().unwrap_err();
2668        k9::snapshot!(
2669            err,
2670            r#"
2671InvalidHeaderValueDuringGet {
2672    header_name: "Sender",
2673    error: HeaderParse(
2674        "Error at line 1, expected "@" but found ".":
2675hello..there@docomo.ne.jp
2676     ^___________________
2677
2678while parsing addr_spec
2679while parsing mailbox
2680",
2681    ),
2682}
2683"#
2684        );
2685    }
2686
2687    #[test]
2688    fn sender() {
2689        let message = "Sender: someone@[127.0.0.1]\n\n\n";
2690        let msg = MimePart::parse(message).unwrap();
2691        let list = match msg.headers().sender() {
2692            Err(err) => panic!("Doh.\n{err:#}"),
2693            Ok(list) => list,
2694        };
2695        k9::snapshot!(
2696            list,
2697            r#"
2698Some(
2699    Mailbox {
2700        name: None,
2701        address: AddrSpec {
2702            local_part: "someone",
2703            domain: "[127.0.0.1]",
2704        },
2705    },
2706)
2707"#
2708        );
2709    }
2710
2711    #[test]
2712    fn domain_literal() {
2713        let message = "From: someone@[127.0.0.1]\n\n\n";
2714        let msg = MimePart::parse(message).unwrap();
2715        let list = match msg.headers().from() {
2716            Err(err) => panic!("Doh.\n{err:#}"),
2717            Ok(list) => list,
2718        };
2719        k9::snapshot!(
2720            list,
2721            r#"
2722Some(
2723    MailboxList(
2724        [
2725            Mailbox {
2726                name: None,
2727                address: AddrSpec {
2728                    local_part: "someone",
2729                    domain: "[127.0.0.1]",
2730                },
2731            },
2732        ],
2733    ),
2734)
2735"#
2736        );
2737    }
2738
2739    #[test]
2740    fn rfc6532() {
2741        let message = concat!(
2742            "From: Keith Moore <moore@cs.utk.edu>\n",
2743            "To: Keld Jørn Simonsen <keld@dkuug.dk>\n",
2744            "CC: André Pirard <PIRARD@vm1.ulg.ac.be>\n",
2745            "Subject: Hello André\n",
2746            "\n\n"
2747        );
2748        let msg = MimePart::parse(message).unwrap();
2749        let list = match msg.headers().from() {
2750            Err(err) => panic!("Doh.\n{err:#}"),
2751            Ok(list) => list,
2752        };
2753        k9::snapshot!(
2754            list,
2755            r#"
2756Some(
2757    MailboxList(
2758        [
2759            Mailbox {
2760                name: Some(
2761                    "Keith Moore",
2762                ),
2763                address: AddrSpec {
2764                    local_part: "moore",
2765                    domain: "cs.utk.edu",
2766                },
2767            },
2768        ],
2769    ),
2770)
2771"#
2772        );
2773
2774        let list = match msg.headers().to() {
2775            Err(err) => panic!("Doh.\n{err:#}"),
2776            Ok(list) => list,
2777        };
2778        k9::snapshot!(
2779            list,
2780            r#"
2781Some(
2782    AddressList(
2783        [
2784            Mailbox(
2785                Mailbox {
2786                    name: Some(
2787                        "Keld Jørn Simonsen",
2788                    ),
2789                    address: AddrSpec {
2790                        local_part: "keld",
2791                        domain: "dkuug.dk",
2792                    },
2793                },
2794            ),
2795        ],
2796    ),
2797)
2798"#
2799        );
2800
2801        let list = match msg.headers().cc() {
2802            Err(err) => panic!("Doh.\n{err:#}"),
2803            Ok(list) => list,
2804        };
2805        k9::snapshot!(
2806            list,
2807            r#"
2808Some(
2809    AddressList(
2810        [
2811            Mailbox(
2812                Mailbox {
2813                    name: Some(
2814                        "André Pirard",
2815                    ),
2816                    address: AddrSpec {
2817                        local_part: "PIRARD",
2818                        domain: "vm1.ulg.ac.be",
2819                    },
2820                },
2821            ),
2822        ],
2823    ),
2824)
2825"#
2826        );
2827        let list = match msg.headers().subject() {
2828            Err(err) => panic!("Doh.\n{err:#}"),
2829            Ok(list) => list,
2830        };
2831        k9::snapshot!(
2832            list,
2833            r#"
2834Some(
2835    "Hello André",
2836)
2837"#
2838        );
2839    }
2840
2841    #[test]
2842    fn unstructured_bare_non_ascii() {
2843        // Direct test of unstructured header parsing with bare UTF-8
2844        // (no encoded-word), exercising obs_utext -> utf8_non_ascii
2845        let message = "Subject: Héllo wörld äöü\n\n\n";
2846        let msg = MimePart::parse(message).unwrap();
2847        k9::snapshot!(
2848            msg.headers().subject().unwrap(),
2849            r#"
2850Some(
2851    "Héllo wörld äöü",
2852)
2853"#
2854        );
2855
2856        // Subject with CJK characters
2857        let message = "Subject: 件名テスト\n\n\n";
2858        let msg = MimePart::parse(message).unwrap();
2859        k9::snapshot!(
2860            msg.headers().subject().unwrap(),
2861            r#"
2862Some(
2863    "件名テスト",
2864)
2865"#
2866        );
2867    }
2868
2869    #[test]
2870    fn unstructured_raw_shift_jis() {
2871        // Raw Shift-JIS bytes in a Subject header (not wrapped in an
2872        // RFC 2047 encoded-word). "テスト" in Shift-JIS is:
2873        //   テ=0x83 0x65  ス=0x83 0x58  ト=0x83 0x67
2874        // These bytes are not valid UTF-8 (0x83 is a continuation byte
2875        // appearing as a lead byte). With utf8_non_ascii validation,
2876        // the parser will not match them as non-ASCII text.
2877        let message = b"Subject: \x83\x65\x83\x58\x83\x67\n\n\n";
2878
2879        // Structural parse succeeds: the message is split into headers
2880        // and body, and the Subject header is recognized.
2881        let msg = MimePart::parse(message.as_slice()).unwrap();
2882        let subject_header = msg.headers().get_first("Subject").unwrap();
2883        k9::assert_equal!(
2884            subject_header.get_raw_value(),
2885            b"\x83\x65\x83\x58\x83\x67".as_slice()
2886        );
2887
2888        // Semantic parse of the value as unstructured text fails because
2889        // the raw bytes are not valid UTF-8.
2890        k9::snapshot!(
2891            msg.headers().subject(),
2892            r#"
2893Err(
2894    InvalidHeaderValueDuringGet {
2895        header_name: "Subject",
2896        error: HeaderParse(
2897            "Error at line 1, in Eof:
2898\\x83e\\x83X\\x83g
2899^_____
2900
2901",
2902        ),
2903    },
2904)
2905"#
2906        );
2907    }
2908
2909    #[test]
2910    fn rfc2047_bogus() {
2911        let message = concat!(
2912            "From: =?US-OSCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2913            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2914            "CC: =?ISO-8859-1?Q?Andr=E?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2915            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?=\n",
2916            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2917            "\n\n"
2918        );
2919        let msg = MimePart::parse(message).unwrap();
2920
2921        // Invalid charset causes encoded_word to fail and we will instead match
2922        // obs_utext and return it as it was
2923        k9::assert_equal!(
2924            msg.headers().from().unwrap().unwrap().0[0]
2925                .name
2926                .as_ref()
2927                .unwrap(),
2928            "=?US-OSCII?Q?Keith_Moore?="
2929        );
2930
2931        match &msg.headers().cc().unwrap().unwrap().0[0] {
2932            Address::Mailbox(mbox) => {
2933                // 'Andr=E9?=' is in the non-bogus example below, but above we
2934                // broke it as 'Andr=E?=', and instead of triggering a qp decode
2935                // error, it is passed through here as-is
2936                k9::assert_equal!(mbox.name.as_ref().unwrap(), "Andr=E Pirard");
2937            }
2938            wat => panic!("should not have {wat:?}"),
2939        }
2940
2941        // The invalid base64 (an I was replaced by an !) is interpreted as obs_utext
2942        // and passed through to us
2943        k9::assert_equal!(
2944            msg.headers().subject().unwrap().unwrap(),
2945            "Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?= u understand the example."
2946        );
2947    }
2948
2949    #[test]
2950    fn attachment_filename_mess_totally_bogus() {
2951        let message = concat!("Content-Disposition: attachment; filename=@\n", "\n\n");
2952        let msg = MimePart::parse(message).unwrap();
2953        eprintln!("{msg:#?}");
2954
2955        assert!(msg
2956            .conformance()
2957            .contains(MessageConformance::INVALID_MIME_HEADERS));
2958        msg.headers().content_disposition().unwrap_err();
2959
2960        // There is no Content-Disposition in the rebuilt message, because
2961        // there was no valid Content-Disposition in what we parsed
2962        let rebuilt = msg.rebuild(None).unwrap();
2963        k9::assert_equal!(rebuilt.headers().content_disposition(), Ok(None));
2964    }
2965
2966    #[test]
2967    fn attachment_filename_mess_aberrant() {
2968        let message = concat!(
2969            "Content-Disposition: attachment; filename= =?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\n",
2970            "\n\n"
2971        );
2972        let msg = MimePart::parse(message).unwrap();
2973
2974        let cd = msg.headers().content_disposition().unwrap().unwrap();
2975        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
2976
2977        let encoded = cd.encode_value();
2978        k9::assert_equal!(encoded, "attachment;\r\n\tfilename==?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98?=");
2979    }
2980
2981    #[test]
2982    fn attachment_filename_mess_gmail() {
2983        let message = concat!(
2984            "Content-Disposition: attachment; filename=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
2985            "Content-Type: text/plain;\n",
2986            "   name=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
2987            "\n\n"
2988        );
2989        let msg = MimePart::parse(message).unwrap();
2990
2991        let cd = msg.headers().content_disposition().unwrap().unwrap();
2992        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
2993        let encoded = cd.encode_value();
2994        k9::assert_equal!(encoded, "attachment;\r\n\tfilename=\"=?UTF-8?q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98?=\"");
2995
2996        let ct = msg.headers().content_type().unwrap().unwrap();
2997        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付");
2998    }
2999
3000    #[test]
3001    fn attachment_filename_mess_fastmail() {
3002        let message = concat!(
3003            "Content-Disposition: attachment;\n",
3004            "  filename*0*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98;\n",
3005            "  filename*1*=.txt\n",
3006            "Content-Type: text/plain;\n",
3007            "   name=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=\"\n",
3008            "   x-name=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=bork\"\n",
3009            "\n\n"
3010        );
3011        let msg = MimePart::parse(message).unwrap();
3012
3013        let cd = msg.headers().content_disposition().unwrap().unwrap();
3014        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付.txt");
3015
3016        let ct = msg.headers().content_type().unwrap().unwrap();
3017        eprintln!("{ct:#?}");
3018        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付.txt");
3019        k9::assert_equal!(
3020            ct.get("x-name").unwrap(),
3021            "=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=bork"
3022        );
3023    }
3024
3025    #[test]
3026    fn rfc2047() {
3027        let message = concat!(
3028            "From: =?US-ASCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
3029            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
3030            "CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
3031            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n",
3032            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
3033            "\n\n"
3034        );
3035        let msg = MimePart::parse(message).unwrap();
3036        let list = match msg.headers().from() {
3037            Err(err) => panic!("Doh.\n{err:#}"),
3038            Ok(list) => list,
3039        };
3040        k9::snapshot!(
3041            list,
3042            r#"
3043Some(
3044    MailboxList(
3045        [
3046            Mailbox {
3047                name: Some(
3048                    "Keith Moore",
3049                ),
3050                address: AddrSpec {
3051                    local_part: "moore",
3052                    domain: "cs.utk.edu",
3053                },
3054            },
3055        ],
3056    ),
3057)
3058"#
3059        );
3060
3061        let list = match msg.headers().to() {
3062            Err(err) => panic!("Doh.\n{err:#}"),
3063            Ok(list) => list,
3064        };
3065        k9::snapshot!(
3066            list,
3067            r#"
3068Some(
3069    AddressList(
3070        [
3071            Mailbox(
3072                Mailbox {
3073                    name: Some(
3074                        "Keld Jørn Simonsen",
3075                    ),
3076                    address: AddrSpec {
3077                        local_part: "keld",
3078                        domain: "dkuug.dk",
3079                    },
3080                },
3081            ),
3082        ],
3083    ),
3084)
3085"#
3086        );
3087
3088        let list = match msg.headers().cc() {
3089            Err(err) => panic!("Doh.\n{err:#}"),
3090            Ok(list) => list,
3091        };
3092        k9::snapshot!(
3093            list,
3094            r#"
3095Some(
3096    AddressList(
3097        [
3098            Mailbox(
3099                Mailbox {
3100                    name: Some(
3101                        "André Pirard",
3102                    ),
3103                    address: AddrSpec {
3104                        local_part: "PIRARD",
3105                        domain: "vm1.ulg.ac.be",
3106                    },
3107                },
3108            ),
3109        ],
3110    ),
3111)
3112"#
3113        );
3114        let list = match msg.headers().subject() {
3115            Err(err) => panic!("Doh.\n{err:#}"),
3116            Ok(list) => list,
3117        };
3118        k9::snapshot!(
3119            list,
3120            r#"
3121Some(
3122    "Hello If you can read this you understand the example.",
3123)
3124"#
3125        );
3126
3127        k9::snapshot!(
3128            BString::from(msg.rebuild(None).unwrap().to_message_bytes()),
3129            r#"
3130Content-Type: text/plain;\r
3131\tcharset="us-ascii"\r
3132Content-Transfer-Encoding: quoted-printable\r
3133From: "Keith Moore" <moore@cs.utk.edu>\r
3134To: =?UTF-8?q?Keld_J=C3=B8rn_Simonsen?= <keld@dkuug.dk>\r
3135Cc: =?UTF-8?q?Andr=C3=A9_Pirard?= <PIRARD@vm1.ulg.ac.be>\r
3136Subject: Hello If you can read this you understand the example.\r
3137\r
3138=0A\r
3139
3140"#
3141        );
3142    }
3143
3144    #[test]
3145    fn group_addresses() {
3146        let message = concat!(
3147            "To: A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;\n",
3148            "Cc: Undisclosed recipients:;\n",
3149            "\n\n\n"
3150        );
3151        let msg = MimePart::parse(message).unwrap();
3152        let list = match msg.headers().to() {
3153            Err(err) => panic!("Doh.\n{err:#}"),
3154            Ok(list) => list.unwrap(),
3155        };
3156
3157        k9::snapshot!(
3158            list.encode_value(),
3159            r#"
3160A Group:"Ed Jones" <c@a.test>,\r
3161\t<joe@where.test>,\r
3162\tJohn <jdoe@one.test>;
3163"#
3164        );
3165
3166        let round_trip = Header::new("To", list.clone());
3167        k9::assert_equal!(list, round_trip.as_address_list().unwrap());
3168
3169        k9::snapshot!(
3170            list,
3171            r#"
3172AddressList(
3173    [
3174        Group {
3175            name: "A Group",
3176            entries: MailboxList(
3177                [
3178                    Mailbox {
3179                        name: Some(
3180                            "Ed Jones",
3181                        ),
3182                        address: AddrSpec {
3183                            local_part: "c",
3184                            domain: "a.test",
3185                        },
3186                    },
3187                    Mailbox {
3188                        name: None,
3189                        address: AddrSpec {
3190                            local_part: "joe",
3191                            domain: "where.test",
3192                        },
3193                    },
3194                    Mailbox {
3195                        name: Some(
3196                            "John",
3197                        ),
3198                        address: AddrSpec {
3199                            local_part: "jdoe",
3200                            domain: "one.test",
3201                        },
3202                    },
3203                ],
3204            ),
3205        },
3206    ],
3207)
3208"#
3209        );
3210
3211        let list = match msg.headers().cc() {
3212            Err(err) => panic!("Doh.\n{err:#}"),
3213            Ok(list) => list,
3214        };
3215        k9::snapshot!(
3216            list,
3217            r#"
3218Some(
3219    AddressList(
3220        [
3221            Group {
3222                name: "Undisclosed recipients",
3223                entries: MailboxList(
3224                    [],
3225                ),
3226            },
3227        ],
3228    ),
3229)
3230"#
3231        );
3232    }
3233
3234    #[test]
3235    fn message_id() {
3236        let message = concat!(
3237            "Message-Id: <foo@example.com>\n",
3238            "References: <a@example.com> <b@example.com>\n",
3239            "  <\"legacy\"@example.com>\n",
3240            "  <literal@[127.0.0.1]>\n",
3241            "\n\n\n"
3242        );
3243        let msg = MimePart::parse(message).unwrap();
3244        let list = match msg.headers().message_id() {
3245            Err(err) => panic!("Doh.\n{err:#}"),
3246            Ok(list) => list,
3247        };
3248        k9::snapshot!(
3249            list,
3250            r#"
3251Some(
3252    MessageID(
3253        "foo@example.com",
3254    ),
3255)
3256"#
3257        );
3258
3259        let list = match msg.headers().references() {
3260            Err(err) => panic!("Doh.\n{err:#}"),
3261            Ok(list) => list,
3262        };
3263        k9::snapshot!(
3264            list,
3265            r#"
3266Some(
3267    [
3268        MessageID(
3269            "a@example.com",
3270        ),
3271        MessageID(
3272            "b@example.com",
3273        ),
3274        MessageID(
3275            "legacy@example.com",
3276        ),
3277        MessageID(
3278            "literal@[127.0.0.1]",
3279        ),
3280    ],
3281)
3282"#
3283        );
3284    }
3285
3286    #[test]
3287    fn content_type() {
3288        let message = "Content-Type: text/plain\n\n\n\n";
3289        let msg = MimePart::parse(message).unwrap();
3290        let params = match msg.headers().content_type() {
3291            Err(err) => panic!("Doh.\n{err:#}"),
3292            Ok(params) => params,
3293        };
3294        k9::snapshot!(
3295            params,
3296            r#"
3297Some(
3298    MimeParameters {
3299        value: "text/plain",
3300        parameters: [],
3301    },
3302)
3303"#
3304        );
3305
3306        let message = "Content-Type: text/plain; charset=us-ascii\n\n\n\n";
3307        let msg = MimePart::parse(message).unwrap();
3308        let params = match msg.headers().content_type() {
3309            Err(err) => panic!("Doh.\n{err:#}"),
3310            Ok(params) => params.unwrap(),
3311        };
3312
3313        k9::snapshot!(
3314            params.get("charset"),
3315            r#"
3316Some(
3317    "us-ascii",
3318)
3319"#
3320        );
3321        k9::snapshot!(
3322            params,
3323            r#"
3324MimeParameters {
3325    value: "text/plain",
3326    parameters: [
3327        MimeParameter {
3328            name: "charset",
3329            section: None,
3330            mime_charset: None,
3331            mime_language: None,
3332            encoding: None,
3333            value: "us-ascii",
3334        },
3335    ],
3336}
3337"#
3338        );
3339
3340        let message = "Content-Type: text/plain; charset=\"us-ascii\"\n\n\n\n";
3341        let msg = MimePart::parse(message).unwrap();
3342        let params = match msg.headers().content_type() {
3343            Err(err) => panic!("Doh.\n{err:#}"),
3344            Ok(params) => params,
3345        };
3346        k9::snapshot!(
3347            params,
3348            r#"
3349Some(
3350    MimeParameters {
3351        value: "text/plain",
3352        parameters: [
3353            MimeParameter {
3354                name: "charset",
3355                section: None,
3356                mime_charset: None,
3357                mime_language: None,
3358                encoding: None,
3359                value: "us-ascii",
3360            },
3361        ],
3362    },
3363)
3364"#
3365        );
3366    }
3367
3368    #[test]
3369    fn content_type_rfc2231() {
3370        // This example is taken from the errata for rfc2231.
3371        // <https://www.rfc-editor.org/errata/eid590>
3372        let message = concat!(
3373            "Content-Type: application/x-stuff;\n",
3374            "\ttitle*0*=us-ascii'en'This%20is%20even%20more%20;\n",
3375            "\ttitle*1*=%2A%2A%2Afun%2A%2A%2A%20;\n",
3376            "\ttitle*2=\"isn't it!\"\n",
3377            "\n\n\n"
3378        );
3379        let msg = MimePart::parse(message).unwrap();
3380        let mut params = match msg.headers().content_type() {
3381            Err(err) => panic!("Doh.\n{err:#}"),
3382            Ok(params) => params.unwrap(),
3383        };
3384
3385        let original_title = params.get("title");
3386        k9::snapshot!(
3387            &original_title,
3388            r#"
3389Some(
3390    "This is even more ***fun*** isn't it!",
3391)
3392"#
3393        );
3394
3395        k9::snapshot!(
3396            &params,
3397            r#"
3398MimeParameters {
3399    value: "application/x-stuff",
3400    parameters: [
3401        MimeParameter {
3402            name: "title",
3403            section: Some(
3404                0,
3405            ),
3406            mime_charset: Some(
3407                "us-ascii",
3408            ),
3409            mime_language: Some(
3410                "en",
3411            ),
3412            encoding: Rfc2231,
3413            value: "This%20is%20even%20more%20",
3414        },
3415        MimeParameter {
3416            name: "title",
3417            section: Some(
3418                1,
3419            ),
3420            mime_charset: None,
3421            mime_language: None,
3422            encoding: Rfc2231,
3423            value: "%2A%2A%2Afun%2A%2A%2A%20",
3424        },
3425        MimeParameter {
3426            name: "title",
3427            section: Some(
3428                2,
3429            ),
3430            mime_charset: None,
3431            mime_language: None,
3432            encoding: None,
3433            value: "isn't it!",
3434        },
3435    ],
3436}
3437"#
3438        );
3439
3440        k9::snapshot!(
3441            params.encode_value(),
3442            r#"
3443application/x-stuff;\r
3444\ttitle="This is even more ***fun*** isn't it!"
3445"#
3446        );
3447
3448        params.set("foo", "bar 💩");
3449
3450        params.set(
3451            "long",
3452            "this is some text that should wrap because \
3453                it should be a good bit longer than our target maximum \
3454                length for this sort of thing, and hopefully we see at \
3455                least three lines produced as a result of setting \
3456                this value in this way",
3457        );
3458
3459        params.set(
3460            "longernnamethananyoneshouldreallyuse",
3461            "this is some text that should wrap because \
3462                it should be a good bit longer than our target maximum \
3463                length for this sort of thing, and hopefully we see at \
3464                least three lines produced as a result of setting \
3465                this value in this way",
3466        );
3467
3468        k9::snapshot!(
3469            params.encode_value(),
3470            r#"
3471application/x-stuff;\r
3472\tfoo*=UTF-8''bar%20%F0%9F%92%A9;\r
3473\tlong*0="this is some text that should wrap because it should be a good bi";\r
3474\tlong*1="t longer than our target maximum length for this sort of thing, a";\r
3475\tlong*2="nd hopefully we see at least three lines produced as a result of ";\r
3476\tlong*3="setting this value in this way";\r
3477\tlongernnamethananyoneshouldreallyuse*0="this is some text that should wra";\r
3478\tlongernnamethananyoneshouldreallyuse*1="p because it should be a good bit";\r
3479\tlongernnamethananyoneshouldreallyuse*2=" longer than our target maximum l";\r
3480\tlongernnamethananyoneshouldreallyuse*3="ength for this sort of thing, and";\r
3481\tlongernnamethananyoneshouldreallyuse*4=" hopefully we see at least three ";\r
3482\tlongernnamethananyoneshouldreallyuse*5="lines produced as a result of set";\r
3483\tlongernnamethananyoneshouldreallyuse*6="ting this value in this way";\r
3484\ttitle="This is even more ***fun*** isn't it!"
3485"#
3486        );
3487    }
3488
3489    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.2>
3490    #[test]
3491    fn authentication_results_b_2() {
3492        let ar = Header::with_name_value("Authentication-Results", "example.org 1; none");
3493        let ar = ar.as_authentication_results().unwrap();
3494        k9::snapshot!(
3495            &ar,
3496            r#"
3497AuthenticationResults {
3498    serv_id: "example.org",
3499    version: Some(
3500        1,
3501    ),
3502    results: [],
3503}
3504"#
3505        );
3506
3507        k9::snapshot!(ar.encode_value(), "example.org 1; none");
3508    }
3509
3510    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.3>
3511    #[test]
3512    fn authentication_results_b_3() {
3513        let ar = Header::with_name_value(
3514            "Authentication-Results",
3515            "example.com; spf=pass smtp.mailfrom=example.net",
3516        );
3517        k9::snapshot!(
3518            ar.as_authentication_results(),
3519            r#"
3520Ok(
3521    AuthenticationResults {
3522        serv_id: "example.com",
3523        version: None,
3524        results: [
3525            AuthenticationResult {
3526                method: "spf",
3527                method_version: None,
3528                result: "pass",
3529                reason: None,
3530                props: {
3531                    "smtp.mailfrom": "example.net",
3532                },
3533            },
3534        ],
3535    },
3536)
3537"#
3538        );
3539    }
3540
3541    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.4>
3542    #[test]
3543    fn authentication_results_b_4() {
3544        let ar = Header::with_name_value(
3545            "Authentication-Results",
3546            concat!(
3547                "example.com;\n",
3548                "\tauth=pass (cram-md5) smtp.auth=sender@example.net;\n",
3549                "\tspf=pass smtp.mailfrom=example.net"
3550            ),
3551        );
3552        k9::snapshot!(
3553            ar.as_authentication_results(),
3554            r#"
3555Ok(
3556    AuthenticationResults {
3557        serv_id: "example.com",
3558        version: None,
3559        results: [
3560            AuthenticationResult {
3561                method: "auth",
3562                method_version: None,
3563                result: "pass",
3564                reason: None,
3565                props: {
3566                    "smtp.auth": "sender@example.net",
3567                },
3568            },
3569            AuthenticationResult {
3570                method: "spf",
3571                method_version: None,
3572                result: "pass",
3573                reason: None,
3574                props: {
3575                    "smtp.mailfrom": "example.net",
3576                },
3577            },
3578        ],
3579    },
3580)
3581"#
3582        );
3583
3584        let ar = Header::with_name_value(
3585            "Authentication-Results",
3586            "example.com; iprev=pass\n\tpolicy.iprev=192.0.2.200",
3587        );
3588        k9::snapshot!(
3589            ar.as_authentication_results(),
3590            r#"
3591Ok(
3592    AuthenticationResults {
3593        serv_id: "example.com",
3594        version: None,
3595        results: [
3596            AuthenticationResult {
3597                method: "iprev",
3598                method_version: None,
3599                result: "pass",
3600                reason: None,
3601                props: {
3602                    "policy.iprev": "192.0.2.200",
3603                },
3604            },
3605        ],
3606    },
3607)
3608"#
3609        );
3610    }
3611
3612    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.5>
3613    #[test]
3614    fn authentication_results_b_5() {
3615        let ar = Header::with_name_value(
3616            "Authentication-Results",
3617            "example.com;\n\tdkim=pass (good signature) header.d=example.com",
3618        );
3619        k9::snapshot!(
3620            ar.as_authentication_results(),
3621            r#"
3622Ok(
3623    AuthenticationResults {
3624        serv_id: "example.com",
3625        version: None,
3626        results: [
3627            AuthenticationResult {
3628                method: "dkim",
3629                method_version: None,
3630                result: "pass",
3631                reason: None,
3632                props: {
3633                    "header.d": "example.com",
3634                },
3635            },
3636        ],
3637    },
3638)
3639"#
3640        );
3641
3642        let ar = Header::with_name_value(
3643            "Authentication-Results",
3644            "example.com;\n\tauth=pass (cram-md5) smtp.auth=sender@example.com;\n\tspf=fail smtp.mailfrom=example.com"
3645        );
3646        let ar = ar.as_authentication_results().unwrap();
3647        k9::snapshot!(
3648            &ar,
3649            r#"
3650AuthenticationResults {
3651    serv_id: "example.com",
3652    version: None,
3653    results: [
3654        AuthenticationResult {
3655            method: "auth",
3656            method_version: None,
3657            result: "pass",
3658            reason: None,
3659            props: {
3660                "smtp.auth": "sender@example.com",
3661            },
3662        },
3663        AuthenticationResult {
3664            method: "spf",
3665            method_version: None,
3666            result: "fail",
3667            reason: None,
3668            props: {
3669                "smtp.mailfrom": "example.com",
3670            },
3671        },
3672    ],
3673}
3674"#
3675        );
3676
3677        k9::snapshot!(
3678            ar.encode_value(),
3679            r#"
3680example.com;\r
3681\tauth=pass\r
3682\tsmtp.auth=sender@example.com;\r
3683\tspf=fail\r
3684\tsmtp.mailfrom=example.com
3685"#
3686        );
3687    }
3688
3689    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.6>
3690    #[test]
3691    fn authentication_results_b_6() {
3692        let ar = Header::with_name_value(
3693            "Authentication-Results",
3694            concat!(
3695                "example.com;\n",
3696                "\tdkim=pass reason=\"good signature\"\n",
3697                "\theader.i=@mail-router.example.net;\n",
3698                "\tdkim=fail reason=\"bad signature\"\n",
3699                "\theader.i=@newyork.example.com"
3700            ),
3701        );
3702        let ar = match ar.as_authentication_results() {
3703            Err(err) => panic!("\n{err}"),
3704            Ok(ar) => ar,
3705        };
3706
3707        k9::snapshot!(
3708            &ar,
3709            r#"
3710AuthenticationResults {
3711    serv_id: "example.com",
3712    version: None,
3713    results: [
3714        AuthenticationResult {
3715            method: "dkim",
3716            method_version: None,
3717            result: "pass",
3718            reason: Some(
3719                "good signature",
3720            ),
3721            props: {
3722                "header.i": "@mail-router.example.net",
3723            },
3724        },
3725        AuthenticationResult {
3726            method: "dkim",
3727            method_version: None,
3728            result: "fail",
3729            reason: Some(
3730                "bad signature",
3731            ),
3732            props: {
3733                "header.i": "@newyork.example.com",
3734            },
3735        },
3736    ],
3737}
3738"#
3739        );
3740
3741        k9::snapshot!(
3742            ar.encode_value(),
3743            r#"
3744example.com;\r
3745\tdkim=pass reason="good signature"\r
3746\theader.i=@mail-router.example.net;\r
3747\tdkim=fail reason="bad signature"\r
3748\theader.i=@newyork.example.com
3749"#
3750        );
3751
3752        let ar = Header::with_name_value(
3753            "Authentication-Results",
3754            concat!(
3755                "example.net;\n",
3756                "\tdkim=pass (good signature) header.i=@newyork.example.com"
3757            ),
3758        );
3759        let ar = match ar.as_authentication_results() {
3760            Err(err) => panic!("\n{err}"),
3761            Ok(ar) => ar,
3762        };
3763
3764        k9::snapshot!(
3765            &ar,
3766            r#"
3767AuthenticationResults {
3768    serv_id: "example.net",
3769    version: None,
3770    results: [
3771        AuthenticationResult {
3772            method: "dkim",
3773            method_version: None,
3774            result: "pass",
3775            reason: None,
3776            props: {
3777                "header.i": "@newyork.example.com",
3778            },
3779        },
3780    ],
3781}
3782"#
3783        );
3784
3785        k9::snapshot!(
3786            ar.encode_value(),
3787            r#"
3788example.net;\r
3789\tdkim=pass\r
3790\theader.i=@newyork.example.com
3791"#
3792        );
3793    }
3794
3795    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.7>
3796    #[test]
3797    fn authentication_results_b_7() {
3798        let ar = Header::with_name_value(
3799            "Authentication-Results",
3800            concat!(
3801                "foo.example.net (foobar) 1 (baz);\n",
3802                "\tdkim (Because I like it) / 1 (One yay) = (wait for it) fail\n",
3803                "\tpolicy (A dot can go here) . (like that) expired\n",
3804                "\t(this surprised me) = (as I wasn't expecting it) 1362471462"
3805            ),
3806        );
3807        let ar = match ar.as_authentication_results() {
3808            Err(err) => panic!("\n{err}"),
3809            Ok(ar) => ar,
3810        };
3811
3812        k9::snapshot!(
3813            &ar,
3814            r#"
3815AuthenticationResults {
3816    serv_id: "foo.example.net",
3817    version: Some(
3818        1,
3819    ),
3820    results: [
3821        AuthenticationResult {
3822            method: "dkim",
3823            method_version: Some(
3824                1,
3825            ),
3826            result: "fail",
3827            reason: None,
3828            props: {
3829                "policy.expired": "1362471462",
3830            },
3831        },
3832    ],
3833}
3834"#
3835        );
3836
3837        k9::snapshot!(
3838            ar.encode_value(),
3839            r#"
3840foo.example.net 1;\r
3841\tdkim/1=fail\r
3842\tpolicy.expired=1362471462
3843"#
3844        );
3845    }
3846
3847    #[test]
3848    fn arc_authentication_results_1() {
3849        let ar = Header::with_name_value(
3850            "ARC-Authentication-Results",
3851            "i=3; clochette.example.org; spf=fail
3852    smtp.from=jqd@d1.example; dkim=fail (512-bit key)
3853    header.i=@d1.example; dmarc=fail; arc=pass (as.2.gmail.example=pass,
3854    ams.2.gmail.example=pass, as.1.lists.example.org=pass,
3855    ams.1.lists.example.org=fail (message has been altered))",
3856        );
3857        let ar = match ar.as_arc_authentication_results() {
3858            Err(err) => panic!("\n{err}"),
3859            Ok(ar) => ar,
3860        };
3861
3862        k9::snapshot!(
3863            &ar,
3864            r#"
3865ARCAuthenticationResults {
3866    instance: 3,
3867    serv_id: "clochette.example.org",
3868    version: None,
3869    results: [
3870        AuthenticationResult {
3871            method: "spf",
3872            method_version: None,
3873            result: "fail",
3874            reason: None,
3875            props: {
3876                "smtp.from": "jqd@d1.example",
3877            },
3878        },
3879        AuthenticationResult {
3880            method: "dkim",
3881            method_version: None,
3882            result: "fail",
3883            reason: None,
3884            props: {
3885                "header.i": "@d1.example",
3886            },
3887        },
3888        AuthenticationResult {
3889            method: "dmarc",
3890            method_version: None,
3891            result: "fail",
3892            reason: None,
3893            props: {},
3894        },
3895        AuthenticationResult {
3896            method: "arc",
3897            method_version: None,
3898            result: "pass",
3899            reason: None,
3900            props: {},
3901        },
3902    ],
3903}
3904"#
3905        );
3906    }
3907
3908    #[test]
3909    fn bstring_utf8_serializes_utf8_as_string() {
3910        // A MessageID with pure ASCII content serializes as a JSON string
3911        let mid = MessageID(BString::from("abc123@example.com"));
3912        let json = serde_json::to_string(&mid).unwrap();
3913        k9::assert_equal!(json, r#""abc123@example.com""#);
3914    }
3915
3916    #[test]
3917    fn bstring_utf8_serializes_non_utf8_as_array() {
3918        // A MessageID with invalid UTF-8 falls back to byte array
3919        let mid = MessageID(BString::from(&b"hello\x80world"[..]));
3920        let json = serde_json::to_string(&mid).unwrap();
3921        k9::assert_equal!(json, "[104,101,108,108,111,128,119,111,114,108,100]");
3922    }
3923
3924    #[test]
3925    fn bstring_utf8_round_trip_utf8() {
3926        let mid = MessageID(BString::from("test@example.com"));
3927        let json = serde_json::to_string(&mid).unwrap();
3928        let restored: MessageID = serde_json::from_str(&json).unwrap();
3929        k9::assert_equal!(restored, mid);
3930    }
3931
3932    #[test]
3933    fn bstring_utf8_round_trip_non_utf8() {
3934        let mid = MessageID(BString::from(&b"\xff\xfe"[..]));
3935        let json = serde_json::to_string(&mid).unwrap();
3936        let restored: MessageID = serde_json::from_str(&json).unwrap();
3937        k9::assert_equal!(restored, mid);
3938    }
3939
3940    #[test]
3941    fn authentication_results_serialize_as_strings() {
3942        let ar = AuthenticationResults {
3943            serv_id: BString::from("example.com"),
3944            version: None,
3945            results: vec![AuthenticationResult {
3946                method: "dkim".into(),
3947                method_version: None,
3948                result: "pass".into(),
3949                reason: Some(BString::from("good signature")),
3950                props: BTreeMap::from([
3951                    ("header.d".into(), BString::from("example.com")),
3952                    ("header.s".into(), BString::from("selector1")),
3953                ]),
3954            }],
3955        };
3956        let json = serde_json::to_string_pretty(&ar).unwrap();
3957        // All BString fields that are valid UTF-8 should appear as JSON strings
3958        k9::assert_equal!(
3959            json,
3960            r#"{
3961  "serv_id": "example.com",
3962  "version": null,
3963  "results": [
3964    {
3965      "method": "dkim",
3966      "method_version": null,
3967      "result": "pass",
3968      "reason": "good signature",
3969      "props": {
3970        "header.d": "example.com",
3971        "header.s": "selector1"
3972      }
3973    }
3974  ]
3975}"#
3976        );
3977    }
3978
3979    #[test]
3980    fn authentication_results_round_trip() {
3981        let ar = AuthenticationResults {
3982            serv_id: BString::from("mx.example.org"),
3983            version: Some(1),
3984            results: vec![AuthenticationResult {
3985                method: "spf".into(),
3986                method_version: None,
3987                result: "pass".into(),
3988                reason: None,
3989                props: BTreeMap::from([(
3990                    "smtp.mailfrom".into(),
3991                    BString::from("sender@example.com"),
3992                )]),
3993            }],
3994        };
3995        let json = serde_json::to_string(&ar).unwrap();
3996        let restored: AuthenticationResults = serde_json::from_str(&json).unwrap();
3997        k9::assert_equal!(restored, ar);
3998    }
3999
4000    #[test]
4001    fn authentication_result_non_utf8_reason() {
4002        let ar = AuthenticationResult {
4003            method: "dkim".into(),
4004            method_version: None,
4005            result: "temperror".into(),
4006            reason: Some(BString::from(&b"bad\x80data"[..])),
4007            props: BTreeMap::new(),
4008        };
4009        let json = serde_json::to_string(&ar).unwrap();
4010        // reason should be a byte array since it contains invalid UTF-8
4011        assert!(json.contains(r#""reason":[98,97,100,128,100,97,116,97]"#));
4012        let restored: AuthenticationResult = serde_json::from_str(&json).unwrap();
4013        k9::assert_equal!(restored, ar);
4014    }
4015
4016    #[test]
4017    fn authentication_results_encode_value_with_binary() {
4018        // Construct AuthenticationResults with non-UTF-8 bytes in BString fields
4019        // and capture the encode_value() output for use in a Lua test.
4020        let ar = AuthenticationResults {
4021            serv_id: BString::from(&b"mx.ex\x80mple.com"[..]),
4022            version: None,
4023            results: vec![AuthenticationResult {
4024                method: "spf".into(),
4025                method_version: None,
4026                result: "pass".into(),
4027                reason: Some(BString::from(&b"good\xffsig"[..])),
4028                props: BTreeMap::from([(
4029                    "smtp.mailfrom".into(),
4030                    BString::from(&b"user@\xfehost"[..]),
4031                )]),
4032            }],
4033        };
4034        let encoded = ar.encode_value();
4035        k9::snapshot!(
4036            encoded,
4037            r#"
4038"mx.ex\x80mple.com";\r
4039\tspf=pass reason="good\xffsig"\r
4040\tsmtp.mailfrom="user@\xfehost"
4041"#
4042        );
4043    }
4044
4045    #[test]
4046    fn authentication_results_serv_id_quoting() {
4047        // A serv_id containing characters that need quoting is properly quoted
4048        let ar = AuthenticationResults {
4049            serv_id: BString::from("mx example.com"),
4050            version: None,
4051            results: vec![],
4052        };
4053        let encoded = ar.encode_value();
4054        k9::snapshot!(encoded, r#""mx example.com"; none"#);
4055
4056        // Normal domain-like serv_id is emitted bare
4057        let ar2 = AuthenticationResults {
4058            serv_id: BString::from("mx.example.com"),
4059            version: Some(1),
4060            results: vec![],
4061        };
4062        let encoded2 = ar2.encode_value();
4063        k9::snapshot!(&encoded2, "mx.example.com 1; none");
4064        // Bare serv_id roundtrips
4065        let parsed = Parser::parse_authentication_results_header(encoded2.as_bytes()).unwrap();
4066        k9::assert_equal!(parsed.serv_id, ar2.serv_id);
4067        k9::assert_equal!(parsed.version, Some(1));
4068    }
4069
4070    #[test]
4071    fn arc_authentication_results_serialize_as_strings() {
4072        let arc = ARCAuthenticationResults {
4073            instance: 1,
4074            serv_id: BString::from("mx.example.com"),
4075            version: None,
4076            results: vec![],
4077        };
4078        let json = serde_json::to_string(&arc).unwrap();
4079        k9::assert_equal!(
4080            json,
4081            r#"{"instance":1,"serv_id":"mx.example.com","version":null,"results":[]}"#
4082        );
4083    }
4084}