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;
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,
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,
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,
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;
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        // RFC 2047 section 5(3) restricts the punctuation allowed unencoded in
2456        // a Q encoded-word within a phrase to this set. Because a phrase is the
2457        // most restrictive context this encoder serves, encoding to it keeps
2458        // one encoder valid everywhere, at the cost of encoding some
2459        // punctuation a Subject could have left alone. Since an underscore
2460        // represents a space in the Q encoding, a literal underscore must be
2461        // encoded as =5F to avoid a decoder turning it back into a space.
2462        let b = if c.is_ascii_alphanumeric() || matches!(c, '!' | '*' | '+' | '-' | '/') {
2463            Bytes::Passthru(bytes)
2464        } else if c == ' ' {
2465            Bytes::Passthru(b"_")
2466        } else {
2467            Bytes::Encode(bytes)
2468        };
2469
2470        let need_len = match b {
2471            Bytes::Passthru(b) => b.len(),
2472            Bytes::Encode(b) => b.len() * 3,
2473        };
2474
2475        if need_len > limit - line_length {
2476            // Need to wrap
2477            result.extend_from_slice(suffix);
2478            result.extend_from_slice(b"\r\n\t");
2479            result.extend_from_slice(prefix);
2480            line_length = 0;
2481        }
2482
2483        match b {
2484            Bytes::Passthru(c) => {
2485                result.extend_from_slice(c);
2486            }
2487            Bytes::Encode(bytes) => {
2488                for &c in bytes {
2489                    result.push(b'=');
2490                    result.push(HEX_CHARS[(c as usize) >> 4]);
2491                    result.push(HEX_CHARS[(c as usize) & 0x0f]);
2492                }
2493            }
2494        }
2495
2496        line_length += need_len;
2497    }
2498
2499    if line_length > 0 {
2500        result.extend_from_slice(suffix);
2501    }
2502
2503    // Safety: we ensured that everything we output is in the ASCII
2504    // range, therefore the string is valid UTF-8
2505    unsafe { String::from_utf8_unchecked(result) }
2506}
2507
2508#[cfg(test)]
2509#[test]
2510fn test_qp_encode() {
2511    let encoded = qp_encode(
2512        b"hello, I am a line that is this long, or maybe a little \
2513        bit longer than this, and that should get wrapped by the encoder",
2514    );
2515    k9::snapshot!(
2516        encoded,
2517        r#"
2518=?UTF-8?q?hello=2C_I_am_a_line_that_is_this_long=2C_or_maybe_a_little_?=\r
2519\t=?UTF-8?q?bit_longer_than_this=2C_and_that_should_get_wrapped_by_the_e?=\r
2520\t=?UTF-8?q?ncoder?=
2521"#
2522    );
2523}
2524
2525#[cfg(test)]
2526#[test]
2527fn test_qp_encode_literal_underscore() {
2528    // A literal underscore must be escaped as =5F to distinguish it from the
2529    // underscore that Q encoding uses to represent a space.
2530    let encoded = qp_encode("formul\u{e1}rios Word_TEST".as_bytes());
2531    k9::assert_equal!(encoded, "=?UTF-8?q?formul=C3=A1rios_Word=5FTEST?=");
2532}
2533
2534/// Quote input string `s`, using a backslash escape, if any
2535/// of the characters is NOT atext.  When quoting, the input
2536/// string is enclosed in quotes.
2537fn quote_string(s: impl AsRef<[u8]>) -> BString {
2538    let s = s.as_ref();
2539
2540    if s.iter().any(|&c| !is_atext(c)) {
2541        let mut result = Vec::<u8>::with_capacity(s.len() + 4);
2542        result.push(b'"');
2543        for (start, end, c) in s.char_indices() {
2544            let c = c as u32;
2545            if c <= 0xff {
2546                let c = c as u8;
2547                if !c.is_ascii_whitespace() && !is_qtext(c) && !is_atext(c) {
2548                    result.push(b'\\');
2549                }
2550            }
2551            result.push_str(&s[start..end]);
2552        }
2553        result.push(b'"');
2554        result.into()
2555    } else {
2556        s.into()
2557    }
2558}
2559
2560#[cfg(test)]
2561#[test]
2562fn test_quote_string() {
2563    k9::snapshot!(
2564        quote_string("TEST [ne_pas_repondre]"),
2565        r#""TEST [ne_pas_repondre]""#
2566    );
2567    k9::snapshot!(quote_string("hello"), "hello");
2568    k9::snapshot!(quote_string("hello there"), r#""hello there""#);
2569    k9::snapshot!(quote_string("hello, there"), "\"hello, there\"");
2570    k9::snapshot!(quote_string("hello \"there\""), r#""hello \\"there\\"""#);
2571    k9::snapshot!(
2572        quote_string("hello c:\\backslash"),
2573        r#""hello c:\\\\backslash""#
2574    );
2575    k9::assert_equal!(quote_string("hello\n there"), "\"hello\n there\"");
2576}
2577
2578impl EncodeHeaderValue for Mailbox {
2579    fn encode_value(&self) -> SharedString<'static> {
2580        match &self.name {
2581            Some(name) => {
2582                let mut value: Vec<u8> = if name.is_ascii() {
2583                    quote_string(name).into()
2584                } else {
2585                    qp_encode(name.as_bytes()).into_bytes()
2586                };
2587
2588                value.push_str(" <");
2589                value.push_str(self.address.encode_value().as_bytes());
2590                value.push(b'>');
2591                value.into()
2592            }
2593            None => {
2594                let mut result: Vec<u8> = vec![];
2595                result.push(b'<');
2596                result.push_str(self.address.encode_value().as_bytes());
2597                result.push(b'>');
2598                result.into()
2599            }
2600        }
2601    }
2602}
2603
2604impl EncodeHeaderValue for MailboxList {
2605    fn encode_value(&self) -> SharedString<'static> {
2606        let mut result: Vec<u8> = vec![];
2607        for mailbox in &self.0 {
2608            if !result.is_empty() {
2609                result.push_str(",\r\n\t");
2610            }
2611            result.push_str(mailbox.encode_value().as_bytes());
2612        }
2613        result.into()
2614    }
2615}
2616
2617impl EncodeHeaderValue for Address {
2618    fn encode_value(&self) -> SharedString<'static> {
2619        match self {
2620            Self::Mailbox(mbox) => mbox.encode_value(),
2621            Self::Group { name, entries } => {
2622                let mut result: Vec<u8> = vec![];
2623                result.push_str(name);
2624                result.push(b':');
2625                result.push_str(entries.encode_value().as_bytes());
2626                result.push(b';');
2627                result.into()
2628            }
2629        }
2630    }
2631}
2632
2633impl EncodeHeaderValue for AddressList {
2634    fn encode_value(&self) -> SharedString<'static> {
2635        let mut result: Vec<u8> = vec![];
2636        for address in &self.0 {
2637            if !result.is_empty() {
2638                result.push_str(",\r\n\t");
2639            }
2640            result.push_str(address.encode_value().as_bytes());
2641        }
2642        result.into()
2643    }
2644}
2645
2646#[cfg(test)]
2647mod test {
2648    use super::*;
2649    use crate::{Header, MessageConformance, MimePart};
2650
2651    #[test]
2652    fn mailbox_encodes_at() {
2653        let mbox = Mailbox {
2654            name: Some("foo@bar.com".into()),
2655            address: AddrSpec {
2656                local_part: "foo".into(),
2657                domain: "bar.com".into(),
2658            },
2659        };
2660        assert_eq!(mbox.encode_value(), "\"foo@bar.com\" <foo@bar.com>");
2661    }
2662
2663    #[test]
2664    fn mailbox_list_singular() {
2665        let message = concat!(
2666            "From:  Someone (hello) <someone@example.com>, other@example.com,\n",
2667            "  \"John \\\"Smith\\\"\" (comment) \"More Quotes\" (more comment) <someone(another comment)@crazy.example.com(woot)>\n",
2668            "\n",
2669            "I am the body"
2670        );
2671        let msg = MimePart::parse(message).unwrap();
2672        let list = match msg.headers().from() {
2673            Err(err) => panic!("Doh.\n{err:#}"),
2674            Ok(list) => list,
2675        };
2676
2677        k9::snapshot!(
2678            list,
2679            r#"
2680Some(
2681    MailboxList(
2682        [
2683            Mailbox {
2684                name: Some(
2685                    "Someone",
2686                ),
2687                address: AddrSpec {
2688                    local_part: "someone",
2689                    domain: "example.com",
2690                },
2691            },
2692            Mailbox {
2693                name: None,
2694                address: AddrSpec {
2695                    local_part: "other",
2696                    domain: "example.com",
2697                },
2698            },
2699            Mailbox {
2700                name: Some(
2701                    "John "Smith" More Quotes",
2702                ),
2703                address: AddrSpec {
2704                    local_part: "someone",
2705                    domain: "crazy.example.com",
2706                },
2707            },
2708        ],
2709    ),
2710)
2711"#
2712        );
2713    }
2714
2715    #[test]
2716    fn docomo_non_compliant_localpart() {
2717        let message = "Sender: hello..there@docomo.ne.jp\n\n\n";
2718        let msg = MimePart::parse(message).unwrap();
2719        let err = msg.headers().sender().unwrap_err();
2720        k9::snapshot!(
2721            err,
2722            r#"
2723InvalidHeaderValueDuringGet {
2724    header_name: "Sender",
2725    error: HeaderParse(
2726        "Error at line 1, expected "@" but found ".":
2727hello..there@docomo.ne.jp
2728     ^___________________
2729
2730while parsing addr_spec
2731while parsing mailbox
2732",
2733    ),
2734}
2735"#
2736        );
2737    }
2738
2739    #[test]
2740    fn sender() {
2741        let message = "Sender: someone@[127.0.0.1]\n\n\n";
2742        let msg = MimePart::parse(message).unwrap();
2743        let list = match msg.headers().sender() {
2744            Err(err) => panic!("Doh.\n{err:#}"),
2745            Ok(list) => list,
2746        };
2747        k9::snapshot!(
2748            list,
2749            r#"
2750Some(
2751    Mailbox {
2752        name: None,
2753        address: AddrSpec {
2754            local_part: "someone",
2755            domain: "[127.0.0.1]",
2756        },
2757    },
2758)
2759"#
2760        );
2761    }
2762
2763    #[test]
2764    fn domain_literal() {
2765        let message = "From: someone@[127.0.0.1]\n\n\n";
2766        let msg = MimePart::parse(message).unwrap();
2767        let list = match msg.headers().from() {
2768            Err(err) => panic!("Doh.\n{err:#}"),
2769            Ok(list) => list,
2770        };
2771        k9::snapshot!(
2772            list,
2773            r#"
2774Some(
2775    MailboxList(
2776        [
2777            Mailbox {
2778                name: None,
2779                address: AddrSpec {
2780                    local_part: "someone",
2781                    domain: "[127.0.0.1]",
2782                },
2783            },
2784        ],
2785    ),
2786)
2787"#
2788        );
2789    }
2790
2791    #[test]
2792    fn rfc6532() {
2793        let message = concat!(
2794            "From: Keith Moore <moore@cs.utk.edu>\n",
2795            "To: Keld Jørn Simonsen <keld@dkuug.dk>\n",
2796            "CC: André Pirard <PIRARD@vm1.ulg.ac.be>\n",
2797            "Subject: Hello André\n",
2798            "\n\n"
2799        );
2800        let msg = MimePart::parse(message).unwrap();
2801        let list = match msg.headers().from() {
2802            Err(err) => panic!("Doh.\n{err:#}"),
2803            Ok(list) => list,
2804        };
2805        k9::snapshot!(
2806            list,
2807            r#"
2808Some(
2809    MailboxList(
2810        [
2811            Mailbox {
2812                name: Some(
2813                    "Keith Moore",
2814                ),
2815                address: AddrSpec {
2816                    local_part: "moore",
2817                    domain: "cs.utk.edu",
2818                },
2819            },
2820        ],
2821    ),
2822)
2823"#
2824        );
2825
2826        let list = match msg.headers().to() {
2827            Err(err) => panic!("Doh.\n{err:#}"),
2828            Ok(list) => list,
2829        };
2830        k9::snapshot!(
2831            list,
2832            r#"
2833Some(
2834    AddressList(
2835        [
2836            Mailbox(
2837                Mailbox {
2838                    name: Some(
2839                        "Keld Jørn Simonsen",
2840                    ),
2841                    address: AddrSpec {
2842                        local_part: "keld",
2843                        domain: "dkuug.dk",
2844                    },
2845                },
2846            ),
2847        ],
2848    ),
2849)
2850"#
2851        );
2852
2853        let list = match msg.headers().cc() {
2854            Err(err) => panic!("Doh.\n{err:#}"),
2855            Ok(list) => list,
2856        };
2857        k9::snapshot!(
2858            list,
2859            r#"
2860Some(
2861    AddressList(
2862        [
2863            Mailbox(
2864                Mailbox {
2865                    name: Some(
2866                        "André Pirard",
2867                    ),
2868                    address: AddrSpec {
2869                        local_part: "PIRARD",
2870                        domain: "vm1.ulg.ac.be",
2871                    },
2872                },
2873            ),
2874        ],
2875    ),
2876)
2877"#
2878        );
2879        let list = match msg.headers().subject() {
2880            Err(err) => panic!("Doh.\n{err:#}"),
2881            Ok(list) => list,
2882        };
2883        k9::snapshot!(
2884            list,
2885            r#"
2886Some(
2887    "Hello André",
2888)
2889"#
2890        );
2891    }
2892
2893    #[test]
2894    fn unstructured_bare_non_ascii() {
2895        // Direct test of unstructured header parsing with bare UTF-8
2896        // (no encoded-word), exercising obs_utext -> utf8_non_ascii
2897        let message = "Subject: Héllo wörld äöü\n\n\n";
2898        let msg = MimePart::parse(message).unwrap();
2899        k9::snapshot!(
2900            msg.headers().subject().unwrap(),
2901            r#"
2902Some(
2903    "Héllo wörld äöü",
2904)
2905"#
2906        );
2907
2908        // Subject with CJK characters
2909        let message = "Subject: 件名テスト\n\n\n";
2910        let msg = MimePart::parse(message).unwrap();
2911        k9::snapshot!(
2912            msg.headers().subject().unwrap(),
2913            r#"
2914Some(
2915    "件名テスト",
2916)
2917"#
2918        );
2919    }
2920
2921    #[test]
2922    fn unstructured_raw_shift_jis() {
2923        // Raw Shift-JIS bytes in a Subject header (not wrapped in an
2924        // RFC 2047 encoded-word). "テスト" in Shift-JIS is:
2925        //   テ=0x83 0x65  ス=0x83 0x58  ト=0x83 0x67
2926        // These bytes are not valid UTF-8 (0x83 is a continuation byte
2927        // appearing as a lead byte). With utf8_non_ascii validation,
2928        // the parser will not match them as non-ASCII text.
2929        let message = b"Subject: \x83\x65\x83\x58\x83\x67\n\n\n";
2930
2931        // Structural parse succeeds: the message is split into headers
2932        // and body, and the Subject header is recognized.
2933        let msg = MimePart::parse(message.as_slice()).unwrap();
2934        let subject_header = msg.headers().get_first("Subject").unwrap();
2935        k9::assert_equal!(
2936            subject_header.get_raw_value(),
2937            b"\x83\x65\x83\x58\x83\x67".as_slice()
2938        );
2939
2940        // Semantic parse of the value as unstructured text fails because
2941        // the raw bytes are not valid UTF-8.
2942        k9::snapshot!(
2943            msg.headers().subject(),
2944            r#"
2945Err(
2946    InvalidHeaderValueDuringGet {
2947        header_name: "Subject",
2948        error: HeaderParse(
2949            "Error at line 1, in Eof:
2950\\x83e\\x83X\\x83g
2951^_____
2952
2953",
2954        ),
2955    },
2956)
2957"#
2958        );
2959    }
2960
2961    #[test]
2962    fn rfc2047_bogus() {
2963        let message = concat!(
2964            "From: =?US-OSCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2965            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2966            "CC: =?ISO-8859-1?Q?Andr=E?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2967            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?=\n",
2968            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2969            "\n\n"
2970        );
2971        let msg = MimePart::parse(message).unwrap();
2972
2973        // Invalid charset causes encoded_word to fail and we will instead match
2974        // obs_utext and return it as it was
2975        k9::assert_equal!(
2976            msg.headers().from().unwrap().unwrap().0[0]
2977                .name
2978                .as_ref()
2979                .unwrap(),
2980            "=?US-OSCII?Q?Keith_Moore?="
2981        );
2982
2983        match &msg.headers().cc().unwrap().unwrap().0[0] {
2984            Address::Mailbox(mbox) => {
2985                // 'Andr=E9?=' is in the non-bogus example below, but above we
2986                // broke it as 'Andr=E?=', and instead of triggering a qp decode
2987                // error, it is passed through here as-is
2988                k9::assert_equal!(mbox.name.as_ref().unwrap(), "Andr=E Pirard");
2989            }
2990            wat => panic!("should not have {wat:?}"),
2991        }
2992
2993        // The invalid base64 (an I was replaced by an !) is interpreted as obs_utext
2994        // and passed through to us
2995        k9::assert_equal!(
2996            msg.headers().subject().unwrap().unwrap(),
2997            "Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?= u understand the example."
2998        );
2999    }
3000
3001    #[test]
3002    fn attachment_filename_mess_totally_bogus() {
3003        let message = concat!("Content-Disposition: attachment; filename=@\n", "\n\n");
3004        let msg = MimePart::parse(message).unwrap();
3005        eprintln!("{msg:#?}");
3006
3007        assert!(msg
3008            .conformance()
3009            .contains(MessageConformance::INVALID_MIME_HEADERS));
3010        msg.headers().content_disposition().unwrap_err();
3011
3012        // There is no Content-Disposition in the rebuilt message, because
3013        // there was no valid Content-Disposition in what we parsed
3014        let rebuilt = msg.rebuild(None).unwrap();
3015        k9::assert_equal!(rebuilt.headers().content_disposition(), Ok(None));
3016    }
3017
3018    #[test]
3019    fn attachment_filename_mess_aberrant() {
3020        let message = concat!(
3021            "Content-Disposition: attachment; filename= =?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\n",
3022            "\n\n"
3023        );
3024        let msg = MimePart::parse(message).unwrap();
3025
3026        let cd = msg.headers().content_disposition().unwrap().unwrap();
3027        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
3028
3029        let encoded = cd.encode_value();
3030        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?=");
3031    }
3032
3033    #[test]
3034    fn attachment_filename_mess_gmail() {
3035        let message = concat!(
3036            "Content-Disposition: attachment; filename=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
3037            "Content-Type: text/plain;\n",
3038            "   name=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
3039            "\n\n"
3040        );
3041        let msg = MimePart::parse(message).unwrap();
3042
3043        let cd = msg.headers().content_disposition().unwrap().unwrap();
3044        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
3045        let encoded = cd.encode_value();
3046        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?=\"");
3047
3048        let ct = msg.headers().content_type().unwrap().unwrap();
3049        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付");
3050    }
3051
3052    #[test]
3053    fn attachment_filename_mess_fastmail() {
3054        let message = concat!(
3055            "Content-Disposition: attachment;\n",
3056            "  filename*0*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98;\n",
3057            "  filename*1*=.txt\n",
3058            "Content-Type: text/plain;\n",
3059            "   name=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=\"\n",
3060            "   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",
3061            "\n\n"
3062        );
3063        let msg = MimePart::parse(message).unwrap();
3064
3065        let cd = msg.headers().content_disposition().unwrap().unwrap();
3066        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付.txt");
3067
3068        let ct = msg.headers().content_type().unwrap().unwrap();
3069        eprintln!("{ct:#?}");
3070        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付.txt");
3071        k9::assert_equal!(
3072            ct.get("x-name").unwrap(),
3073            "=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=bork"
3074        );
3075    }
3076
3077    #[test]
3078    fn rfc2047() {
3079        let message = concat!(
3080            "From: =?US-ASCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
3081            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
3082            "CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
3083            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n",
3084            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
3085            "\n\n"
3086        );
3087        let msg = MimePart::parse(message).unwrap();
3088        let list = match msg.headers().from() {
3089            Err(err) => panic!("Doh.\n{err:#}"),
3090            Ok(list) => list,
3091        };
3092        k9::snapshot!(
3093            list,
3094            r#"
3095Some(
3096    MailboxList(
3097        [
3098            Mailbox {
3099                name: Some(
3100                    "Keith Moore",
3101                ),
3102                address: AddrSpec {
3103                    local_part: "moore",
3104                    domain: "cs.utk.edu",
3105                },
3106            },
3107        ],
3108    ),
3109)
3110"#
3111        );
3112
3113        let list = match msg.headers().to() {
3114            Err(err) => panic!("Doh.\n{err:#}"),
3115            Ok(list) => list,
3116        };
3117        k9::snapshot!(
3118            list,
3119            r#"
3120Some(
3121    AddressList(
3122        [
3123            Mailbox(
3124                Mailbox {
3125                    name: Some(
3126                        "Keld Jørn Simonsen",
3127                    ),
3128                    address: AddrSpec {
3129                        local_part: "keld",
3130                        domain: "dkuug.dk",
3131                    },
3132                },
3133            ),
3134        ],
3135    ),
3136)
3137"#
3138        );
3139
3140        let list = match msg.headers().cc() {
3141            Err(err) => panic!("Doh.\n{err:#}"),
3142            Ok(list) => list,
3143        };
3144        k9::snapshot!(
3145            list,
3146            r#"
3147Some(
3148    AddressList(
3149        [
3150            Mailbox(
3151                Mailbox {
3152                    name: Some(
3153                        "André Pirard",
3154                    ),
3155                    address: AddrSpec {
3156                        local_part: "PIRARD",
3157                        domain: "vm1.ulg.ac.be",
3158                    },
3159                },
3160            ),
3161        ],
3162    ),
3163)
3164"#
3165        );
3166        let list = match msg.headers().subject() {
3167            Err(err) => panic!("Doh.\n{err:#}"),
3168            Ok(list) => list,
3169        };
3170        k9::snapshot!(
3171            list,
3172            r#"
3173Some(
3174    "Hello If you can read this you understand the example.",
3175)
3176"#
3177        );
3178
3179        k9::snapshot!(
3180            BString::from(msg.rebuild(None).unwrap().to_message_bytes()),
3181            r#"
3182Content-Type: text/plain;\r
3183\tcharset="us-ascii"\r
3184Content-Transfer-Encoding: quoted-printable\r
3185From: "Keith Moore" <moore@cs.utk.edu>\r
3186To: =?UTF-8?q?Keld_J=C3=B8rn_Simonsen?= <keld@dkuug.dk>\r
3187Cc: =?UTF-8?q?Andr=C3=A9_Pirard?= <PIRARD@vm1.ulg.ac.be>\r
3188Subject: Hello If you can read this you understand the example.\r
3189\r
3190=0A\r
3191
3192"#
3193        );
3194    }
3195
3196    #[test]
3197    fn group_addresses() {
3198        let message = concat!(
3199            "To: A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;\n",
3200            "Cc: Undisclosed recipients:;\n",
3201            "\n\n\n"
3202        );
3203        let msg = MimePart::parse(message).unwrap();
3204        let list = match msg.headers().to() {
3205            Err(err) => panic!("Doh.\n{err:#}"),
3206            Ok(list) => list.unwrap(),
3207        };
3208
3209        k9::snapshot!(
3210            list.encode_value(),
3211            r#"
3212A Group:"Ed Jones" <c@a.test>,\r
3213\t<joe@where.test>,\r
3214\tJohn <jdoe@one.test>;
3215"#
3216        );
3217
3218        let round_trip = Header::new("To", list.clone());
3219        k9::assert_equal!(list, round_trip.as_address_list().unwrap());
3220
3221        k9::snapshot!(
3222            list,
3223            r#"
3224AddressList(
3225    [
3226        Group {
3227            name: "A Group",
3228            entries: MailboxList(
3229                [
3230                    Mailbox {
3231                        name: Some(
3232                            "Ed Jones",
3233                        ),
3234                        address: AddrSpec {
3235                            local_part: "c",
3236                            domain: "a.test",
3237                        },
3238                    },
3239                    Mailbox {
3240                        name: None,
3241                        address: AddrSpec {
3242                            local_part: "joe",
3243                            domain: "where.test",
3244                        },
3245                    },
3246                    Mailbox {
3247                        name: Some(
3248                            "John",
3249                        ),
3250                        address: AddrSpec {
3251                            local_part: "jdoe",
3252                            domain: "one.test",
3253                        },
3254                    },
3255                ],
3256            ),
3257        },
3258    ],
3259)
3260"#
3261        );
3262
3263        let list = match msg.headers().cc() {
3264            Err(err) => panic!("Doh.\n{err:#}"),
3265            Ok(list) => list,
3266        };
3267        k9::snapshot!(
3268            list,
3269            r#"
3270Some(
3271    AddressList(
3272        [
3273            Group {
3274                name: "Undisclosed recipients",
3275                entries: MailboxList(
3276                    [],
3277                ),
3278            },
3279        ],
3280    ),
3281)
3282"#
3283        );
3284    }
3285
3286    #[test]
3287    fn message_id() {
3288        let message = concat!(
3289            "Message-Id: <foo@example.com>\n",
3290            "References: <a@example.com> <b@example.com>\n",
3291            "  <\"legacy\"@example.com>\n",
3292            "  <literal@[127.0.0.1]>\n",
3293            "\n\n\n"
3294        );
3295        let msg = MimePart::parse(message).unwrap();
3296        let list = match msg.headers().message_id() {
3297            Err(err) => panic!("Doh.\n{err:#}"),
3298            Ok(list) => list,
3299        };
3300        k9::snapshot!(
3301            list,
3302            r#"
3303Some(
3304    MessageID(
3305        "foo@example.com",
3306    ),
3307)
3308"#
3309        );
3310
3311        let list = match msg.headers().references() {
3312            Err(err) => panic!("Doh.\n{err:#}"),
3313            Ok(list) => list,
3314        };
3315        k9::snapshot!(
3316            list,
3317            r#"
3318Some(
3319    [
3320        MessageID(
3321            "a@example.com",
3322        ),
3323        MessageID(
3324            "b@example.com",
3325        ),
3326        MessageID(
3327            "legacy@example.com",
3328        ),
3329        MessageID(
3330            "literal@[127.0.0.1]",
3331        ),
3332    ],
3333)
3334"#
3335        );
3336    }
3337
3338    #[test]
3339    fn content_type() {
3340        let message = "Content-Type: text/plain\n\n\n\n";
3341        let msg = MimePart::parse(message).unwrap();
3342        let params = match msg.headers().content_type() {
3343            Err(err) => panic!("Doh.\n{err:#}"),
3344            Ok(params) => params,
3345        };
3346        k9::snapshot!(
3347            params,
3348            r#"
3349Some(
3350    MimeParameters {
3351        value: "text/plain",
3352        parameters: [],
3353    },
3354)
3355"#
3356        );
3357
3358        let message = "Content-Type: text/plain; charset=us-ascii\n\n\n\n";
3359        let msg = MimePart::parse(message).unwrap();
3360        let params = match msg.headers().content_type() {
3361            Err(err) => panic!("Doh.\n{err:#}"),
3362            Ok(params) => params.unwrap(),
3363        };
3364
3365        k9::snapshot!(
3366            params.get("charset"),
3367            r#"
3368Some(
3369    "us-ascii",
3370)
3371"#
3372        );
3373        k9::snapshot!(
3374            params,
3375            r#"
3376MimeParameters {
3377    value: "text/plain",
3378    parameters: [
3379        MimeParameter {
3380            name: "charset",
3381            section: None,
3382            mime_charset: None,
3383            mime_language: None,
3384            encoding: None,
3385            value: "us-ascii",
3386        },
3387    ],
3388}
3389"#
3390        );
3391
3392        let message = "Content-Type: text/plain; charset=\"us-ascii\"\n\n\n\n";
3393        let msg = MimePart::parse(message).unwrap();
3394        let params = match msg.headers().content_type() {
3395            Err(err) => panic!("Doh.\n{err:#}"),
3396            Ok(params) => params,
3397        };
3398        k9::snapshot!(
3399            params,
3400            r#"
3401Some(
3402    MimeParameters {
3403        value: "text/plain",
3404        parameters: [
3405            MimeParameter {
3406                name: "charset",
3407                section: None,
3408                mime_charset: None,
3409                mime_language: None,
3410                encoding: None,
3411                value: "us-ascii",
3412            },
3413        ],
3414    },
3415)
3416"#
3417        );
3418    }
3419
3420    #[test]
3421    fn content_type_rfc2231() {
3422        // This example is taken from the errata for rfc2231.
3423        // <https://www.rfc-editor.org/errata/eid590>
3424        let message = concat!(
3425            "Content-Type: application/x-stuff;\n",
3426            "\ttitle*0*=us-ascii'en'This%20is%20even%20more%20;\n",
3427            "\ttitle*1*=%2A%2A%2Afun%2A%2A%2A%20;\n",
3428            "\ttitle*2=\"isn't it!\"\n",
3429            "\n\n\n"
3430        );
3431        let msg = MimePart::parse(message).unwrap();
3432        let mut params = match msg.headers().content_type() {
3433            Err(err) => panic!("Doh.\n{err:#}"),
3434            Ok(params) => params.unwrap(),
3435        };
3436
3437        let original_title = params.get("title");
3438        k9::snapshot!(
3439            &original_title,
3440            r#"
3441Some(
3442    "This is even more ***fun*** isn't it!",
3443)
3444"#
3445        );
3446
3447        k9::snapshot!(
3448            &params,
3449            r#"
3450MimeParameters {
3451    value: "application/x-stuff",
3452    parameters: [
3453        MimeParameter {
3454            name: "title",
3455            section: Some(
3456                0,
3457            ),
3458            mime_charset: Some(
3459                "us-ascii",
3460            ),
3461            mime_language: Some(
3462                "en",
3463            ),
3464            encoding: Rfc2231,
3465            value: "This%20is%20even%20more%20",
3466        },
3467        MimeParameter {
3468            name: "title",
3469            section: Some(
3470                1,
3471            ),
3472            mime_charset: None,
3473            mime_language: None,
3474            encoding: Rfc2231,
3475            value: "%2A%2A%2Afun%2A%2A%2A%20",
3476        },
3477        MimeParameter {
3478            name: "title",
3479            section: Some(
3480                2,
3481            ),
3482            mime_charset: None,
3483            mime_language: None,
3484            encoding: None,
3485            value: "isn't it!",
3486        },
3487    ],
3488}
3489"#
3490        );
3491
3492        k9::snapshot!(
3493            params.encode_value(),
3494            r#"
3495application/x-stuff;\r
3496\ttitle="This is even more ***fun*** isn't it!"
3497"#
3498        );
3499
3500        params.set("foo", "bar 💩");
3501
3502        params.set(
3503            "long",
3504            "this is some text that should wrap because \
3505                it should be a good bit longer than our target maximum \
3506                length for this sort of thing, and hopefully we see at \
3507                least three lines produced as a result of setting \
3508                this value in this way",
3509        );
3510
3511        params.set(
3512            "longernnamethananyoneshouldreallyuse",
3513            "this is some text that should wrap because \
3514                it should be a good bit longer than our target maximum \
3515                length for this sort of thing, and hopefully we see at \
3516                least three lines produced as a result of setting \
3517                this value in this way",
3518        );
3519
3520        k9::snapshot!(
3521            params.encode_value(),
3522            r#"
3523application/x-stuff;\r
3524\tfoo*=UTF-8''bar%20%F0%9F%92%A9;\r
3525\tlong*0="this is some text that should wrap because it should be a good bi";\r
3526\tlong*1="t longer than our target maximum length for this sort of thing, a";\r
3527\tlong*2="nd hopefully we see at least three lines produced as a result of ";\r
3528\tlong*3="setting this value in this way";\r
3529\tlongernnamethananyoneshouldreallyuse*0="this is some text that should wra";\r
3530\tlongernnamethananyoneshouldreallyuse*1="p because it should be a good bit";\r
3531\tlongernnamethananyoneshouldreallyuse*2=" longer than our target maximum l";\r
3532\tlongernnamethananyoneshouldreallyuse*3="ength for this sort of thing, and";\r
3533\tlongernnamethananyoneshouldreallyuse*4=" hopefully we see at least three ";\r
3534\tlongernnamethananyoneshouldreallyuse*5="lines produced as a result of set";\r
3535\tlongernnamethananyoneshouldreallyuse*6="ting this value in this way";\r
3536\ttitle="This is even more ***fun*** isn't it!"
3537"#
3538        );
3539    }
3540
3541    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.2>
3542    #[test]
3543    fn authentication_results_b_2() {
3544        let ar = Header::with_name_value("Authentication-Results", "example.org 1; none");
3545        let ar = ar.as_authentication_results().unwrap();
3546        k9::snapshot!(
3547            &ar,
3548            r#"
3549AuthenticationResults {
3550    serv_id: "example.org",
3551    version: Some(
3552        1,
3553    ),
3554    results: [],
3555}
3556"#
3557        );
3558
3559        k9::snapshot!(ar.encode_value(), "example.org 1; none");
3560    }
3561
3562    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.3>
3563    #[test]
3564    fn authentication_results_b_3() {
3565        let ar = Header::with_name_value(
3566            "Authentication-Results",
3567            "example.com; spf=pass smtp.mailfrom=example.net",
3568        );
3569        k9::snapshot!(
3570            ar.as_authentication_results(),
3571            r#"
3572Ok(
3573    AuthenticationResults {
3574        serv_id: "example.com",
3575        version: None,
3576        results: [
3577            AuthenticationResult {
3578                method: "spf",
3579                method_version: None,
3580                result: "pass",
3581                reason: None,
3582                props: {
3583                    "smtp.mailfrom": "example.net",
3584                },
3585            },
3586        ],
3587    },
3588)
3589"#
3590        );
3591    }
3592
3593    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.4>
3594    #[test]
3595    fn authentication_results_b_4() {
3596        let ar = Header::with_name_value(
3597            "Authentication-Results",
3598            concat!(
3599                "example.com;\n",
3600                "\tauth=pass (cram-md5) smtp.auth=sender@example.net;\n",
3601                "\tspf=pass smtp.mailfrom=example.net"
3602            ),
3603        );
3604        k9::snapshot!(
3605            ar.as_authentication_results(),
3606            r#"
3607Ok(
3608    AuthenticationResults {
3609        serv_id: "example.com",
3610        version: None,
3611        results: [
3612            AuthenticationResult {
3613                method: "auth",
3614                method_version: None,
3615                result: "pass",
3616                reason: None,
3617                props: {
3618                    "smtp.auth": "sender@example.net",
3619                },
3620            },
3621            AuthenticationResult {
3622                method: "spf",
3623                method_version: None,
3624                result: "pass",
3625                reason: None,
3626                props: {
3627                    "smtp.mailfrom": "example.net",
3628                },
3629            },
3630        ],
3631    },
3632)
3633"#
3634        );
3635
3636        let ar = Header::with_name_value(
3637            "Authentication-Results",
3638            "example.com; iprev=pass\n\tpolicy.iprev=192.0.2.200",
3639        );
3640        k9::snapshot!(
3641            ar.as_authentication_results(),
3642            r#"
3643Ok(
3644    AuthenticationResults {
3645        serv_id: "example.com",
3646        version: None,
3647        results: [
3648            AuthenticationResult {
3649                method: "iprev",
3650                method_version: None,
3651                result: "pass",
3652                reason: None,
3653                props: {
3654                    "policy.iprev": "192.0.2.200",
3655                },
3656            },
3657        ],
3658    },
3659)
3660"#
3661        );
3662    }
3663
3664    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.5>
3665    #[test]
3666    fn authentication_results_b_5() {
3667        let ar = Header::with_name_value(
3668            "Authentication-Results",
3669            "example.com;\n\tdkim=pass (good signature) header.d=example.com",
3670        );
3671        k9::snapshot!(
3672            ar.as_authentication_results(),
3673            r#"
3674Ok(
3675    AuthenticationResults {
3676        serv_id: "example.com",
3677        version: None,
3678        results: [
3679            AuthenticationResult {
3680                method: "dkim",
3681                method_version: None,
3682                result: "pass",
3683                reason: None,
3684                props: {
3685                    "header.d": "example.com",
3686                },
3687            },
3688        ],
3689    },
3690)
3691"#
3692        );
3693
3694        let ar = Header::with_name_value(
3695            "Authentication-Results",
3696            "example.com;\n\tauth=pass (cram-md5) smtp.auth=sender@example.com;\n\tspf=fail smtp.mailfrom=example.com"
3697        );
3698        let ar = ar.as_authentication_results().unwrap();
3699        k9::snapshot!(
3700            &ar,
3701            r#"
3702AuthenticationResults {
3703    serv_id: "example.com",
3704    version: None,
3705    results: [
3706        AuthenticationResult {
3707            method: "auth",
3708            method_version: None,
3709            result: "pass",
3710            reason: None,
3711            props: {
3712                "smtp.auth": "sender@example.com",
3713            },
3714        },
3715        AuthenticationResult {
3716            method: "spf",
3717            method_version: None,
3718            result: "fail",
3719            reason: None,
3720            props: {
3721                "smtp.mailfrom": "example.com",
3722            },
3723        },
3724    ],
3725}
3726"#
3727        );
3728
3729        k9::snapshot!(
3730            ar.encode_value(),
3731            r#"
3732example.com;\r
3733\tauth=pass\r
3734\tsmtp.auth=sender@example.com;\r
3735\tspf=fail\r
3736\tsmtp.mailfrom=example.com
3737"#
3738        );
3739    }
3740
3741    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.6>
3742    #[test]
3743    fn authentication_results_b_6() {
3744        let ar = Header::with_name_value(
3745            "Authentication-Results",
3746            concat!(
3747                "example.com;\n",
3748                "\tdkim=pass reason=\"good signature\"\n",
3749                "\theader.i=@mail-router.example.net;\n",
3750                "\tdkim=fail reason=\"bad signature\"\n",
3751                "\theader.i=@newyork.example.com"
3752            ),
3753        );
3754        let ar = match ar.as_authentication_results() {
3755            Err(err) => panic!("\n{err}"),
3756            Ok(ar) => ar,
3757        };
3758
3759        k9::snapshot!(
3760            &ar,
3761            r#"
3762AuthenticationResults {
3763    serv_id: "example.com",
3764    version: None,
3765    results: [
3766        AuthenticationResult {
3767            method: "dkim",
3768            method_version: None,
3769            result: "pass",
3770            reason: Some(
3771                "good signature",
3772            ),
3773            props: {
3774                "header.i": "@mail-router.example.net",
3775            },
3776        },
3777        AuthenticationResult {
3778            method: "dkim",
3779            method_version: None,
3780            result: "fail",
3781            reason: Some(
3782                "bad signature",
3783            ),
3784            props: {
3785                "header.i": "@newyork.example.com",
3786            },
3787        },
3788    ],
3789}
3790"#
3791        );
3792
3793        k9::snapshot!(
3794            ar.encode_value(),
3795            r#"
3796example.com;\r
3797\tdkim=pass reason="good signature"\r
3798\theader.i=@mail-router.example.net;\r
3799\tdkim=fail reason="bad signature"\r
3800\theader.i=@newyork.example.com
3801"#
3802        );
3803
3804        let ar = Header::with_name_value(
3805            "Authentication-Results",
3806            concat!(
3807                "example.net;\n",
3808                "\tdkim=pass (good signature) header.i=@newyork.example.com"
3809            ),
3810        );
3811        let ar = match ar.as_authentication_results() {
3812            Err(err) => panic!("\n{err}"),
3813            Ok(ar) => ar,
3814        };
3815
3816        k9::snapshot!(
3817            &ar,
3818            r#"
3819AuthenticationResults {
3820    serv_id: "example.net",
3821    version: None,
3822    results: [
3823        AuthenticationResult {
3824            method: "dkim",
3825            method_version: None,
3826            result: "pass",
3827            reason: None,
3828            props: {
3829                "header.i": "@newyork.example.com",
3830            },
3831        },
3832    ],
3833}
3834"#
3835        );
3836
3837        k9::snapshot!(
3838            ar.encode_value(),
3839            r#"
3840example.net;\r
3841\tdkim=pass\r
3842\theader.i=@newyork.example.com
3843"#
3844        );
3845    }
3846
3847    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.7>
3848    #[test]
3849    fn authentication_results_b_7() {
3850        let ar = Header::with_name_value(
3851            "Authentication-Results",
3852            concat!(
3853                "foo.example.net (foobar) 1 (baz);\n",
3854                "\tdkim (Because I like it) / 1 (One yay) = (wait for it) fail\n",
3855                "\tpolicy (A dot can go here) . (like that) expired\n",
3856                "\t(this surprised me) = (as I wasn't expecting it) 1362471462"
3857            ),
3858        );
3859        let ar = match ar.as_authentication_results() {
3860            Err(err) => panic!("\n{err}"),
3861            Ok(ar) => ar,
3862        };
3863
3864        k9::snapshot!(
3865            &ar,
3866            r#"
3867AuthenticationResults {
3868    serv_id: "foo.example.net",
3869    version: Some(
3870        1,
3871    ),
3872    results: [
3873        AuthenticationResult {
3874            method: "dkim",
3875            method_version: Some(
3876                1,
3877            ),
3878            result: "fail",
3879            reason: None,
3880            props: {
3881                "policy.expired": "1362471462",
3882            },
3883        },
3884    ],
3885}
3886"#
3887        );
3888
3889        k9::snapshot!(
3890            ar.encode_value(),
3891            r#"
3892foo.example.net 1;\r
3893\tdkim/1=fail\r
3894\tpolicy.expired=1362471462
3895"#
3896        );
3897    }
3898
3899    #[test]
3900    fn arc_authentication_results_1() {
3901        let ar = Header::with_name_value(
3902            "ARC-Authentication-Results",
3903            "i=3; clochette.example.org; spf=fail
3904    smtp.from=jqd@d1.example; dkim=fail (512-bit key)
3905    header.i=@d1.example; dmarc=fail; arc=pass (as.2.gmail.example=pass,
3906    ams.2.gmail.example=pass, as.1.lists.example.org=pass,
3907    ams.1.lists.example.org=fail (message has been altered))",
3908        );
3909        let ar = match ar.as_arc_authentication_results() {
3910            Err(err) => panic!("\n{err}"),
3911            Ok(ar) => ar,
3912        };
3913
3914        k9::snapshot!(
3915            &ar,
3916            r#"
3917ARCAuthenticationResults {
3918    instance: 3,
3919    serv_id: "clochette.example.org",
3920    version: None,
3921    results: [
3922        AuthenticationResult {
3923            method: "spf",
3924            method_version: None,
3925            result: "fail",
3926            reason: None,
3927            props: {
3928                "smtp.from": "jqd@d1.example",
3929            },
3930        },
3931        AuthenticationResult {
3932            method: "dkim",
3933            method_version: None,
3934            result: "fail",
3935            reason: None,
3936            props: {
3937                "header.i": "@d1.example",
3938            },
3939        },
3940        AuthenticationResult {
3941            method: "dmarc",
3942            method_version: None,
3943            result: "fail",
3944            reason: None,
3945            props: {},
3946        },
3947        AuthenticationResult {
3948            method: "arc",
3949            method_version: None,
3950            result: "pass",
3951            reason: None,
3952            props: {},
3953        },
3954    ],
3955}
3956"#
3957        );
3958    }
3959
3960    #[test]
3961    fn bstring_utf8_serializes_utf8_as_string() {
3962        // A MessageID with pure ASCII content serializes as a JSON string
3963        let mid = MessageID(BString::from("abc123@example.com"));
3964        let json = serde_json::to_string(&mid).unwrap();
3965        k9::assert_equal!(json, r#""abc123@example.com""#);
3966    }
3967
3968    #[test]
3969    fn bstring_utf8_serializes_non_utf8_as_array() {
3970        // A MessageID with invalid UTF-8 falls back to byte array
3971        let mid = MessageID(BString::from(&b"hello\x80world"[..]));
3972        let json = serde_json::to_string(&mid).unwrap();
3973        k9::assert_equal!(json, "[104,101,108,108,111,128,119,111,114,108,100]");
3974    }
3975
3976    #[test]
3977    fn bstring_utf8_round_trip_utf8() {
3978        let mid = MessageID(BString::from("test@example.com"));
3979        let json = serde_json::to_string(&mid).unwrap();
3980        let restored: MessageID = serde_json::from_str(&json).unwrap();
3981        k9::assert_equal!(restored, mid);
3982    }
3983
3984    #[test]
3985    fn bstring_utf8_round_trip_non_utf8() {
3986        let mid = MessageID(BString::from(&b"\xff\xfe"[..]));
3987        let json = serde_json::to_string(&mid).unwrap();
3988        let restored: MessageID = serde_json::from_str(&json).unwrap();
3989        k9::assert_equal!(restored, mid);
3990    }
3991
3992    #[test]
3993    fn authentication_results_serialize_as_strings() {
3994        let ar = AuthenticationResults {
3995            serv_id: BString::from("example.com"),
3996            version: None,
3997            results: vec![AuthenticationResult {
3998                method: "dkim".into(),
3999                method_version: None,
4000                result: "pass".into(),
4001                reason: Some(BString::from("good signature")),
4002                props: BTreeMap::from([
4003                    ("header.d".into(), BString::from("example.com")),
4004                    ("header.s".into(), BString::from("selector1")),
4005                ]),
4006            }],
4007        };
4008        let json = serde_json::to_string_pretty(&ar).unwrap();
4009        // All BString fields that are valid UTF-8 should appear as JSON strings
4010        k9::assert_equal!(
4011            json,
4012            r#"{
4013  "serv_id": "example.com",
4014  "version": null,
4015  "results": [
4016    {
4017      "method": "dkim",
4018      "method_version": null,
4019      "result": "pass",
4020      "reason": "good signature",
4021      "props": {
4022        "header.d": "example.com",
4023        "header.s": "selector1"
4024      }
4025    }
4026  ]
4027}"#
4028        );
4029    }
4030
4031    #[test]
4032    fn authentication_results_round_trip() {
4033        let ar = AuthenticationResults {
4034            serv_id: BString::from("mx.example.org"),
4035            version: Some(1),
4036            results: vec![AuthenticationResult {
4037                method: "spf".into(),
4038                method_version: None,
4039                result: "pass".into(),
4040                reason: None,
4041                props: BTreeMap::from([(
4042                    "smtp.mailfrom".into(),
4043                    BString::from("sender@example.com"),
4044                )]),
4045            }],
4046        };
4047        let json = serde_json::to_string(&ar).unwrap();
4048        let restored: AuthenticationResults = serde_json::from_str(&json).unwrap();
4049        k9::assert_equal!(restored, ar);
4050    }
4051
4052    #[test]
4053    fn authentication_result_non_utf8_reason() {
4054        let ar = AuthenticationResult {
4055            method: "dkim".into(),
4056            method_version: None,
4057            result: "temperror".into(),
4058            reason: Some(BString::from(&b"bad\x80data"[..])),
4059            props: BTreeMap::new(),
4060        };
4061        let json = serde_json::to_string(&ar).unwrap();
4062        // reason should be a byte array since it contains invalid UTF-8
4063        assert!(json.contains(r#""reason":[98,97,100,128,100,97,116,97]"#));
4064        let restored: AuthenticationResult = serde_json::from_str(&json).unwrap();
4065        k9::assert_equal!(restored, ar);
4066    }
4067
4068    #[test]
4069    fn authentication_results_encode_value_with_binary() {
4070        // Construct AuthenticationResults with non-UTF-8 bytes in BString fields
4071        // and capture the encode_value() output for use in a Lua test.
4072        let ar = AuthenticationResults {
4073            serv_id: BString::from(&b"mx.ex\x80mple.com"[..]),
4074            version: None,
4075            results: vec![AuthenticationResult {
4076                method: "spf".into(),
4077                method_version: None,
4078                result: "pass".into(),
4079                reason: Some(BString::from(&b"good\xffsig"[..])),
4080                props: BTreeMap::from([(
4081                    "smtp.mailfrom".into(),
4082                    BString::from(&b"user@\xfehost"[..]),
4083                )]),
4084            }],
4085        };
4086        let encoded = ar.encode_value();
4087        k9::snapshot!(
4088            encoded,
4089            r#"
4090"mx.ex\x80mple.com";\r
4091\tspf=pass reason="good\xffsig"\r
4092\tsmtp.mailfrom="user@\xfehost"
4093"#
4094        );
4095    }
4096
4097    #[test]
4098    fn authentication_results_serv_id_quoting() {
4099        // A serv_id containing characters that need quoting is properly quoted
4100        let ar = AuthenticationResults {
4101            serv_id: BString::from("mx example.com"),
4102            version: None,
4103            results: vec![],
4104        };
4105        let encoded = ar.encode_value();
4106        k9::snapshot!(encoded, r#""mx example.com"; none"#);
4107
4108        // Normal domain-like serv_id is emitted bare
4109        let ar2 = AuthenticationResults {
4110            serv_id: BString::from("mx.example.com"),
4111            version: Some(1),
4112            results: vec![],
4113        };
4114        let encoded2 = ar2.encode_value();
4115        k9::snapshot!(&encoded2, "mx.example.com 1; none");
4116        // Bare serv_id roundtrips
4117        let parsed = Parser::parse_authentication_results_header(encoded2.as_bytes()).unwrap();
4118        k9::assert_equal!(parsed.serv_id, ar2.serv_id);
4119        k9::assert_equal!(parsed.version, Some(1));
4120    }
4121
4122    #[test]
4123    fn arc_authentication_results_serialize_as_strings() {
4124        let arc = ARCAuthenticationResults {
4125            instance: 1,
4126            serv_id: BString::from("mx.example.com"),
4127            version: None,
4128            results: vec![],
4129        };
4130        let json = serde_json::to_string(&arc).unwrap();
4131        k9::assert_equal!(
4132            json,
4133            r#"{"instance":1,"serv_id":"mx.example.com","version":null,"results":[]}"#
4134        );
4135    }
4136}