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
922/// Remove CFWS (comments and folding whitespace) from a header value, returning
923/// the surviving tokens joined by single spaces, or `None` if it does not
924/// tokenize cleanly.
925pub(crate) fn strip_cfws(input: &str) -> Option<String> {
926    fn token(input: Span) -> IResult<Span, Span> {
927        take_while1(|c| !matches!(c, b' ' | b'\t' | b'\r' | b'\n' | b'(')).parse(input)
928    }
929    fn tokens(input: Span) -> IResult<Span, Vec<Option<Span>>> {
930        many0(alt((map(cfws, |_| None), map(token, Some)))).parse(input)
931    }
932
933    let mut out: Vec<u8> = Vec::new();
934    for token in parse_with(input.as_bytes(), tokens)
935        .ok()?
936        .into_iter()
937        .flatten()
938    {
939        if !out.is_empty() {
940            out.push(b' ');
941        }
942        out.extend_from_slice(token.fragment());
943    }
944    String::from_utf8(out).ok()
945}
946
947#[cfg(test)]
948#[test]
949fn test_strip_cfws() {
950    k9::assert_equal!(
951        strip_cfws("Thu, 02 Jul 26 18:55:38 UTC (Coordinated)").unwrap(),
952        "Thu, 02 Jul 26 18:55:38 UTC"
953    );
954    k9::assert_equal!(
955        strip_cfws("Thu, 02 Jul 26 (comment) 18:55:38 UTC").unwrap(),
956        "Thu, 02 Jul 26 18:55:38 UTC"
957    );
958    // Nested comments and quoted parens are handled by the comment parser.
959    k9::assert_equal!(strip_cfws("a (b (c) \\) d) e").unwrap(), "a e");
960}
961
962fn is_quoted_pair_ascii(c: u8) -> bool {
963    match c {
964        0x00 | b'\r' | b'\n' | b' ' => true,
965        c => is_obs_no_ws_ctl(c) || is_vchar_ascii(c),
966    }
967}
968
969/// Byte-level predicate for quoted_pair, including UTF-8 continuation/leading
970/// bytes. Used for non-parser checks. For parsing, use `quoted_pair` which
971/// validates UTF-8 via `utf8_non_ascii`.
972fn is_quoted_pair(c: u8) -> bool {
973    is_quoted_pair_ascii(c) || c >= 0x80
974}
975
976// quoted_pair = { ( "\\"  ~ (vchar | wsp)) | obs_qp }
977// obs_qp = { "\\" ~ ( "\u{00}" | obs_no_ws_ctl | "\r" | "\n") }
978fn quoted_pair(input: Span) -> IResult<Span, Span> {
979    context(
980        "quoted_pair",
981        preceded(
982            tag("\\"),
983            alt((take_while_m_n(1, 1, is_quoted_pair_ascii), utf8_non_ascii)),
984        ),
985    )
986    .parse(input)
987}
988
989// encoded_word = { "=?" ~ charset ~ ("*" ~ language)? ~ "?" ~ encoding ~ "?" ~ encoded_text ~ "?=" }
990fn encoded_word(input: Span) -> IResult<Span, BString> {
991    let (loc, (charset, _language, _, encoding, _, text)) = context(
992        "encoded_word",
993        delimited(
994            tag("=?"),
995            (
996                charset,
997                opt(preceded(tag("*"), language)),
998                tag("?"),
999                encoding,
1000                tag("?"),
1001                encoded_text,
1002            ),
1003            tag("?="),
1004        ),
1005    )
1006    .parse(input)?;
1007
1008    let bytes = match *encoding.fragment() {
1009        b"B" | b"b" => data_encoding::BASE64_MIME
1010            .decode(text.as_bytes())
1011            .map_err(|err| {
1012                make_context_error(
1013                    input,
1014                    format!("encoded_word: base64 decode failed: {err:#}"),
1015                )
1016            })?,
1017        b"Q" | b"q" => {
1018            // for rfc2047 header encoding, _ can be used to represent a space
1019            let munged = text.replace("_", " ");
1020            // The quoted_printable crate will unhelpfully strip trailing space
1021            // from the decoded input string, and we must track and restore it
1022            let had_trailing_space = munged.ends_with_str(" ");
1023            let mut decoded = quoted_printable::decode(munged, quoted_printable::ParseMode::Robust)
1024                .map_err(|err| {
1025                    make_context_error(
1026                        input,
1027                        format!("encoded_word: quoted printable decode failed: {err:#}"),
1028                    )
1029                })?;
1030            if had_trailing_space && !decoded.ends_with(b" ") {
1031                decoded.push(b' ');
1032            }
1033            decoded
1034        }
1035        encoding => {
1036            let encoding = BStr::new(encoding);
1037            return Err(make_context_error(
1038                input,
1039                format!(
1040                    "encoded_word: invalid encoding '{encoding}', expected one of b, B, q or Q"
1041                ),
1042            ));
1043        }
1044    };
1045
1046    let charset_name = charset.to_str().map_err(|err| {
1047        make_context_error(
1048            input,
1049            format!(
1050                "encoded_word: charset {} is not UTF-8: {err}",
1051                BStr::new(*charset)
1052            ),
1053        )
1054    })?;
1055
1056    let charset = Encoding::by_name(&*charset_name).ok_or_else(|| {
1057        make_context_error(
1058            input,
1059            format!("encoded_word: unsupported charset '{charset_name}'"),
1060        )
1061    })?;
1062
1063    let decoded = charset.decode_simple(&bytes).map_err(|err| {
1064        make_context_error(
1065            input,
1066            format!("encoded_word: failed to decode as '{charset_name}': {err}"),
1067        )
1068    })?;
1069
1070    Ok((loc, decoded.into()))
1071}
1072
1073// charset = @{ (!"*" ~ token)+ }
1074fn charset(input: Span) -> IResult<Span, Span> {
1075    context("charset", take_while1(|c| c != b'*' && is_token(c))).parse(input)
1076}
1077
1078// language = @{ token+ }
1079fn language(input: Span) -> IResult<Span, Span> {
1080    context("language", take_while1(|c| c != b'*' && is_token(c))).parse(input)
1081}
1082
1083// encoding = @{ token+ }
1084fn encoding(input: Span) -> IResult<Span, Span> {
1085    context("encoding", take_while1(|c| c != b'*' && is_token(c))).parse(input)
1086}
1087
1088// encoded_text = @{ (!( " " | "?") ~ vchar)+ }
1089fn encoded_text(input: Span) -> IResult<Span, Span> {
1090    context(
1091        "encoded_text",
1092        recognize(many1(alt((
1093            take_while1(|c| is_vchar_ascii(c) && c != b' ' && c != b'?'),
1094            utf8_non_ascii,
1095        )))),
1096    )
1097    .parse(input)
1098}
1099
1100// quoted_string = { cfws? ~ "\"" ~ (fws? ~ qcontent)* ~ fws? ~ "\"" ~ cfws? }
1101fn quoted_string(input: Span) -> IResult<Span, BString> {
1102    let (loc, (bits, trailer)) = context(
1103        "quoted_string",
1104        delimited(
1105            opt(cfws),
1106            delimited(
1107                tag("\""),
1108                (many0((opt(fws), qcontent)), opt(fws)),
1109                tag("\""),
1110            ),
1111            opt(cfws),
1112        ),
1113    )
1114    .parse(input)?;
1115
1116    let mut result = BString::default();
1117    for (a, b) in bits {
1118        if let Some(a) = a {
1119            result.push_str(&a);
1120        }
1121        result.push_str(b);
1122    }
1123    if let Some(t) = trailer {
1124        result.push_str(&t);
1125    }
1126    Ok((loc, result))
1127}
1128
1129// qcontent = { qtext | quoted_pair }
1130fn qcontent(input: Span) -> IResult<Span, Span> {
1131    context(
1132        "qcontent",
1133        alt((
1134            take_while_m_n(1, 1, is_qtext_ascii),
1135            utf8_non_ascii,
1136            quoted_pair,
1137        )),
1138    )
1139    .parse(input)
1140}
1141
1142fn content_id(input: Span) -> IResult<Span, MessageID> {
1143    let (loc, id) = context("content_id", msg_id).parse(input)?;
1144    Ok((loc, id))
1145}
1146
1147fn msg_id(input: Span) -> IResult<Span, MessageID> {
1148    let (loc, id) = context("msg_id", alt((strict_msg_id, relaxed_msg_id))).parse(input)?;
1149    Ok((loc, id))
1150}
1151
1152fn relaxed_msg_id(input: Span) -> IResult<Span, MessageID> {
1153    let (loc, id) = context(
1154        "msg_id",
1155        delimited(
1156            preceded(opt(cfws), tag("<")),
1157            many0(take_while_m_n(1, 1, not_angle)),
1158            preceded(tag(">"), opt(cfws)),
1159        ),
1160    )
1161    .parse(input)?;
1162
1163    let mut result = BString::default();
1164    for item in id.into_iter() {
1165        result.push_str(*item);
1166    }
1167
1168    Ok((loc, MessageID(result)))
1169}
1170
1171// msg_id_list = { msg_id+ }
1172fn msg_id_list(input: Span) -> IResult<Span, Vec<MessageID>> {
1173    context("msg_id_list", many1(msg_id)).parse(input)
1174}
1175
1176// id_left = { dot_atom_text | obs_id_left }
1177// obs_id_left = { local_part }
1178fn id_left(input: Span) -> IResult<Span, BString> {
1179    context("id_left", alt((dot_atom_text, local_part))).parse(input)
1180}
1181
1182// id_right = { dot_atom_text | no_fold_literal | obs_id_right }
1183// obs_id_right = { domain }
1184fn id_right(input: Span) -> IResult<Span, BString> {
1185    context("id_right", alt((dot_atom_text, no_fold_literal, domain))).parse(input)
1186}
1187
1188// no_fold_literal = { "[" ~ dtext* ~ "]" }
1189fn no_fold_literal(input: Span) -> IResult<Span, BString> {
1190    context(
1191        "no_fold_literal",
1192        map(
1193            recognize((
1194                tag("["),
1195                recognize(many0(alt((take_while1(is_dtext_ascii), utf8_non_ascii)))),
1196                tag("]"),
1197            )),
1198            |s: Span| (*s).into(),
1199        ),
1200    )
1201    .parse(input)
1202}
1203
1204// msg_id = { cfws? ~ "<" ~ id_left ~ "@" ~ id_right ~ ">" ~ cfws? }
1205fn strict_msg_id(input: Span) -> IResult<Span, MessageID> {
1206    let (loc, (left, _, right)) = context(
1207        "msg_id",
1208        delimited(
1209            preceded(opt(cfws), tag("<")),
1210            (id_left, tag("@"), id_right),
1211            preceded(tag(">"), opt(cfws)),
1212        ),
1213    )
1214    .parse(input)?;
1215
1216    let mut result: BString = left.into();
1217    result.push_char('@');
1218    result.push_str(right);
1219
1220    Ok((loc, MessageID(result)))
1221}
1222
1223// obs_unstruct = { (( "\r"* ~ "\n"* ~ ((encoded_word | obs_utext)~ "\r"* ~ "\n"*)+) | fws)+ }
1224fn unstructured(input: Span) -> IResult<Span, BString> {
1225    #[derive(Debug)]
1226    enum Word {
1227        Encoded(BString),
1228        UText(BString),
1229        Fws,
1230    }
1231
1232    let (loc, words) = context(
1233        "unstructured",
1234        many0(alt((
1235            preceded(
1236                map(take_while(|c| c == b'\r' || c == b'\n'), |_| Word::Fws),
1237                terminated(
1238                    alt((
1239                        map(encoded_word, Word::Encoded),
1240                        map(obs_utext, |s| Word::UText((*s).into())),
1241                    )),
1242                    map(take_while(|c| c == b'\r' || c == b'\n'), |_| Word::Fws),
1243                ),
1244            ),
1245            map(fws, |_| Word::Fws),
1246        ))),
1247    )
1248    .parse(input)?;
1249
1250    #[derive(Debug)]
1251    enum ProcessedWord {
1252        Encoded(BString),
1253        Text(BString),
1254        Fws,
1255    }
1256    let mut processed = vec![];
1257    for w in words {
1258        match w {
1259            Word::Encoded(p) => {
1260                if processed.len() >= 2
1261                    && matches!(processed.last(), Some(ProcessedWord::Fws))
1262                    && matches!(processed[processed.len() - 2], ProcessedWord::Encoded(_))
1263                {
1264                    // Fws between encoded words is elided
1265                    processed.pop();
1266                }
1267                processed.push(ProcessedWord::Encoded(p));
1268            }
1269            Word::Fws => {
1270                // Collapse runs of Fws/newline to a single Fws
1271                if !matches!(processed.last(), Some(ProcessedWord::Fws)) {
1272                    processed.push(ProcessedWord::Fws);
1273                }
1274            }
1275            Word::UText(c) => match processed.last_mut() {
1276                Some(ProcessedWord::Text(prior)) => prior.push_str(c),
1277                _ => processed.push(ProcessedWord::Text(c)),
1278            },
1279        }
1280    }
1281
1282    let mut result = BString::default();
1283    for word in processed {
1284        match word {
1285            ProcessedWord::Encoded(s) | ProcessedWord::Text(s) => {
1286                result.push_str(&s);
1287            }
1288            ProcessedWord::Fws => {
1289                result.push(b' ');
1290            }
1291        }
1292    }
1293
1294    Ok((loc, result))
1295}
1296
1297fn arc_authentication_results(input: Span) -> IResult<Span, ARCAuthenticationResults> {
1298    context(
1299        "arc_authentication_results",
1300        map(
1301            (
1302                preceded(opt(cfws), tag("i")),
1303                preceded(opt(cfws), tag("=")),
1304                preceded(opt(cfws), nom::character::complete::u8),
1305                preceded(opt(cfws), tag(";")),
1306                preceded(opt(cfws), value),
1307                opt(preceded(cfws, nom::character::complete::u32)),
1308                alt((no_result, many1(resinfo))),
1309                opt(cfws),
1310            ),
1311            |(_i, _eq, instance, _semic, serv_id, version, results, _)| ARCAuthenticationResults {
1312                instance,
1313                serv_id: serv_id.into(),
1314                version,
1315                results,
1316            },
1317        ),
1318    )
1319    .parse(input)
1320}
1321
1322fn authentication_results(input: Span) -> IResult<Span, AuthenticationResults> {
1323    context(
1324        "authentication_results",
1325        map(
1326            (
1327                preceded(opt(cfws), value),
1328                opt(preceded(cfws, nom::character::complete::u32)),
1329                alt((no_result, many1(resinfo))),
1330                opt(cfws),
1331            ),
1332            |(serv_id, version, results, _)| AuthenticationResults {
1333                serv_id: serv_id.into(),
1334                version,
1335                results,
1336            },
1337        ),
1338    )
1339    .parse(input)
1340}
1341
1342fn no_result(input: Span) -> IResult<Span, Vec<AuthenticationResult>> {
1343    context(
1344        "no_result",
1345        map((opt(cfws), tag(";"), opt(cfws), tag("none")), |_| vec![]),
1346    )
1347    .parse(input)
1348}
1349
1350fn resinfo(input: Span) -> IResult<Span, AuthenticationResult> {
1351    context(
1352        "resinfo",
1353        map(
1354            (
1355                opt(cfws),
1356                tag(";"),
1357                methodspec,
1358                opt(preceded(cfws, reasonspec)),
1359                opt(many1(propspec)),
1360            ),
1361            |(_, _, (method, method_version, result), reason, props)| AuthenticationResult {
1362                method,
1363                method_version,
1364                result,
1365                reason: reason.map(Into::into),
1366                props: match props {
1367                    None => BTreeMap::default(),
1368                    Some(props) => props.into_iter().collect(),
1369                },
1370            },
1371        ),
1372    )
1373    .parse(input)
1374}
1375
1376fn methodspec(input: Span) -> IResult<Span, (String, Option<u32>, String)> {
1377    context(
1378        "methodspec",
1379        map(
1380            (
1381                opt(cfws),
1382                (keyword, opt(methodversion)),
1383                opt(cfws),
1384                tag("="),
1385                opt(cfws),
1386                keyword,
1387            ),
1388            |(_, (method, methodversion), _, _, _, result)| (method, methodversion, result),
1389        ),
1390    )
1391    .parse(input)
1392}
1393
1394// Taken from https://datatracker.ietf.org/doc/html/rfc8601 which says
1395// that this is the same as the SMTP Keyword token (RFC 5321 section 4.1.2).
1396// Keyword = Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig
1397// Only matches ASCII alphanumeric and '-'.
1398fn keyword(input: Span) -> IResult<Span, String> {
1399    context(
1400        "keyword",
1401        map(
1402            take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-'),
1403            // SAFETY: predicate only matches ASCII bytes
1404            |s: Span| String::from_utf8((*s).into()).expect("keyword is ASCII-only"),
1405        ),
1406    )
1407    .parse(input)
1408}
1409
1410fn methodversion(input: Span) -> IResult<Span, u32> {
1411    context(
1412        "methodversion",
1413        preceded(
1414            (opt(cfws), tag("/"), opt(cfws)),
1415            nom::character::complete::u32,
1416        ),
1417    )
1418    .parse(input)
1419}
1420
1421fn reasonspec(input: Span) -> IResult<Span, BString> {
1422    context(
1423        "reason",
1424        map(
1425            (tag("reason"), opt(cfws), tag("="), opt(cfws), value),
1426            |(_, _, _, _, value)| value,
1427        ),
1428    )
1429    .parse(input)
1430}
1431
1432fn propspec(input: Span) -> IResult<Span, (String, BString)> {
1433    context(
1434        "propspec",
1435        map(
1436            (
1437                // RFC 8601 resinfo ABNF says CFWS is required before each
1438                // propspec, but we use opt(cfws) here because other parsers
1439                // (notably quoted_string) may have already consumed the
1440                // whitespace.
1441                opt(cfws),
1442                keyword,
1443                opt(cfws),
1444                tag("."),
1445                opt(cfws),
1446                keyword,
1447                opt(cfws),
1448                tag("="),
1449                opt(cfws),
1450                // pvalue = [CFWS] ( value / [ [CFWS] "@" ] domain ) [CFWS]
1451                // Try @domain and local@domain first (distinctive @ marker),
1452                // then quoted_string (distinctive " marker), then domain
1453                // (handles dotted names), then mime_token last (single tokens).
1454                alt((
1455                    map(preceded(tag("@"), domain), |d| {
1456                        let mut at_dom = BString::from("@");
1457                        at_dom.push_str(d);
1458                        at_dom
1459                    }),
1460                    map(separated_pair(local_part, tag("@"), domain), |(u, d)| {
1461                        let mut result: BString = u.into();
1462                        result.push(b'@');
1463                        result.push_str(d);
1464                        result
1465                    }),
1466                    quoted_string,
1467                    domain,
1468                    map(mime_token, |s: Span| (*s).into()),
1469                )),
1470                opt(cfws),
1471            ),
1472            |(_, ptype, _, _, _, property, _, _, _, value, _)| {
1473                (format!("{ptype}.{property}"), value)
1474            },
1475        ),
1476    )
1477    .parse(input)
1478}
1479
1480// obs_utext = @{ "\u{00}" | obs_no_ws_ctl | vchar }
1481fn obs_utext(input: Span) -> IResult<Span, Span> {
1482    context(
1483        "obs_utext",
1484        alt((
1485            take_while_m_n(1, 1, |c| {
1486                c == 0x00 || is_obs_no_ws_ctl(c) || is_vchar_ascii(c)
1487            }),
1488            utf8_non_ascii,
1489        )),
1490    )
1491    .parse(input)
1492}
1493
1494fn is_mime_token(c: u8) -> bool {
1495    is_char(c) && c != b' ' && !is_ctl(c) && !is_tspecial(c)
1496}
1497
1498// mime_token = { (!(" " | ctl | tspecials) ~ char)+ }
1499// Also accepts validated UTF-8 multi-byte sequences per RFC 6532.
1500fn mime_token(input: Span) -> IResult<Span, Span> {
1501    context(
1502        "mime_token",
1503        recognize(many1(alt((take_while1(is_mime_token), utf8_non_ascii)))),
1504    )
1505    .parse(input)
1506}
1507
1508// RFC2045 modified by RFC2231 MIME header fields
1509// content_type = { cfws? ~ mime_type ~ cfws? ~ "/" ~ cfws? ~ subtype ~
1510//  cfws? ~ (";"? ~ cfws? ~ parameter ~ cfws?)*
1511// }
1512fn content_type(input: Span) -> IResult<Span, MimeParameters> {
1513    let (loc, (mime_type, _, _, _, mime_subtype, _, parameters)) = context(
1514        "content_type",
1515        preceded(
1516            opt(cfws),
1517            (
1518                mime_token,
1519                opt(cfws),
1520                tag("/"),
1521                opt(cfws),
1522                mime_token,
1523                opt(cfws),
1524                many0(preceded(
1525                    // Note that RFC 2231 is a bit of a mess, showing examples
1526                    // without `;` as a separator in the original text, but
1527                    // in the errata from several years later, corrects those
1528                    // to show the `;`.
1529                    // In the meantime, there are implementations that assume
1530                    // that the `;` is optional, so we therefore allow them
1531                    // to be optional here in our implementation
1532                    preceded(opt(tag(";")), opt(cfws)),
1533                    terminated(parameter, opt(cfws)),
1534                )),
1535            ),
1536        ),
1537    )
1538    .parse(input)?;
1539
1540    let mut value: BString = (*mime_type).into();
1541    value.push_char('/');
1542    value.push_str(mime_subtype);
1543
1544    Ok((loc, MimeParameters { value, parameters }))
1545}
1546
1547fn content_transfer_encoding(input: Span) -> IResult<Span, MimeParameters> {
1548    let (loc, (value, _, parameters)) = context(
1549        "content_transfer_encoding",
1550        preceded(
1551            opt(cfws),
1552            (
1553                mime_token,
1554                opt(cfws),
1555                many0(preceded(
1556                    // Note that RFC 2231 is a bit of a mess, showing examples
1557                    // without `;` as a separator in the original text, but
1558                    // in the errata from several years later, corrects those
1559                    // to show the `;`.
1560                    // In the meantime, there are implementations that assume
1561                    // that the `;` is optional, so we therefore allow them
1562                    // to be optional here in our implementation
1563                    preceded(opt(tag(";")), opt(cfws)),
1564                    terminated(parameter, opt(cfws)),
1565                )),
1566            ),
1567        ),
1568    )
1569    .parse(input)?;
1570
1571    Ok((
1572        loc,
1573        MimeParameters {
1574            value: value.as_bytes().into(),
1575            parameters,
1576        },
1577    ))
1578}
1579
1580// parameter = { regular_parameter | extended_parameter }
1581fn parameter(input: Span) -> IResult<Span, MimeParameter> {
1582    context(
1583        "parameter",
1584        alt((
1585            // Note that RFC2047 explicitly prohibits both of
1586            // these 2047 cases from appearing here, but that
1587            // major MUAs produce this sort of prohibited content
1588            // and we thus need to accommodate it
1589            param_with_unquoted_rfc2047,
1590            param_with_quoted_rfc2047,
1591            regular_parameter,
1592            extended_param_with_charset,
1593            extended_param_no_charset,
1594        )),
1595    )
1596    .parse(input)
1597}
1598
1599fn param_with_unquoted_rfc2047(input: Span) -> IResult<Span, MimeParameter> {
1600    context(
1601        "param_with_unquoted_rfc2047",
1602        map(
1603            (attribute, opt(cfws), tag("="), opt(cfws), encoded_word),
1604            |(name, _, _, _, value)| MimeParameter {
1605                name: name.as_bytes().into(),
1606                value: value.as_bytes().into(),
1607                section: None,
1608                encoding: MimeParameterEncoding::UnquotedRfc2047,
1609                mime_charset: None,
1610                mime_language: None,
1611            },
1612        ),
1613    )
1614    .parse(input)
1615}
1616
1617fn param_with_quoted_rfc2047(input: Span) -> IResult<Span, MimeParameter> {
1618    context(
1619        "param_with_quoted_rfc2047",
1620        map(
1621            (
1622                attribute,
1623                opt(cfws),
1624                tag("="),
1625                opt(cfws),
1626                delimited(tag("\""), encoded_word, tag("\"")),
1627            ),
1628            |(name, _, _, _, value)| MimeParameter {
1629                name: name.as_bytes().into(),
1630                value: value.as_bytes().into(),
1631                section: None,
1632                encoding: MimeParameterEncoding::QuotedRfc2047,
1633                mime_charset: None,
1634                mime_language: None,
1635            },
1636        ),
1637    )
1638    .parse(input)
1639}
1640
1641fn extended_param_with_charset(input: Span) -> IResult<Span, MimeParameter> {
1642    context(
1643        "extended_param_with_charset",
1644        map(
1645            (
1646                attribute,
1647                opt(section),
1648                tag("*"),
1649                opt(cfws),
1650                tag("="),
1651                opt(cfws),
1652                opt(mime_charset),
1653                tag("'"),
1654                opt(mime_language),
1655                tag("'"),
1656                map(
1657                    recognize(many0(alt((ext_octet, take_while1(is_attribute_char))))),
1658                    |s: Span| (*s).into(),
1659                ),
1660            ),
1661            |(name, section, _, _, _, _, mime_charset, _, mime_language, _, value)| MimeParameter {
1662                name: name.as_bytes().into(),
1663                section,
1664                mime_charset: mime_charset.map(|s| s.as_bytes().into()),
1665                mime_language: mime_language.map(|s| s.as_bytes().into()),
1666                encoding: MimeParameterEncoding::Rfc2231,
1667                value,
1668            },
1669        ),
1670    )
1671    .parse(input)
1672}
1673
1674fn extended_param_no_charset(input: Span) -> IResult<Span, MimeParameter> {
1675    context(
1676        "extended_param_no_charset",
1677        map(
1678            (
1679                attribute,
1680                opt(section),
1681                opt(tag("*")),
1682                opt(cfws),
1683                tag("="),
1684                opt(cfws),
1685                alt((
1686                    quoted_string,
1687                    map(
1688                        recognize(many0(alt((ext_octet, take_while1(is_attribute_char))))),
1689                        |s: Span| (*s).into(),
1690                    ),
1691                )),
1692            ),
1693            |(name, section, star, _, _, _, value)| MimeParameter {
1694                name: name.as_bytes().into(),
1695                section,
1696                mime_charset: None,
1697                mime_language: None,
1698                encoding: if star.is_some() {
1699                    MimeParameterEncoding::Rfc2231
1700                } else {
1701                    MimeParameterEncoding::None
1702                },
1703                value,
1704            },
1705        ),
1706    )
1707    .parse(input)
1708}
1709
1710fn mime_charset(input: Span) -> IResult<Span, Span> {
1711    context(
1712        "mime_charset",
1713        take_while1(|c| is_mime_token(c) && c != b'\''),
1714    )
1715    .parse(input)
1716}
1717
1718fn mime_language(input: Span) -> IResult<Span, Span> {
1719    context(
1720        "mime_language",
1721        take_while1(|c| is_mime_token(c) && c != b'\''),
1722    )
1723    .parse(input)
1724}
1725
1726fn ext_octet(input: Span) -> IResult<Span, Span> {
1727    context(
1728        "ext_octet",
1729        recognize((
1730            tag("%"),
1731            take_while_m_n(2, 2, |b: u8| b.is_ascii_hexdigit()),
1732        )),
1733    )
1734    .parse(input)
1735}
1736
1737// section = { "*" ~ ASCII_DIGIT+ }
1738fn section(input: Span) -> IResult<Span, u32> {
1739    context("section", preceded(tag("*"), nom::character::complete::u32)).parse(input)
1740}
1741
1742// regular_parameter = { attribute ~ cfws? ~ "=" ~ cfws? ~ value }
1743fn regular_parameter(input: Span) -> IResult<Span, MimeParameter> {
1744    context(
1745        "regular_parameter",
1746        map(
1747            (attribute, opt(cfws), tag("="), opt(cfws), value),
1748            |(name, _, _, _, value)| MimeParameter {
1749                name: name.as_bytes().into(),
1750                value: value.as_bytes().into(),
1751                section: None,
1752                encoding: MimeParameterEncoding::None,
1753                mime_charset: None,
1754                mime_language: None,
1755            },
1756        ),
1757    )
1758    .parse(input)
1759}
1760
1761// attribute = { attribute_char+ }
1762// attribute_char = { !(" " | ctl | tspecials | "*" | "'" | "%") ~ char }
1763fn attribute(input: Span) -> IResult<Span, Span> {
1764    context("attribute", take_while1(is_attribute_char)).parse(input)
1765}
1766
1767fn value(input: Span) -> IResult<Span, BString> {
1768    context(
1769        "value",
1770        alt((map(mime_token, |s: Span| (*s).into()), quoted_string)),
1771    )
1772    .parse(input)
1773}
1774
1775pub struct Parser;
1776
1777impl Parser {
1778    pub fn parse_mailbox_list_header(text: &[u8]) -> Result<MailboxList> {
1779        parse_with(text, mailbox_list)
1780    }
1781
1782    pub fn parse_mailbox_header(text: &[u8]) -> Result<Mailbox> {
1783        parse_with(text, mailbox)
1784    }
1785
1786    pub fn parse_address_list_header(text: &[u8]) -> Result<AddressList> {
1787        parse_with(text, address_list)
1788    }
1789
1790    pub fn parse_msg_id_header(text: &[u8]) -> Result<MessageID> {
1791        parse_with(text, msg_id)
1792    }
1793
1794    pub fn parse_msg_id_header_list(text: &[u8]) -> Result<Vec<MessageID>> {
1795        parse_with(text, msg_id_list)
1796    }
1797
1798    pub fn parse_content_id_header(text: &[u8]) -> Result<MessageID> {
1799        parse_with(text, content_id)
1800    }
1801
1802    pub fn parse_content_type_header(text: &[u8]) -> Result<MimeParameters> {
1803        parse_with(text, content_type)
1804    }
1805
1806    pub fn parse_content_transfer_encoding_header(text: &[u8]) -> Result<MimeParameters> {
1807        parse_with(text, content_transfer_encoding)
1808    }
1809
1810    pub fn parse_unstructured_header(text: &[u8]) -> Result<BString> {
1811        parse_with(text, unstructured)
1812    }
1813
1814    pub fn parse_authentication_results_header(text: &[u8]) -> Result<AuthenticationResults> {
1815        parse_with(text, authentication_results)
1816    }
1817
1818    pub fn parse_arc_authentication_results_header(
1819        text: &[u8],
1820    ) -> Result<ARCAuthenticationResults> {
1821        parse_with(text, arc_authentication_results)
1822    }
1823}
1824
1825#[serde_as]
1826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1827#[serde(deny_unknown_fields)]
1828pub struct ARCAuthenticationResults {
1829    pub instance: u8,
1830    #[serde_as(as = "BStringUtf8")]
1831    pub serv_id: BString,
1832    pub version: Option<u32>,
1833    pub results: Vec<AuthenticationResult>,
1834}
1835
1836impl EncodeHeaderValue for ARCAuthenticationResults {
1837    fn encode_value(&self) -> SharedString<'static> {
1838        let mut result = format!("i={}; ", self.instance).into_bytes();
1839
1840        emit_value_token(&self.serv_id, &mut result);
1841        if let Some(v) = self.version {
1842            result.push_str(&format!(" {v}"));
1843        }
1844
1845        if self.results.is_empty() {
1846            result.push_str("; none");
1847        } else {
1848            for res in &self.results {
1849                result.push_str(";\r\n\t");
1850                emit_value_token(res.method.as_bytes(), &mut result);
1851                if let Some(v) = res.method_version {
1852                    result.push_str(&format!("/{v}"));
1853                }
1854                result.push(b'=');
1855                emit_value_token(res.result.as_bytes(), &mut result);
1856                if let Some(reason) = &res.reason {
1857                    result.push_str(" reason=");
1858                    emit_value_token(reason.as_bytes(), &mut result);
1859                }
1860                for (k, v) in &res.props {
1861                    result.push_str(&format!("\r\n\t{k}="));
1862                    emit_value_token(v.as_bytes(), &mut result);
1863                }
1864            }
1865        }
1866
1867        result.into()
1868    }
1869}
1870
1871#[serde_as]
1872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1873#[serde(deny_unknown_fields)]
1874pub struct AuthenticationResults {
1875    #[serde_as(as = "BStringUtf8")]
1876    pub serv_id: BString,
1877    #[serde(default)]
1878    pub version: Option<u32>,
1879    #[serde(default)]
1880    pub results: Vec<AuthenticationResult>,
1881}
1882
1883/// Emits a value that was parsed by `value`, into target
1884fn emit_value_token(value: &[u8], target: &mut Vec<u8>) {
1885    // Allow '@' bare since the pvalue parser handles @domain and local@domain
1886    let use_quoted_string = !value.iter().all(|&c| is_mime_token(c) || c == b'@');
1887    if use_quoted_string {
1888        target.push(b'"');
1889        for (start, end, c) in value.char_indices() {
1890            if c == '"' || c == '\\' {
1891                target.push(b'\\');
1892            }
1893            target.push_str(&value[start..end]);
1894        }
1895        target.push(b'"');
1896    } else {
1897        target.push_str(value);
1898    }
1899}
1900
1901impl EncodeHeaderValue for AuthenticationResults {
1902    fn encode_value(&self) -> SharedString<'static> {
1903        let mut result = Vec::new();
1904        emit_value_token(&self.serv_id, &mut result);
1905        if let Some(v) = self.version {
1906            result.push_str(&format!(" {v}"));
1907        }
1908        if self.results.is_empty() {
1909            result.push_str("; none");
1910        } else {
1911            for res in &self.results {
1912                result.push_str(";\r\n\t");
1913                emit_value_token(res.method.as_bytes(), &mut result);
1914                if let Some(v) = res.method_version {
1915                    result.push_str(&format!("/{v}"));
1916                }
1917                result.push(b'=');
1918                emit_value_token(res.result.as_bytes(), &mut result);
1919                if let Some(reason) = &res.reason {
1920                    result.push_str(" reason=");
1921                    emit_value_token(reason.as_bytes(), &mut result);
1922                }
1923                for (k, v) in &res.props {
1924                    result.push_str(&format!("\r\n\t{k}="));
1925                    emit_value_token(v.as_bytes(), &mut result);
1926                }
1927            }
1928        }
1929
1930        result.into()
1931    }
1932}
1933
1934#[serde_as]
1935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1936#[serde(deny_unknown_fields)]
1937pub struct AuthenticationResult {
1938    pub method: String,
1939    #[serde(default)]
1940    pub method_version: Option<u32>,
1941    pub result: String,
1942    #[serde_as(as = "Option<BStringUtf8>")]
1943    #[serde(default)]
1944    pub reason: Option<BString>,
1945    #[serde_as(as = "BTreeMap<_, BStringUtf8>")]
1946    #[serde(default)]
1947    pub props: BTreeMap<String, BString>,
1948}
1949
1950#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1951#[serde(deny_unknown_fields)]
1952pub struct AddrSpec {
1953    pub local_part: String,
1954    pub domain: String,
1955}
1956
1957impl AddrSpec {
1958    pub fn new(local_part: &str, domain: &str) -> Self {
1959        Self {
1960            local_part: local_part.into(),
1961            domain: domain.into(),
1962        }
1963    }
1964
1965    pub fn parse(email: &str) -> Result<Self> {
1966        parse_with(email.as_bytes(), addr_spec)
1967    }
1968}
1969
1970impl EncodeHeaderValue for AddrSpec {
1971    fn encode_value(&self) -> SharedString<'static> {
1972        let mut result: Vec<u8> = vec![];
1973
1974        let needs_quoting = !self
1975            .local_part
1976            .as_bytes()
1977            .iter()
1978            .all(|&c| is_atext(c) || c == b'.');
1979        if needs_quoting {
1980            result.push(b'"');
1981            // RFC5321 4.1.2 qtextSMTP:
1982            // within a quoted string, any ASCII graphic or space is permitted without
1983            // blackslash-quoting except double-quote and the backslash itself.
1984
1985            for &c in self.local_part.as_bytes().iter() {
1986                if c == b'"' || c == b'\\' {
1987                    result.push(b'\\');
1988                }
1989                result.push(c);
1990            }
1991            result.push(b'"');
1992        } else {
1993            result.push_str(&self.local_part);
1994        }
1995        result.push(b'@');
1996        result.push_str(&self.domain);
1997
1998        result.into()
1999    }
2000}
2001
2002#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2003#[serde(untagged)]
2004pub enum Address {
2005    Mailbox(Mailbox),
2006    Group { name: String, entries: MailboxList },
2007}
2008
2009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2010#[serde(deny_unknown_fields, transparent)]
2011pub struct AddressList(pub Vec<Address>);
2012
2013impl std::ops::Deref for AddressList {
2014    type Target = Vec<Address>;
2015    fn deref(&self) -> &Vec<Address> {
2016        &self.0
2017    }
2018}
2019
2020impl AddressList {
2021    pub fn extract_first_mailbox(&self) -> Option<&Mailbox> {
2022        let address = self.0.first()?;
2023        match address {
2024            Address::Mailbox(mailbox) => Some(mailbox),
2025            Address::Group { entries, .. } => entries.extract_first_mailbox(),
2026        }
2027    }
2028}
2029
2030#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2031#[serde(deny_unknown_fields, transparent)]
2032pub struct MailboxList(pub Vec<Mailbox>);
2033
2034impl std::ops::Deref for MailboxList {
2035    type Target = Vec<Mailbox>;
2036    fn deref(&self) -> &Vec<Mailbox> {
2037        &self.0
2038    }
2039}
2040
2041impl MailboxList {
2042    pub fn extract_first_mailbox(&self) -> Option<&Mailbox> {
2043        self.0.first()
2044    }
2045}
2046
2047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2048#[serde(deny_unknown_fields)]
2049pub struct Mailbox {
2050    pub name: Option<String>,
2051    pub address: AddrSpec,
2052}
2053
2054#[serde_as]
2055#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2056#[serde(transparent)]
2057pub struct MessageID(#[serde_as(as = "BStringUtf8")] pub BString);
2058
2059impl EncodeHeaderValue for MessageID {
2060    fn encode_value(&self) -> SharedString<'static> {
2061        let mut result = Vec::<u8>::with_capacity(self.0.len() + 2);
2062        result.push(b'<');
2063        result.push_str(&self.0);
2064        result.push(b'>');
2065        result.into()
2066    }
2067}
2068
2069impl EncodeHeaderValue for Vec<MessageID> {
2070    fn encode_value(&self) -> SharedString<'static> {
2071        let mut result = BString::default();
2072        for id in self {
2073            if !result.is_empty() {
2074                result.push_str("\r\n\t");
2075            }
2076            result.push(b'<');
2077            result.push_str(&id.0);
2078            result.push(b'>');
2079        }
2080        result.into()
2081    }
2082}
2083
2084// In theory, everyone would be aware of RFC 2231 and we can stop here,
2085// but in practice, things are messy.  At some point someone started
2086// to emit encoded-words insides quoted-string values, and for the sake
2087// of compatibility what we see now is technically illegal stuff like
2088// Content-Disposition: attachment; filename="=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?="
2089// being used to represent UTF-8 filenames.
2090// As such, in our RFC 2231 handling, we also need to accommodate
2091// these bogus representations, hence their presence in this enum
2092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2093pub(crate) enum MimeParameterEncoding {
2094    None,
2095    Rfc2231,
2096    UnquotedRfc2047,
2097    QuotedRfc2047,
2098}
2099
2100#[derive(Debug, Clone, PartialEq, Eq)]
2101struct MimeParameter {
2102    pub name: BString,
2103    pub section: Option<u32>,
2104    pub mime_charset: Option<BString>,
2105    pub mime_language: Option<BString>,
2106    pub encoding: MimeParameterEncoding,
2107    pub value: BString,
2108}
2109
2110#[derive(Debug, Clone, PartialEq, Eq)]
2111pub struct MimeParameters {
2112    pub value: BString,
2113    parameters: Vec<MimeParameter>,
2114}
2115
2116impl MimeParameters {
2117    pub fn new(value: impl AsRef<[u8]>) -> Self {
2118        Self {
2119            value: value.as_ref().into(),
2120            parameters: vec![],
2121        }
2122    }
2123
2124    /// Decode all named parameters per RFC 2231 and return a map
2125    /// of the parameter names to parameters values.
2126    /// Incorrectly encoded parameters are silently ignored
2127    /// and are not returned in the resulting map.
2128    pub fn parameter_map(&self) -> BTreeMap<BString, BString> {
2129        let mut map = BTreeMap::new();
2130
2131        fn contains_key_ignore_case(map: &BTreeMap<BString, BString>, key: &[u8]) -> bool {
2132            for k in map.keys() {
2133                if k.eq_ignore_ascii_case(key) {
2134                    return true;
2135                }
2136            }
2137            false
2138        }
2139
2140        for entry in &self.parameters {
2141            let name = entry.name.as_bytes();
2142            if !contains_key_ignore_case(&map, name) {
2143                if let Some(value) = self.get(name) {
2144                    map.insert(name.into(), value);
2145                }
2146            }
2147        }
2148
2149        map
2150    }
2151
2152    /// Retrieve the value for a named parameter.
2153    /// This method will attempt to decode any %-encoded values
2154    /// per RFC 2231 and combine multi-element fields into a single
2155    /// contiguous value.
2156    /// Invalid charsets and encoding will be silently ignored.
2157    pub fn get(&self, name: impl AsRef<[u8]>) -> Option<BString> {
2158        let name = name.as_ref();
2159        let mut elements: Vec<_> = self
2160            .parameters
2161            .iter()
2162            .filter(|p| p.name.eq_ignore_ascii_case(name.as_bytes()))
2163            .collect();
2164        if elements.is_empty() {
2165            return None;
2166        }
2167        elements.sort_by(|a, b| a.section.cmp(&b.section));
2168
2169        let mut mime_charset = None;
2170        let mut result: Vec<u8> = vec![];
2171
2172        for ele in elements {
2173            if let Some(cset) = ele.mime_charset.as_ref().and_then(|b| b.to_str().ok()) {
2174                mime_charset = Encoding::by_name(&*cset);
2175            }
2176
2177            match ele.encoding {
2178                MimeParameterEncoding::Rfc2231 => {
2179                    if let Some(charset) = mime_charset.as_ref() {
2180                        let mut chars = ele.value.chars();
2181                        let mut bytes: Vec<u8> = vec![];
2182
2183                        fn char_to_bytes(c: char, bytes: &mut Vec<u8>) {
2184                            let mut buf = [0u8; 8];
2185                            let s = c.encode_utf8(&mut buf);
2186                            for b in s.bytes() {
2187                                bytes.push(b);
2188                            }
2189                        }
2190
2191                        'next_char: while let Some(c) = chars.next() {
2192                            match c {
2193                                '%' => {
2194                                    let mut value = 0u8;
2195                                    for _ in 0..2 {
2196                                        match chars.next() {
2197                                            Some(n) => match n {
2198                                                '0'..='9' => {
2199                                                    value <<= 4;
2200                                                    value |= n as u32 as u8 - b'0';
2201                                                }
2202                                                'a'..='f' => {
2203                                                    value <<= 4;
2204                                                    value |= (n as u32 as u8 - b'a') + 10;
2205                                                }
2206                                                'A'..='F' => {
2207                                                    value <<= 4;
2208                                                    value |= (n as u32 as u8 - b'A') + 10;
2209                                                }
2210                                                _ => {
2211                                                    char_to_bytes('%', &mut bytes);
2212                                                    char_to_bytes(n, &mut bytes);
2213                                                    break 'next_char;
2214                                                }
2215                                            },
2216                                            None => {
2217                                                char_to_bytes('%', &mut bytes);
2218                                                break 'next_char;
2219                                            }
2220                                        }
2221                                    }
2222
2223                                    bytes.push(value);
2224                                }
2225                                c => {
2226                                    char_to_bytes(c, &mut bytes);
2227                                }
2228                            }
2229                        }
2230
2231                        if let Ok(decoded) = charset.decode_simple(&bytes) {
2232                            result.push_str(&decoded);
2233                        }
2234                    } else {
2235                        result.push_str(&ele.value);
2236                    }
2237                }
2238                MimeParameterEncoding::UnquotedRfc2047
2239                | MimeParameterEncoding::QuotedRfc2047
2240                | MimeParameterEncoding::None => {
2241                    result.push_str(&ele.value);
2242                }
2243            }
2244        }
2245
2246        Some(result.into())
2247    }
2248
2249    /// Remove the named parameter
2250    pub fn remove(&mut self, name: impl AsRef<[u8]>) {
2251        let name = name.as_ref();
2252        self.parameters
2253            .retain(|p| !p.name.eq_ignore_ascii_case(name));
2254    }
2255
2256    pub fn set(&mut self, name: impl AsRef<[u8]>, value: impl AsRef<[u8]>) {
2257        self.set_with_encoding(name, value, MimeParameterEncoding::None)
2258    }
2259
2260    pub(crate) fn set_with_encoding(
2261        &mut self,
2262        name: impl AsRef<[u8]>,
2263        value: impl AsRef<[u8]>,
2264        encoding: MimeParameterEncoding,
2265    ) {
2266        self.remove(name.as_ref());
2267
2268        self.parameters.push(MimeParameter {
2269            name: name.as_ref().into(),
2270            value: value.as_ref().into(),
2271            section: None,
2272            mime_charset: None,
2273            mime_language: None,
2274            encoding,
2275        });
2276    }
2277
2278    pub fn is_multipart(&self) -> bool {
2279        self.value.starts_with_str("message/") || self.value.starts_with_str("multipart/")
2280    }
2281
2282    pub fn is_text(&self) -> bool {
2283        self.value.starts_with_str("text/")
2284    }
2285}
2286
2287impl EncodeHeaderValue for MimeParameters {
2288    fn encode_value(&self) -> SharedString<'static> {
2289        let mut result = self.value.clone();
2290        let names: BTreeMap<&BStr, MimeParameterEncoding> = self
2291            .parameters
2292            .iter()
2293            .map(|p| (p.name.as_bstr(), p.encoding))
2294            .collect();
2295
2296        for (name, stated_encoding) in names {
2297            let value = self.get(name).expect("name to be present");
2298
2299            match stated_encoding {
2300                MimeParameterEncoding::UnquotedRfc2047 => {
2301                    let encoded = qp_encode(&value);
2302                    result.push_str(&format!(";\r\n\t{name}={encoded}"));
2303                }
2304                MimeParameterEncoding::QuotedRfc2047 => {
2305                    let encoded = qp_encode(&value);
2306                    result.push_str(&format!(";\r\n\t{name}=\"{encoded}\""));
2307                }
2308                MimeParameterEncoding::None | MimeParameterEncoding::Rfc2231 => {
2309                    let needs_encoding = value.iter().any(|&c| !is_mime_token(c) || !c.is_ascii());
2310                    // Prefer to use quoted_string representation when possible, as it doesn't
2311                    // require any RFC 2231 encoding
2312                    let use_quoted_string = value
2313                        .iter()
2314                        .all(|&c| (is_qtext(c) || is_quoted_pair(c)) && c.is_ascii());
2315
2316                    let mut params = vec![];
2317                    let mut chars = value.char_indices().peekable();
2318                    while chars.peek().is_some() {
2319                        let count = params.len();
2320                        let is_first = count == 0;
2321                        let prefix = if use_quoted_string {
2322                            "\""
2323                        } else if is_first && needs_encoding {
2324                            "UTF-8''"
2325                        } else {
2326                            ""
2327                        };
2328                        let limit = 74 - (name.len() + 4 + prefix.len());
2329
2330                        let mut encoded: Vec<u8> = vec![];
2331
2332                        while encoded.len() < limit {
2333                            let Some((start, end, c)) = chars.next() else {
2334                                break;
2335                            };
2336                            let s = &value[start..end];
2337
2338                            if use_quoted_string {
2339                                if c == '"' || c == '\\' {
2340                                    encoded.push(b'\\');
2341                                }
2342                                encoded.push_str(s);
2343                            } else if (c as u32) <= 0xff
2344                                && is_mime_token(c as u32 as u8)
2345                                && (!needs_encoding || c != '%')
2346                            {
2347                                encoded.push_str(s);
2348                            } else {
2349                                for b in s.bytes() {
2350                                    encoded.push(b'%');
2351                                    encoded.push(HEX_CHARS[(b as usize) >> 4]);
2352                                    encoded.push(HEX_CHARS[(b as usize) & 0x0f]);
2353                                }
2354                            }
2355                        }
2356
2357                        if use_quoted_string {
2358                            encoded.push(b'"');
2359                        }
2360
2361                        params.push(MimeParameter {
2362                            name: name.into(),
2363                            section: Some(count as u32),
2364                            mime_charset: if is_first { Some("UTF-8".into()) } else { None },
2365                            mime_language: None,
2366                            encoding: if needs_encoding {
2367                                MimeParameterEncoding::Rfc2231
2368                            } else {
2369                                MimeParameterEncoding::None
2370                            },
2371                            value: encoded.into(),
2372                        })
2373                    }
2374                    if params.len() == 1 {
2375                        params.last_mut().map(|p| p.section = None);
2376                    }
2377                    for p in params {
2378                        result.push_str(";\r\n\t");
2379                        let charset_tick = if !use_quoted_string
2380                            && (p.mime_charset.is_some() || p.mime_language.is_some())
2381                        {
2382                            "'"
2383                        } else {
2384                            ""
2385                        };
2386                        let lang_tick = if !use_quoted_string
2387                            && (p.mime_language.is_some() || p.mime_charset.is_some())
2388                        {
2389                            "'"
2390                        } else {
2391                            ""
2392                        };
2393
2394                        let section = p
2395                            .section
2396                            .map(|s| format!("*{s}"))
2397                            .unwrap_or_else(String::new);
2398
2399                        let uses_encoding =
2400                            if !use_quoted_string && p.encoding == MimeParameterEncoding::Rfc2231 {
2401                                "*"
2402                            } else {
2403                                ""
2404                            };
2405                        let charset = if use_quoted_string {
2406                            BStr::new("\"")
2407                        } else {
2408                            p.mime_charset
2409                                .as_ref()
2410                                .map(|b| b.as_bstr())
2411                                .unwrap_or(BStr::new(""))
2412                        };
2413                        let lang = p
2414                            .mime_language
2415                            .as_ref()
2416                            .map(|b| b.as_bstr())
2417                            .unwrap_or(BStr::new(""));
2418
2419                        let line = format!(
2420                            "{name}{section}{uses_encoding}={charset}{charset_tick}{lang}{lang_tick}{value}",
2421                            name = &p.name,
2422                            value = &p.value
2423                        );
2424                        result.push_str(&line);
2425                    }
2426                }
2427            }
2428        }
2429        result.into()
2430    }
2431}
2432
2433static HEX_CHARS: &[u8] = b"0123456789ABCDEF";
2434
2435pub(crate) fn qp_encode(s: &[u8]) -> String {
2436    let prefix = b"=?UTF-8?q?";
2437    let suffix = b"?=";
2438    let limit = 72 - (prefix.len() + suffix.len());
2439
2440    let mut result = Vec::with_capacity(s.len());
2441
2442    result.extend_from_slice(prefix);
2443    let mut line_length = 0;
2444
2445    enum Bytes<'a> {
2446        Passthru(&'a [u8]),
2447        Encode(&'a [u8]),
2448    }
2449
2450    // Iterate by char so that we don't confuse space (0x20) with a
2451    // utf8 subsequence and incorrectly encode the input string.
2452    for (start, end, c) in s.char_indices() {
2453        let bytes = &s[start..end];
2454
2455        let b = if (c.is_ascii_alphanumeric() || c.is_ascii_punctuation())
2456            && c != '?'
2457            && c != '='
2458            && c != ' '
2459            && c != '\t'
2460        {
2461            Bytes::Passthru(bytes)
2462        } else if c == ' ' {
2463            Bytes::Passthru(b"_")
2464        } else {
2465            Bytes::Encode(bytes)
2466        };
2467
2468        let need_len = match b {
2469            Bytes::Passthru(b) => b.len(),
2470            Bytes::Encode(b) => b.len() * 3,
2471        };
2472
2473        if need_len > limit - line_length {
2474            // Need to wrap
2475            result.extend_from_slice(suffix);
2476            result.extend_from_slice(b"\r\n\t");
2477            result.extend_from_slice(prefix);
2478            line_length = 0;
2479        }
2480
2481        match b {
2482            Bytes::Passthru(c) => {
2483                result.extend_from_slice(c);
2484            }
2485            Bytes::Encode(bytes) => {
2486                for &c in bytes {
2487                    result.push(b'=');
2488                    result.push(HEX_CHARS[(c as usize) >> 4]);
2489                    result.push(HEX_CHARS[(c as usize) & 0x0f]);
2490                }
2491            }
2492        }
2493
2494        line_length += need_len;
2495    }
2496
2497    if line_length > 0 {
2498        result.extend_from_slice(suffix);
2499    }
2500
2501    // Safety: we ensured that everything we output is in the ASCII
2502    // range, therefore the string is valid UTF-8
2503    unsafe { String::from_utf8_unchecked(result) }
2504}
2505
2506#[cfg(test)]
2507#[test]
2508fn test_qp_encode() {
2509    let encoded = qp_encode(
2510        b"hello, I am a line that is this long, or maybe a little \
2511        bit longer than this, and that should get wrapped by the encoder",
2512    );
2513    k9::snapshot!(
2514        encoded,
2515        r#"
2516=?UTF-8?q?hello,_I_am_a_line_that_is_this_long,_or_maybe_a_little_bit_?=\r
2517\t=?UTF-8?q?longer_than_this,_and_that_should_get_wrapped_by_the_encoder?=
2518"#
2519    );
2520}
2521
2522/// Quote input string `s`, using a backslash escape, if any
2523/// of the characters is NOT atext.  When quoting, the input
2524/// string is enclosed in quotes.
2525fn quote_string(s: impl AsRef<[u8]>) -> BString {
2526    let s = s.as_ref();
2527
2528    if s.iter().any(|&c| !is_atext(c)) {
2529        let mut result = Vec::<u8>::with_capacity(s.len() + 4);
2530        result.push(b'"');
2531        for (start, end, c) in s.char_indices() {
2532            let c = c as u32;
2533            if c <= 0xff {
2534                let c = c as u8;
2535                if !c.is_ascii_whitespace() && !is_qtext(c) && !is_atext(c) {
2536                    result.push(b'\\');
2537                }
2538            }
2539            result.push_str(&s[start..end]);
2540        }
2541        result.push(b'"');
2542        result.into()
2543    } else {
2544        s.into()
2545    }
2546}
2547
2548#[cfg(test)]
2549#[test]
2550fn test_quote_string() {
2551    k9::snapshot!(
2552        quote_string("TEST [ne_pas_repondre]"),
2553        r#""TEST [ne_pas_repondre]""#
2554    );
2555    k9::snapshot!(quote_string("hello"), "hello");
2556    k9::snapshot!(quote_string("hello there"), r#""hello there""#);
2557    k9::snapshot!(quote_string("hello, there"), "\"hello, there\"");
2558    k9::snapshot!(quote_string("hello \"there\""), r#""hello \\"there\\"""#);
2559    k9::snapshot!(
2560        quote_string("hello c:\\backslash"),
2561        r#""hello c:\\\\backslash""#
2562    );
2563    k9::assert_equal!(quote_string("hello\n there"), "\"hello\n there\"");
2564}
2565
2566impl EncodeHeaderValue for Mailbox {
2567    fn encode_value(&self) -> SharedString<'static> {
2568        match &self.name {
2569            Some(name) => {
2570                let mut value: Vec<u8> = if name.is_ascii() {
2571                    quote_string(name).into()
2572                } else {
2573                    qp_encode(name.as_bytes()).into_bytes()
2574                };
2575
2576                value.push_str(" <");
2577                value.push_str(self.address.encode_value().as_bytes());
2578                value.push(b'>');
2579                value.into()
2580            }
2581            None => {
2582                let mut result: Vec<u8> = vec![];
2583                result.push(b'<');
2584                result.push_str(self.address.encode_value().as_bytes());
2585                result.push(b'>');
2586                result.into()
2587            }
2588        }
2589    }
2590}
2591
2592impl EncodeHeaderValue for MailboxList {
2593    fn encode_value(&self) -> SharedString<'static> {
2594        let mut result: Vec<u8> = vec![];
2595        for mailbox in &self.0 {
2596            if !result.is_empty() {
2597                result.push_str(",\r\n\t");
2598            }
2599            result.push_str(mailbox.encode_value().as_bytes());
2600        }
2601        result.into()
2602    }
2603}
2604
2605impl EncodeHeaderValue for Address {
2606    fn encode_value(&self) -> SharedString<'static> {
2607        match self {
2608            Self::Mailbox(mbox) => mbox.encode_value(),
2609            Self::Group { name, entries } => {
2610                let mut result: Vec<u8> = vec![];
2611                result.push_str(name);
2612                result.push(b':');
2613                result.push_str(entries.encode_value().as_bytes());
2614                result.push(b';');
2615                result.into()
2616            }
2617        }
2618    }
2619}
2620
2621impl EncodeHeaderValue for AddressList {
2622    fn encode_value(&self) -> SharedString<'static> {
2623        let mut result: Vec<u8> = vec![];
2624        for address in &self.0 {
2625            if !result.is_empty() {
2626                result.push_str(",\r\n\t");
2627            }
2628            result.push_str(address.encode_value().as_bytes());
2629        }
2630        result.into()
2631    }
2632}
2633
2634#[cfg(test)]
2635mod test {
2636    use super::*;
2637    use crate::{Header, MessageConformance, MimePart};
2638
2639    #[test]
2640    fn mailbox_encodes_at() {
2641        let mbox = Mailbox {
2642            name: Some("foo@bar.com".into()),
2643            address: AddrSpec {
2644                local_part: "foo".into(),
2645                domain: "bar.com".into(),
2646            },
2647        };
2648        assert_eq!(mbox.encode_value(), "\"foo@bar.com\" <foo@bar.com>");
2649    }
2650
2651    #[test]
2652    fn mailbox_list_singular() {
2653        let message = concat!(
2654            "From:  Someone (hello) <someone@example.com>, other@example.com,\n",
2655            "  \"John \\\"Smith\\\"\" (comment) \"More Quotes\" (more comment) <someone(another comment)@crazy.example.com(woot)>\n",
2656            "\n",
2657            "I am the body"
2658        );
2659        let msg = MimePart::parse(message).unwrap();
2660        let list = match msg.headers().from() {
2661            Err(err) => panic!("Doh.\n{err:#}"),
2662            Ok(list) => list,
2663        };
2664
2665        k9::snapshot!(
2666            list,
2667            r#"
2668Some(
2669    MailboxList(
2670        [
2671            Mailbox {
2672                name: Some(
2673                    "Someone",
2674                ),
2675                address: AddrSpec {
2676                    local_part: "someone",
2677                    domain: "example.com",
2678                },
2679            },
2680            Mailbox {
2681                name: None,
2682                address: AddrSpec {
2683                    local_part: "other",
2684                    domain: "example.com",
2685                },
2686            },
2687            Mailbox {
2688                name: Some(
2689                    "John "Smith" More Quotes",
2690                ),
2691                address: AddrSpec {
2692                    local_part: "someone",
2693                    domain: "crazy.example.com",
2694                },
2695            },
2696        ],
2697    ),
2698)
2699"#
2700        );
2701    }
2702
2703    #[test]
2704    fn docomo_non_compliant_localpart() {
2705        let message = "Sender: hello..there@docomo.ne.jp\n\n\n";
2706        let msg = MimePart::parse(message).unwrap();
2707        let err = msg.headers().sender().unwrap_err();
2708        k9::snapshot!(
2709            err,
2710            r#"
2711InvalidHeaderValueDuringGet {
2712    header_name: "Sender",
2713    error: HeaderParse(
2714        "Error at line 1, expected "@" but found ".":
2715hello..there@docomo.ne.jp
2716     ^___________________
2717
2718while parsing addr_spec
2719while parsing mailbox
2720",
2721    ),
2722}
2723"#
2724        );
2725    }
2726
2727    #[test]
2728    fn sender() {
2729        let message = "Sender: someone@[127.0.0.1]\n\n\n";
2730        let msg = MimePart::parse(message).unwrap();
2731        let list = match msg.headers().sender() {
2732            Err(err) => panic!("Doh.\n{err:#}"),
2733            Ok(list) => list,
2734        };
2735        k9::snapshot!(
2736            list,
2737            r#"
2738Some(
2739    Mailbox {
2740        name: None,
2741        address: AddrSpec {
2742            local_part: "someone",
2743            domain: "[127.0.0.1]",
2744        },
2745    },
2746)
2747"#
2748        );
2749    }
2750
2751    #[test]
2752    fn domain_literal() {
2753        let message = "From: someone@[127.0.0.1]\n\n\n";
2754        let msg = MimePart::parse(message).unwrap();
2755        let list = match msg.headers().from() {
2756            Err(err) => panic!("Doh.\n{err:#}"),
2757            Ok(list) => list,
2758        };
2759        k9::snapshot!(
2760            list,
2761            r#"
2762Some(
2763    MailboxList(
2764        [
2765            Mailbox {
2766                name: None,
2767                address: AddrSpec {
2768                    local_part: "someone",
2769                    domain: "[127.0.0.1]",
2770                },
2771            },
2772        ],
2773    ),
2774)
2775"#
2776        );
2777    }
2778
2779    #[test]
2780    fn rfc6532() {
2781        let message = concat!(
2782            "From: Keith Moore <moore@cs.utk.edu>\n",
2783            "To: Keld Jørn Simonsen <keld@dkuug.dk>\n",
2784            "CC: André Pirard <PIRARD@vm1.ulg.ac.be>\n",
2785            "Subject: Hello André\n",
2786            "\n\n"
2787        );
2788        let msg = MimePart::parse(message).unwrap();
2789        let list = match msg.headers().from() {
2790            Err(err) => panic!("Doh.\n{err:#}"),
2791            Ok(list) => list,
2792        };
2793        k9::snapshot!(
2794            list,
2795            r#"
2796Some(
2797    MailboxList(
2798        [
2799            Mailbox {
2800                name: Some(
2801                    "Keith Moore",
2802                ),
2803                address: AddrSpec {
2804                    local_part: "moore",
2805                    domain: "cs.utk.edu",
2806                },
2807            },
2808        ],
2809    ),
2810)
2811"#
2812        );
2813
2814        let list = match msg.headers().to() {
2815            Err(err) => panic!("Doh.\n{err:#}"),
2816            Ok(list) => list,
2817        };
2818        k9::snapshot!(
2819            list,
2820            r#"
2821Some(
2822    AddressList(
2823        [
2824            Mailbox(
2825                Mailbox {
2826                    name: Some(
2827                        "Keld Jørn Simonsen",
2828                    ),
2829                    address: AddrSpec {
2830                        local_part: "keld",
2831                        domain: "dkuug.dk",
2832                    },
2833                },
2834            ),
2835        ],
2836    ),
2837)
2838"#
2839        );
2840
2841        let list = match msg.headers().cc() {
2842            Err(err) => panic!("Doh.\n{err:#}"),
2843            Ok(list) => list,
2844        };
2845        k9::snapshot!(
2846            list,
2847            r#"
2848Some(
2849    AddressList(
2850        [
2851            Mailbox(
2852                Mailbox {
2853                    name: Some(
2854                        "André Pirard",
2855                    ),
2856                    address: AddrSpec {
2857                        local_part: "PIRARD",
2858                        domain: "vm1.ulg.ac.be",
2859                    },
2860                },
2861            ),
2862        ],
2863    ),
2864)
2865"#
2866        );
2867        let list = match msg.headers().subject() {
2868            Err(err) => panic!("Doh.\n{err:#}"),
2869            Ok(list) => list,
2870        };
2871        k9::snapshot!(
2872            list,
2873            r#"
2874Some(
2875    "Hello André",
2876)
2877"#
2878        );
2879    }
2880
2881    #[test]
2882    fn unstructured_bare_non_ascii() {
2883        // Direct test of unstructured header parsing with bare UTF-8
2884        // (no encoded-word), exercising obs_utext -> utf8_non_ascii
2885        let message = "Subject: Héllo wörld äöü\n\n\n";
2886        let msg = MimePart::parse(message).unwrap();
2887        k9::snapshot!(
2888            msg.headers().subject().unwrap(),
2889            r#"
2890Some(
2891    "Héllo wörld äöü",
2892)
2893"#
2894        );
2895
2896        // Subject with CJK characters
2897        let message = "Subject: 件名テスト\n\n\n";
2898        let msg = MimePart::parse(message).unwrap();
2899        k9::snapshot!(
2900            msg.headers().subject().unwrap(),
2901            r#"
2902Some(
2903    "件名テスト",
2904)
2905"#
2906        );
2907    }
2908
2909    #[test]
2910    fn unstructured_raw_shift_jis() {
2911        // Raw Shift-JIS bytes in a Subject header (not wrapped in an
2912        // RFC 2047 encoded-word). "テスト" in Shift-JIS is:
2913        //   テ=0x83 0x65  ス=0x83 0x58  ト=0x83 0x67
2914        // These bytes are not valid UTF-8 (0x83 is a continuation byte
2915        // appearing as a lead byte). With utf8_non_ascii validation,
2916        // the parser will not match them as non-ASCII text.
2917        let message = b"Subject: \x83\x65\x83\x58\x83\x67\n\n\n";
2918
2919        // Structural parse succeeds: the message is split into headers
2920        // and body, and the Subject header is recognized.
2921        let msg = MimePart::parse(message.as_slice()).unwrap();
2922        let subject_header = msg.headers().get_first("Subject").unwrap();
2923        k9::assert_equal!(
2924            subject_header.get_raw_value(),
2925            b"\x83\x65\x83\x58\x83\x67".as_slice()
2926        );
2927
2928        // Semantic parse of the value as unstructured text fails because
2929        // the raw bytes are not valid UTF-8.
2930        k9::snapshot!(
2931            msg.headers().subject(),
2932            r#"
2933Err(
2934    InvalidHeaderValueDuringGet {
2935        header_name: "Subject",
2936        error: HeaderParse(
2937            "Error at line 1, in Eof:
2938\\x83e\\x83X\\x83g
2939^_____
2940
2941",
2942        ),
2943    },
2944)
2945"#
2946        );
2947    }
2948
2949    #[test]
2950    fn rfc2047_bogus() {
2951        let message = concat!(
2952            "From: =?US-OSCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2953            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2954            "CC: =?ISO-8859-1?Q?Andr=E?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2955            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?=\n",
2956            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2957            "\n\n"
2958        );
2959        let msg = MimePart::parse(message).unwrap();
2960
2961        // Invalid charset causes encoded_word to fail and we will instead match
2962        // obs_utext and return it as it was
2963        k9::assert_equal!(
2964            msg.headers().from().unwrap().unwrap().0[0]
2965                .name
2966                .as_ref()
2967                .unwrap(),
2968            "=?US-OSCII?Q?Keith_Moore?="
2969        );
2970
2971        match &msg.headers().cc().unwrap().unwrap().0[0] {
2972            Address::Mailbox(mbox) => {
2973                // 'Andr=E9?=' is in the non-bogus example below, but above we
2974                // broke it as 'Andr=E?=', and instead of triggering a qp decode
2975                // error, it is passed through here as-is
2976                k9::assert_equal!(mbox.name.as_ref().unwrap(), "Andr=E Pirard");
2977            }
2978            wat => panic!("should not have {wat:?}"),
2979        }
2980
2981        // The invalid base64 (an I was replaced by an !) is interpreted as obs_utext
2982        // and passed through to us
2983        k9::assert_equal!(
2984            msg.headers().subject().unwrap().unwrap(),
2985            "Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?= u understand the example."
2986        );
2987    }
2988
2989    #[test]
2990    fn attachment_filename_mess_totally_bogus() {
2991        let message = concat!("Content-Disposition: attachment; filename=@\n", "\n\n");
2992        let msg = MimePart::parse(message).unwrap();
2993        eprintln!("{msg:#?}");
2994
2995        assert!(msg
2996            .conformance()
2997            .contains(MessageConformance::INVALID_MIME_HEADERS));
2998        msg.headers().content_disposition().unwrap_err();
2999
3000        // There is no Content-Disposition in the rebuilt message, because
3001        // there was no valid Content-Disposition in what we parsed
3002        let rebuilt = msg.rebuild(None).unwrap();
3003        k9::assert_equal!(rebuilt.headers().content_disposition(), Ok(None));
3004    }
3005
3006    #[test]
3007    fn attachment_filename_mess_aberrant() {
3008        let message = concat!(
3009            "Content-Disposition: attachment; filename= =?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\n",
3010            "\n\n"
3011        );
3012        let msg = MimePart::parse(message).unwrap();
3013
3014        let cd = msg.headers().content_disposition().unwrap().unwrap();
3015        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
3016
3017        let encoded = cd.encode_value();
3018        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?=");
3019    }
3020
3021    #[test]
3022    fn attachment_filename_mess_gmail() {
3023        let message = concat!(
3024            "Content-Disposition: attachment; filename=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
3025            "Content-Type: text/plain;\n",
3026            "   name=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
3027            "\n\n"
3028        );
3029        let msg = MimePart::parse(message).unwrap();
3030
3031        let cd = msg.headers().content_disposition().unwrap().unwrap();
3032        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
3033        let encoded = cd.encode_value();
3034        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?=\"");
3035
3036        let ct = msg.headers().content_type().unwrap().unwrap();
3037        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付");
3038    }
3039
3040    #[test]
3041    fn attachment_filename_mess_fastmail() {
3042        let message = concat!(
3043            "Content-Disposition: attachment;\n",
3044            "  filename*0*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98;\n",
3045            "  filename*1*=.txt\n",
3046            "Content-Type: text/plain;\n",
3047            "   name=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=\"\n",
3048            "   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",
3049            "\n\n"
3050        );
3051        let msg = MimePart::parse(message).unwrap();
3052
3053        let cd = msg.headers().content_disposition().unwrap().unwrap();
3054        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付.txt");
3055
3056        let ct = msg.headers().content_type().unwrap().unwrap();
3057        eprintln!("{ct:#?}");
3058        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付.txt");
3059        k9::assert_equal!(
3060            ct.get("x-name").unwrap(),
3061            "=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=bork"
3062        );
3063    }
3064
3065    #[test]
3066    fn rfc2047() {
3067        let message = concat!(
3068            "From: =?US-ASCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
3069            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
3070            "CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
3071            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n",
3072            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
3073            "\n\n"
3074        );
3075        let msg = MimePart::parse(message).unwrap();
3076        let list = match msg.headers().from() {
3077            Err(err) => panic!("Doh.\n{err:#}"),
3078            Ok(list) => list,
3079        };
3080        k9::snapshot!(
3081            list,
3082            r#"
3083Some(
3084    MailboxList(
3085        [
3086            Mailbox {
3087                name: Some(
3088                    "Keith Moore",
3089                ),
3090                address: AddrSpec {
3091                    local_part: "moore",
3092                    domain: "cs.utk.edu",
3093                },
3094            },
3095        ],
3096    ),
3097)
3098"#
3099        );
3100
3101        let list = match msg.headers().to() {
3102            Err(err) => panic!("Doh.\n{err:#}"),
3103            Ok(list) => list,
3104        };
3105        k9::snapshot!(
3106            list,
3107            r#"
3108Some(
3109    AddressList(
3110        [
3111            Mailbox(
3112                Mailbox {
3113                    name: Some(
3114                        "Keld Jørn Simonsen",
3115                    ),
3116                    address: AddrSpec {
3117                        local_part: "keld",
3118                        domain: "dkuug.dk",
3119                    },
3120                },
3121            ),
3122        ],
3123    ),
3124)
3125"#
3126        );
3127
3128        let list = match msg.headers().cc() {
3129            Err(err) => panic!("Doh.\n{err:#}"),
3130            Ok(list) => list,
3131        };
3132        k9::snapshot!(
3133            list,
3134            r#"
3135Some(
3136    AddressList(
3137        [
3138            Mailbox(
3139                Mailbox {
3140                    name: Some(
3141                        "André Pirard",
3142                    ),
3143                    address: AddrSpec {
3144                        local_part: "PIRARD",
3145                        domain: "vm1.ulg.ac.be",
3146                    },
3147                },
3148            ),
3149        ],
3150    ),
3151)
3152"#
3153        );
3154        let list = match msg.headers().subject() {
3155            Err(err) => panic!("Doh.\n{err:#}"),
3156            Ok(list) => list,
3157        };
3158        k9::snapshot!(
3159            list,
3160            r#"
3161Some(
3162    "Hello If you can read this you understand the example.",
3163)
3164"#
3165        );
3166
3167        k9::snapshot!(
3168            BString::from(msg.rebuild(None).unwrap().to_message_bytes()),
3169            r#"
3170Content-Type: text/plain;\r
3171\tcharset="us-ascii"\r
3172Content-Transfer-Encoding: quoted-printable\r
3173From: "Keith Moore" <moore@cs.utk.edu>\r
3174To: =?UTF-8?q?Keld_J=C3=B8rn_Simonsen?= <keld@dkuug.dk>\r
3175Cc: =?UTF-8?q?Andr=C3=A9_Pirard?= <PIRARD@vm1.ulg.ac.be>\r
3176Subject: Hello If you can read this you understand the example.\r
3177\r
3178=0A\r
3179
3180"#
3181        );
3182    }
3183
3184    #[test]
3185    fn group_addresses() {
3186        let message = concat!(
3187            "To: A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;\n",
3188            "Cc: Undisclosed recipients:;\n",
3189            "\n\n\n"
3190        );
3191        let msg = MimePart::parse(message).unwrap();
3192        let list = match msg.headers().to() {
3193            Err(err) => panic!("Doh.\n{err:#}"),
3194            Ok(list) => list.unwrap(),
3195        };
3196
3197        k9::snapshot!(
3198            list.encode_value(),
3199            r#"
3200A Group:"Ed Jones" <c@a.test>,\r
3201\t<joe@where.test>,\r
3202\tJohn <jdoe@one.test>;
3203"#
3204        );
3205
3206        let round_trip = Header::new("To", list.clone());
3207        k9::assert_equal!(list, round_trip.as_address_list().unwrap());
3208
3209        k9::snapshot!(
3210            list,
3211            r#"
3212AddressList(
3213    [
3214        Group {
3215            name: "A Group",
3216            entries: MailboxList(
3217                [
3218                    Mailbox {
3219                        name: Some(
3220                            "Ed Jones",
3221                        ),
3222                        address: AddrSpec {
3223                            local_part: "c",
3224                            domain: "a.test",
3225                        },
3226                    },
3227                    Mailbox {
3228                        name: None,
3229                        address: AddrSpec {
3230                            local_part: "joe",
3231                            domain: "where.test",
3232                        },
3233                    },
3234                    Mailbox {
3235                        name: Some(
3236                            "John",
3237                        ),
3238                        address: AddrSpec {
3239                            local_part: "jdoe",
3240                            domain: "one.test",
3241                        },
3242                    },
3243                ],
3244            ),
3245        },
3246    ],
3247)
3248"#
3249        );
3250
3251        let list = match msg.headers().cc() {
3252            Err(err) => panic!("Doh.\n{err:#}"),
3253            Ok(list) => list,
3254        };
3255        k9::snapshot!(
3256            list,
3257            r#"
3258Some(
3259    AddressList(
3260        [
3261            Group {
3262                name: "Undisclosed recipients",
3263                entries: MailboxList(
3264                    [],
3265                ),
3266            },
3267        ],
3268    ),
3269)
3270"#
3271        );
3272    }
3273
3274    #[test]
3275    fn message_id() {
3276        let message = concat!(
3277            "Message-Id: <foo@example.com>\n",
3278            "References: <a@example.com> <b@example.com>\n",
3279            "  <\"legacy\"@example.com>\n",
3280            "  <literal@[127.0.0.1]>\n",
3281            "\n\n\n"
3282        );
3283        let msg = MimePart::parse(message).unwrap();
3284        let list = match msg.headers().message_id() {
3285            Err(err) => panic!("Doh.\n{err:#}"),
3286            Ok(list) => list,
3287        };
3288        k9::snapshot!(
3289            list,
3290            r#"
3291Some(
3292    MessageID(
3293        "foo@example.com",
3294    ),
3295)
3296"#
3297        );
3298
3299        let list = match msg.headers().references() {
3300            Err(err) => panic!("Doh.\n{err:#}"),
3301            Ok(list) => list,
3302        };
3303        k9::snapshot!(
3304            list,
3305            r#"
3306Some(
3307    [
3308        MessageID(
3309            "a@example.com",
3310        ),
3311        MessageID(
3312            "b@example.com",
3313        ),
3314        MessageID(
3315            "legacy@example.com",
3316        ),
3317        MessageID(
3318            "literal@[127.0.0.1]",
3319        ),
3320    ],
3321)
3322"#
3323        );
3324    }
3325
3326    #[test]
3327    fn content_type() {
3328        let message = "Content-Type: text/plain\n\n\n\n";
3329        let msg = MimePart::parse(message).unwrap();
3330        let params = match msg.headers().content_type() {
3331            Err(err) => panic!("Doh.\n{err:#}"),
3332            Ok(params) => params,
3333        };
3334        k9::snapshot!(
3335            params,
3336            r#"
3337Some(
3338    MimeParameters {
3339        value: "text/plain",
3340        parameters: [],
3341    },
3342)
3343"#
3344        );
3345
3346        let message = "Content-Type: text/plain; charset=us-ascii\n\n\n\n";
3347        let msg = MimePart::parse(message).unwrap();
3348        let params = match msg.headers().content_type() {
3349            Err(err) => panic!("Doh.\n{err:#}"),
3350            Ok(params) => params.unwrap(),
3351        };
3352
3353        k9::snapshot!(
3354            params.get("charset"),
3355            r#"
3356Some(
3357    "us-ascii",
3358)
3359"#
3360        );
3361        k9::snapshot!(
3362            params,
3363            r#"
3364MimeParameters {
3365    value: "text/plain",
3366    parameters: [
3367        MimeParameter {
3368            name: "charset",
3369            section: None,
3370            mime_charset: None,
3371            mime_language: None,
3372            encoding: None,
3373            value: "us-ascii",
3374        },
3375    ],
3376}
3377"#
3378        );
3379
3380        let message = "Content-Type: text/plain; charset=\"us-ascii\"\n\n\n\n";
3381        let msg = MimePart::parse(message).unwrap();
3382        let params = match msg.headers().content_type() {
3383            Err(err) => panic!("Doh.\n{err:#}"),
3384            Ok(params) => params,
3385        };
3386        k9::snapshot!(
3387            params,
3388            r#"
3389Some(
3390    MimeParameters {
3391        value: "text/plain",
3392        parameters: [
3393            MimeParameter {
3394                name: "charset",
3395                section: None,
3396                mime_charset: None,
3397                mime_language: None,
3398                encoding: None,
3399                value: "us-ascii",
3400            },
3401        ],
3402    },
3403)
3404"#
3405        );
3406    }
3407
3408    #[test]
3409    fn content_type_rfc2231() {
3410        // This example is taken from the errata for rfc2231.
3411        // <https://www.rfc-editor.org/errata/eid590>
3412        let message = concat!(
3413            "Content-Type: application/x-stuff;\n",
3414            "\ttitle*0*=us-ascii'en'This%20is%20even%20more%20;\n",
3415            "\ttitle*1*=%2A%2A%2Afun%2A%2A%2A%20;\n",
3416            "\ttitle*2=\"isn't it!\"\n",
3417            "\n\n\n"
3418        );
3419        let msg = MimePart::parse(message).unwrap();
3420        let mut params = match msg.headers().content_type() {
3421            Err(err) => panic!("Doh.\n{err:#}"),
3422            Ok(params) => params.unwrap(),
3423        };
3424
3425        let original_title = params.get("title");
3426        k9::snapshot!(
3427            &original_title,
3428            r#"
3429Some(
3430    "This is even more ***fun*** isn't it!",
3431)
3432"#
3433        );
3434
3435        k9::snapshot!(
3436            &params,
3437            r#"
3438MimeParameters {
3439    value: "application/x-stuff",
3440    parameters: [
3441        MimeParameter {
3442            name: "title",
3443            section: Some(
3444                0,
3445            ),
3446            mime_charset: Some(
3447                "us-ascii",
3448            ),
3449            mime_language: Some(
3450                "en",
3451            ),
3452            encoding: Rfc2231,
3453            value: "This%20is%20even%20more%20",
3454        },
3455        MimeParameter {
3456            name: "title",
3457            section: Some(
3458                1,
3459            ),
3460            mime_charset: None,
3461            mime_language: None,
3462            encoding: Rfc2231,
3463            value: "%2A%2A%2Afun%2A%2A%2A%20",
3464        },
3465        MimeParameter {
3466            name: "title",
3467            section: Some(
3468                2,
3469            ),
3470            mime_charset: None,
3471            mime_language: None,
3472            encoding: None,
3473            value: "isn't it!",
3474        },
3475    ],
3476}
3477"#
3478        );
3479
3480        k9::snapshot!(
3481            params.encode_value(),
3482            r#"
3483application/x-stuff;\r
3484\ttitle="This is even more ***fun*** isn't it!"
3485"#
3486        );
3487
3488        params.set("foo", "bar 💩");
3489
3490        params.set(
3491            "long",
3492            "this is some text that should wrap because \
3493                it should be a good bit longer than our target maximum \
3494                length for this sort of thing, and hopefully we see at \
3495                least three lines produced as a result of setting \
3496                this value in this way",
3497        );
3498
3499        params.set(
3500            "longernnamethananyoneshouldreallyuse",
3501            "this is some text that should wrap because \
3502                it should be a good bit longer than our target maximum \
3503                length for this sort of thing, and hopefully we see at \
3504                least three lines produced as a result of setting \
3505                this value in this way",
3506        );
3507
3508        k9::snapshot!(
3509            params.encode_value(),
3510            r#"
3511application/x-stuff;\r
3512\tfoo*=UTF-8''bar%20%F0%9F%92%A9;\r
3513\tlong*0="this is some text that should wrap because it should be a good bi";\r
3514\tlong*1="t longer than our target maximum length for this sort of thing, a";\r
3515\tlong*2="nd hopefully we see at least three lines produced as a result of ";\r
3516\tlong*3="setting this value in this way";\r
3517\tlongernnamethananyoneshouldreallyuse*0="this is some text that should wra";\r
3518\tlongernnamethananyoneshouldreallyuse*1="p because it should be a good bit";\r
3519\tlongernnamethananyoneshouldreallyuse*2=" longer than our target maximum l";\r
3520\tlongernnamethananyoneshouldreallyuse*3="ength for this sort of thing, and";\r
3521\tlongernnamethananyoneshouldreallyuse*4=" hopefully we see at least three ";\r
3522\tlongernnamethananyoneshouldreallyuse*5="lines produced as a result of set";\r
3523\tlongernnamethananyoneshouldreallyuse*6="ting this value in this way";\r
3524\ttitle="This is even more ***fun*** isn't it!"
3525"#
3526        );
3527    }
3528
3529    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.2>
3530    #[test]
3531    fn authentication_results_b_2() {
3532        let ar = Header::with_name_value("Authentication-Results", "example.org 1; none");
3533        let ar = ar.as_authentication_results().unwrap();
3534        k9::snapshot!(
3535            &ar,
3536            r#"
3537AuthenticationResults {
3538    serv_id: "example.org",
3539    version: Some(
3540        1,
3541    ),
3542    results: [],
3543}
3544"#
3545        );
3546
3547        k9::snapshot!(ar.encode_value(), "example.org 1; none");
3548    }
3549
3550    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.3>
3551    #[test]
3552    fn authentication_results_b_3() {
3553        let ar = Header::with_name_value(
3554            "Authentication-Results",
3555            "example.com; spf=pass smtp.mailfrom=example.net",
3556        );
3557        k9::snapshot!(
3558            ar.as_authentication_results(),
3559            r#"
3560Ok(
3561    AuthenticationResults {
3562        serv_id: "example.com",
3563        version: None,
3564        results: [
3565            AuthenticationResult {
3566                method: "spf",
3567                method_version: None,
3568                result: "pass",
3569                reason: None,
3570                props: {
3571                    "smtp.mailfrom": "example.net",
3572                },
3573            },
3574        ],
3575    },
3576)
3577"#
3578        );
3579    }
3580
3581    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.4>
3582    #[test]
3583    fn authentication_results_b_4() {
3584        let ar = Header::with_name_value(
3585            "Authentication-Results",
3586            concat!(
3587                "example.com;\n",
3588                "\tauth=pass (cram-md5) smtp.auth=sender@example.net;\n",
3589                "\tspf=pass smtp.mailfrom=example.net"
3590            ),
3591        );
3592        k9::snapshot!(
3593            ar.as_authentication_results(),
3594            r#"
3595Ok(
3596    AuthenticationResults {
3597        serv_id: "example.com",
3598        version: None,
3599        results: [
3600            AuthenticationResult {
3601                method: "auth",
3602                method_version: None,
3603                result: "pass",
3604                reason: None,
3605                props: {
3606                    "smtp.auth": "sender@example.net",
3607                },
3608            },
3609            AuthenticationResult {
3610                method: "spf",
3611                method_version: None,
3612                result: "pass",
3613                reason: None,
3614                props: {
3615                    "smtp.mailfrom": "example.net",
3616                },
3617            },
3618        ],
3619    },
3620)
3621"#
3622        );
3623
3624        let ar = Header::with_name_value(
3625            "Authentication-Results",
3626            "example.com; iprev=pass\n\tpolicy.iprev=192.0.2.200",
3627        );
3628        k9::snapshot!(
3629            ar.as_authentication_results(),
3630            r#"
3631Ok(
3632    AuthenticationResults {
3633        serv_id: "example.com",
3634        version: None,
3635        results: [
3636            AuthenticationResult {
3637                method: "iprev",
3638                method_version: None,
3639                result: "pass",
3640                reason: None,
3641                props: {
3642                    "policy.iprev": "192.0.2.200",
3643                },
3644            },
3645        ],
3646    },
3647)
3648"#
3649        );
3650    }
3651
3652    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.5>
3653    #[test]
3654    fn authentication_results_b_5() {
3655        let ar = Header::with_name_value(
3656            "Authentication-Results",
3657            "example.com;\n\tdkim=pass (good signature) header.d=example.com",
3658        );
3659        k9::snapshot!(
3660            ar.as_authentication_results(),
3661            r#"
3662Ok(
3663    AuthenticationResults {
3664        serv_id: "example.com",
3665        version: None,
3666        results: [
3667            AuthenticationResult {
3668                method: "dkim",
3669                method_version: None,
3670                result: "pass",
3671                reason: None,
3672                props: {
3673                    "header.d": "example.com",
3674                },
3675            },
3676        ],
3677    },
3678)
3679"#
3680        );
3681
3682        let ar = Header::with_name_value(
3683            "Authentication-Results",
3684            "example.com;\n\tauth=pass (cram-md5) smtp.auth=sender@example.com;\n\tspf=fail smtp.mailfrom=example.com"
3685        );
3686        let ar = ar.as_authentication_results().unwrap();
3687        k9::snapshot!(
3688            &ar,
3689            r#"
3690AuthenticationResults {
3691    serv_id: "example.com",
3692    version: None,
3693    results: [
3694        AuthenticationResult {
3695            method: "auth",
3696            method_version: None,
3697            result: "pass",
3698            reason: None,
3699            props: {
3700                "smtp.auth": "sender@example.com",
3701            },
3702        },
3703        AuthenticationResult {
3704            method: "spf",
3705            method_version: None,
3706            result: "fail",
3707            reason: None,
3708            props: {
3709                "smtp.mailfrom": "example.com",
3710            },
3711        },
3712    ],
3713}
3714"#
3715        );
3716
3717        k9::snapshot!(
3718            ar.encode_value(),
3719            r#"
3720example.com;\r
3721\tauth=pass\r
3722\tsmtp.auth=sender@example.com;\r
3723\tspf=fail\r
3724\tsmtp.mailfrom=example.com
3725"#
3726        );
3727    }
3728
3729    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.6>
3730    #[test]
3731    fn authentication_results_b_6() {
3732        let ar = Header::with_name_value(
3733            "Authentication-Results",
3734            concat!(
3735                "example.com;\n",
3736                "\tdkim=pass reason=\"good signature\"\n",
3737                "\theader.i=@mail-router.example.net;\n",
3738                "\tdkim=fail reason=\"bad signature\"\n",
3739                "\theader.i=@newyork.example.com"
3740            ),
3741        );
3742        let ar = match ar.as_authentication_results() {
3743            Err(err) => panic!("\n{err}"),
3744            Ok(ar) => ar,
3745        };
3746
3747        k9::snapshot!(
3748            &ar,
3749            r#"
3750AuthenticationResults {
3751    serv_id: "example.com",
3752    version: None,
3753    results: [
3754        AuthenticationResult {
3755            method: "dkim",
3756            method_version: None,
3757            result: "pass",
3758            reason: Some(
3759                "good signature",
3760            ),
3761            props: {
3762                "header.i": "@mail-router.example.net",
3763            },
3764        },
3765        AuthenticationResult {
3766            method: "dkim",
3767            method_version: None,
3768            result: "fail",
3769            reason: Some(
3770                "bad signature",
3771            ),
3772            props: {
3773                "header.i": "@newyork.example.com",
3774            },
3775        },
3776    ],
3777}
3778"#
3779        );
3780
3781        k9::snapshot!(
3782            ar.encode_value(),
3783            r#"
3784example.com;\r
3785\tdkim=pass reason="good signature"\r
3786\theader.i=@mail-router.example.net;\r
3787\tdkim=fail reason="bad signature"\r
3788\theader.i=@newyork.example.com
3789"#
3790        );
3791
3792        let ar = Header::with_name_value(
3793            "Authentication-Results",
3794            concat!(
3795                "example.net;\n",
3796                "\tdkim=pass (good signature) header.i=@newyork.example.com"
3797            ),
3798        );
3799        let ar = match ar.as_authentication_results() {
3800            Err(err) => panic!("\n{err}"),
3801            Ok(ar) => ar,
3802        };
3803
3804        k9::snapshot!(
3805            &ar,
3806            r#"
3807AuthenticationResults {
3808    serv_id: "example.net",
3809    version: None,
3810    results: [
3811        AuthenticationResult {
3812            method: "dkim",
3813            method_version: None,
3814            result: "pass",
3815            reason: None,
3816            props: {
3817                "header.i": "@newyork.example.com",
3818            },
3819        },
3820    ],
3821}
3822"#
3823        );
3824
3825        k9::snapshot!(
3826            ar.encode_value(),
3827            r#"
3828example.net;\r
3829\tdkim=pass\r
3830\theader.i=@newyork.example.com
3831"#
3832        );
3833    }
3834
3835    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.7>
3836    #[test]
3837    fn authentication_results_b_7() {
3838        let ar = Header::with_name_value(
3839            "Authentication-Results",
3840            concat!(
3841                "foo.example.net (foobar) 1 (baz);\n",
3842                "\tdkim (Because I like it) / 1 (One yay) = (wait for it) fail\n",
3843                "\tpolicy (A dot can go here) . (like that) expired\n",
3844                "\t(this surprised me) = (as I wasn't expecting it) 1362471462"
3845            ),
3846        );
3847        let ar = match ar.as_authentication_results() {
3848            Err(err) => panic!("\n{err}"),
3849            Ok(ar) => ar,
3850        };
3851
3852        k9::snapshot!(
3853            &ar,
3854            r#"
3855AuthenticationResults {
3856    serv_id: "foo.example.net",
3857    version: Some(
3858        1,
3859    ),
3860    results: [
3861        AuthenticationResult {
3862            method: "dkim",
3863            method_version: Some(
3864                1,
3865            ),
3866            result: "fail",
3867            reason: None,
3868            props: {
3869                "policy.expired": "1362471462",
3870            },
3871        },
3872    ],
3873}
3874"#
3875        );
3876
3877        k9::snapshot!(
3878            ar.encode_value(),
3879            r#"
3880foo.example.net 1;\r
3881\tdkim/1=fail\r
3882\tpolicy.expired=1362471462
3883"#
3884        );
3885    }
3886
3887    #[test]
3888    fn arc_authentication_results_1() {
3889        let ar = Header::with_name_value(
3890            "ARC-Authentication-Results",
3891            "i=3; clochette.example.org; spf=fail
3892    smtp.from=jqd@d1.example; dkim=fail (512-bit key)
3893    header.i=@d1.example; dmarc=fail; arc=pass (as.2.gmail.example=pass,
3894    ams.2.gmail.example=pass, as.1.lists.example.org=pass,
3895    ams.1.lists.example.org=fail (message has been altered))",
3896        );
3897        let ar = match ar.as_arc_authentication_results() {
3898            Err(err) => panic!("\n{err}"),
3899            Ok(ar) => ar,
3900        };
3901
3902        k9::snapshot!(
3903            &ar,
3904            r#"
3905ARCAuthenticationResults {
3906    instance: 3,
3907    serv_id: "clochette.example.org",
3908    version: None,
3909    results: [
3910        AuthenticationResult {
3911            method: "spf",
3912            method_version: None,
3913            result: "fail",
3914            reason: None,
3915            props: {
3916                "smtp.from": "jqd@d1.example",
3917            },
3918        },
3919        AuthenticationResult {
3920            method: "dkim",
3921            method_version: None,
3922            result: "fail",
3923            reason: None,
3924            props: {
3925                "header.i": "@d1.example",
3926            },
3927        },
3928        AuthenticationResult {
3929            method: "dmarc",
3930            method_version: None,
3931            result: "fail",
3932            reason: None,
3933            props: {},
3934        },
3935        AuthenticationResult {
3936            method: "arc",
3937            method_version: None,
3938            result: "pass",
3939            reason: None,
3940            props: {},
3941        },
3942    ],
3943}
3944"#
3945        );
3946    }
3947
3948    #[test]
3949    fn bstring_utf8_serializes_utf8_as_string() {
3950        // A MessageID with pure ASCII content serializes as a JSON string
3951        let mid = MessageID(BString::from("abc123@example.com"));
3952        let json = serde_json::to_string(&mid).unwrap();
3953        k9::assert_equal!(json, r#""abc123@example.com""#);
3954    }
3955
3956    #[test]
3957    fn bstring_utf8_serializes_non_utf8_as_array() {
3958        // A MessageID with invalid UTF-8 falls back to byte array
3959        let mid = MessageID(BString::from(&b"hello\x80world"[..]));
3960        let json = serde_json::to_string(&mid).unwrap();
3961        k9::assert_equal!(json, "[104,101,108,108,111,128,119,111,114,108,100]");
3962    }
3963
3964    #[test]
3965    fn bstring_utf8_round_trip_utf8() {
3966        let mid = MessageID(BString::from("test@example.com"));
3967        let json = serde_json::to_string(&mid).unwrap();
3968        let restored: MessageID = serde_json::from_str(&json).unwrap();
3969        k9::assert_equal!(restored, mid);
3970    }
3971
3972    #[test]
3973    fn bstring_utf8_round_trip_non_utf8() {
3974        let mid = MessageID(BString::from(&b"\xff\xfe"[..]));
3975        let json = serde_json::to_string(&mid).unwrap();
3976        let restored: MessageID = serde_json::from_str(&json).unwrap();
3977        k9::assert_equal!(restored, mid);
3978    }
3979
3980    #[test]
3981    fn authentication_results_serialize_as_strings() {
3982        let ar = AuthenticationResults {
3983            serv_id: BString::from("example.com"),
3984            version: None,
3985            results: vec![AuthenticationResult {
3986                method: "dkim".into(),
3987                method_version: None,
3988                result: "pass".into(),
3989                reason: Some(BString::from("good signature")),
3990                props: BTreeMap::from([
3991                    ("header.d".into(), BString::from("example.com")),
3992                    ("header.s".into(), BString::from("selector1")),
3993                ]),
3994            }],
3995        };
3996        let json = serde_json::to_string_pretty(&ar).unwrap();
3997        // All BString fields that are valid UTF-8 should appear as JSON strings
3998        k9::assert_equal!(
3999            json,
4000            r#"{
4001  "serv_id": "example.com",
4002  "version": null,
4003  "results": [
4004    {
4005      "method": "dkim",
4006      "method_version": null,
4007      "result": "pass",
4008      "reason": "good signature",
4009      "props": {
4010        "header.d": "example.com",
4011        "header.s": "selector1"
4012      }
4013    }
4014  ]
4015}"#
4016        );
4017    }
4018
4019    #[test]
4020    fn authentication_results_round_trip() {
4021        let ar = AuthenticationResults {
4022            serv_id: BString::from("mx.example.org"),
4023            version: Some(1),
4024            results: vec![AuthenticationResult {
4025                method: "spf".into(),
4026                method_version: None,
4027                result: "pass".into(),
4028                reason: None,
4029                props: BTreeMap::from([(
4030                    "smtp.mailfrom".into(),
4031                    BString::from("sender@example.com"),
4032                )]),
4033            }],
4034        };
4035        let json = serde_json::to_string(&ar).unwrap();
4036        let restored: AuthenticationResults = serde_json::from_str(&json).unwrap();
4037        k9::assert_equal!(restored, ar);
4038    }
4039
4040    #[test]
4041    fn authentication_result_non_utf8_reason() {
4042        let ar = AuthenticationResult {
4043            method: "dkim".into(),
4044            method_version: None,
4045            result: "temperror".into(),
4046            reason: Some(BString::from(&b"bad\x80data"[..])),
4047            props: BTreeMap::new(),
4048        };
4049        let json = serde_json::to_string(&ar).unwrap();
4050        // reason should be a byte array since it contains invalid UTF-8
4051        assert!(json.contains(r#""reason":[98,97,100,128,100,97,116,97]"#));
4052        let restored: AuthenticationResult = serde_json::from_str(&json).unwrap();
4053        k9::assert_equal!(restored, ar);
4054    }
4055
4056    #[test]
4057    fn authentication_results_encode_value_with_binary() {
4058        // Construct AuthenticationResults with non-UTF-8 bytes in BString fields
4059        // and capture the encode_value() output for use in a Lua test.
4060        let ar = AuthenticationResults {
4061            serv_id: BString::from(&b"mx.ex\x80mple.com"[..]),
4062            version: None,
4063            results: vec![AuthenticationResult {
4064                method: "spf".into(),
4065                method_version: None,
4066                result: "pass".into(),
4067                reason: Some(BString::from(&b"good\xffsig"[..])),
4068                props: BTreeMap::from([(
4069                    "smtp.mailfrom".into(),
4070                    BString::from(&b"user@\xfehost"[..]),
4071                )]),
4072            }],
4073        };
4074        let encoded = ar.encode_value();
4075        k9::snapshot!(
4076            encoded,
4077            r#"
4078"mx.ex\x80mple.com";\r
4079\tspf=pass reason="good\xffsig"\r
4080\tsmtp.mailfrom="user@\xfehost"
4081"#
4082        );
4083    }
4084
4085    #[test]
4086    fn authentication_results_serv_id_quoting() {
4087        // A serv_id containing characters that need quoting is properly quoted
4088        let ar = AuthenticationResults {
4089            serv_id: BString::from("mx example.com"),
4090            version: None,
4091            results: vec![],
4092        };
4093        let encoded = ar.encode_value();
4094        k9::snapshot!(encoded, r#""mx example.com"; none"#);
4095
4096        // Normal domain-like serv_id is emitted bare
4097        let ar2 = AuthenticationResults {
4098            serv_id: BString::from("mx.example.com"),
4099            version: Some(1),
4100            results: vec![],
4101        };
4102        let encoded2 = ar2.encode_value();
4103        k9::snapshot!(&encoded2, "mx.example.com 1; none");
4104        // Bare serv_id roundtrips
4105        let parsed = Parser::parse_authentication_results_header(encoded2.as_bytes()).unwrap();
4106        k9::assert_equal!(parsed.serv_id, ar2.serv_id);
4107        k9::assert_equal!(parsed.version, Some(1));
4108    }
4109
4110    #[test]
4111    fn arc_authentication_results_serialize_as_strings() {
4112        let arc = ARCAuthenticationResults {
4113            instance: 1,
4114            serv_id: BString::from("mx.example.com"),
4115            version: None,
4116            results: vec![],
4117        };
4118        let json = serde_json::to_string(&arc).unwrap();
4119        k9::assert_equal!(
4120            json,
4121            r#"{"instance":1,"serv_id":"mx.example.com","version":null,"results":[]}"#
4122        );
4123    }
4124}