mailparsing/
rfc5322_parser.rs

1use crate::headermap::EncodeHeaderValue;
2use crate::nom_utils::{explain_nom, make_context_error, make_span, IResult, ParseError, Span};
3use crate::{MailParsingError, Result, SharedString};
4use charset::Charset;
5use nom::branch::alt;
6use nom::bytes::complete::{tag, take_while, take_while1};
7use nom::character::complete::{char, satisfy};
8use nom::combinator::{all_consuming, map, opt, recognize};
9use nom::error::context;
10use nom::multi::{many0, many1, separated_list1};
11use nom::sequence::{delimited, preceded, separated_pair, terminated, tuple};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::fmt::Debug;
15
16impl MailParsingError {
17    pub fn from_nom(input: Span, err: nom::Err<ParseError<Span<'_>>>) -> Self {
18        MailParsingError::HeaderParse(explain_nom(input, err))
19    }
20}
21
22fn is_utf8_non_ascii(c: char) -> bool {
23    let c = c as u32;
24    c == 0 || c >= 0x80
25}
26
27// ctl = { '\u{00}'..'\u{1f}' | "\u{7f}" }
28fn is_ctl(c: char) -> bool {
29    match c {
30        '\u{00}'..='\u{1f}' | '\u{7f}' => true,
31        _ => false,
32    }
33}
34
35fn not_angle(c: char) -> bool {
36    match c {
37        '<' | '>' => false,
38        _ => true,
39    }
40}
41
42// char = { '\u{01}'..'\u{7f}' }
43fn is_char(c: char) -> bool {
44    match c {
45        '\u{01}'..='\u{ff}' => true,
46        _ => false,
47    }
48}
49
50fn is_especial(c: char) -> bool {
51    match c {
52        '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '/' | '[' | ']' | '?' | '.' | '=' => true,
53        _ => false,
54    }
55}
56
57fn is_token(c: char) -> bool {
58    is_char(c) && c != ' ' && !is_especial(c) && !is_ctl(c)
59}
60
61// vchar = { '\u{21}'..'\u{7e}' | utf8_non_ascii }
62fn is_vchar(c: char) -> bool {
63    let u = c as u32;
64    (0x21..=0x7e).contains(&u) || is_utf8_non_ascii(c)
65}
66
67fn is_atext(c: char) -> bool {
68    match c {
69        '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_'
70        | '`' | '{' | '|' | '}' | '~' => true,
71        c => c.is_ascii_alphanumeric() || is_utf8_non_ascii(c),
72    }
73}
74
75fn atext(input: Span) -> IResult<Span, Span> {
76    context("atext", take_while1(is_atext))(input)
77}
78
79fn is_obs_no_ws_ctl(c: char) -> bool {
80    match c {
81        '\u{01}'..='\u{08}' | '\u{0b}'..='\u{0c}' | '\u{0e}'..='\u{1f}' | '\u{7f}' => true,
82        _ => false,
83    }
84}
85
86fn is_obs_ctext(c: char) -> bool {
87    is_obs_no_ws_ctl(c)
88}
89
90// ctext = { '\u{21}'..'\u{27}' | '\u{2a}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | obs_ctext | utf8_non_ascii }
91fn is_ctext(c: char) -> bool {
92    match c {
93        '\u{21}'..='\u{27}' | '\u{2a}'..='\u{5b}' | '\u{5d}'..='\u{7e}' => true,
94        c => is_obs_ctext(c) || is_utf8_non_ascii(c),
95    }
96}
97
98// dtext = { '\u{21}'..'\u{5a}' | '\u{5e}'..'\u{7e}' | obs_dtext | utf8_non_ascii }
99// obs_dtext = { obs_no_ws_ctl | quoted_pair }
100fn is_dtext(c: char) -> bool {
101    match c {
102        '\u{21}'..='\u{5a}' | '\u{5e}'..='\u{7e}' => true,
103        c => is_obs_no_ws_ctl(c) || is_utf8_non_ascii(c),
104    }
105}
106
107// qtext = { "\u{21}" | '\u{23}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | obs_qtext | utf8_non_ascii }
108// obs_qtext = { obs_no_ws_ctl }
109fn is_qtext(c: char) -> bool {
110    match c {
111        '\u{21}' | '\u{23}'..='\u{5b}' | '\u{5d}'..='\u{7e}' => true,
112        c => is_obs_no_ws_ctl(c) || is_utf8_non_ascii(c),
113    }
114}
115
116fn is_tspecial(c: char) -> bool {
117    match c {
118        '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '\\' | '"' | '/' | '[' | ']' | '?'
119        | '=' => true,
120        _ => false,
121    }
122}
123
124fn is_attribute_char(c: char) -> bool {
125    match c {
126        ' ' | '*' | '\'' | '%' => false,
127        _ => is_char(c) && !is_ctl(c) && !is_tspecial(c),
128    }
129}
130
131fn wsp(input: Span) -> IResult<Span, Span> {
132    context("wsp", take_while1(|c| c == ' ' || c == '\t'))(input)
133}
134
135fn newline(input: Span) -> IResult<Span, Span> {
136    context("newline", recognize(preceded(opt(char('\r')), char('\n'))))(input)
137}
138
139// fws = { ((wsp* ~ "\r"? ~ "\n")* ~ wsp+) | obs_fws }
140fn fws(input: Span) -> IResult<Span, Span> {
141    context(
142        "fws",
143        alt((
144            recognize(preceded(many0(preceded(many0(wsp), newline)), many1(wsp))),
145            obs_fws,
146        )),
147    )(input)
148}
149
150// obs_fws = { wsp+ ~ ("\r"? ~ "\n" ~ wsp+)* }
151fn obs_fws(input: Span) -> IResult<Span, Span> {
152    context(
153        "obs_fws",
154        recognize(preceded(many1(wsp), preceded(newline, many1(wsp)))),
155    )(input)
156}
157
158// mailbox_list = { (mailbox ~ ("," ~ mailbox)*) | obs_mbox_list }
159fn mailbox_list(input: Span) -> IResult<Span, MailboxList> {
160    let (loc, mailboxes) = context(
161        "mailbox_list",
162        alt((separated_list1(char(','), mailbox), obs_mbox_list)),
163    )(input)?;
164    Ok((loc, MailboxList(mailboxes)))
165}
166
167// obs_mbox_list = {  ((cfws? ~ ",")* ~ mailbox ~ ("," ~ (mailbox | cfws))*)+ }
168fn obs_mbox_list(input: Span) -> IResult<Span, Vec<Mailbox>> {
169    let (loc, entries) = context(
170        "obs_mbox_list",
171        many1(preceded(
172            many0(preceded(opt(cfws), char(','))),
173            tuple((
174                mailbox,
175                many0(preceded(
176                    char(','),
177                    alt((map(mailbox, Some), map(cfws, |_| None))),
178                )),
179            )),
180        )),
181    )(input)?;
182
183    let mut result: Vec<Mailbox> = vec![];
184
185    for (first, boxes) in entries {
186        result.push(first);
187        for b in boxes {
188            if let Some(m) = b {
189                result.push(m);
190            }
191        }
192    }
193
194    Ok((loc, result))
195}
196
197// mailbox = { name_addr | addr_spec }
198fn mailbox(input: Span) -> IResult<Span, Mailbox> {
199    if let Ok(res) = name_addr(input) {
200        Ok(res)
201    } else {
202        let (loc, address) = context("mailbox", addr_spec)(input)?;
203        Ok((
204            loc,
205            Mailbox {
206                name: None,
207                address,
208            },
209        ))
210    }
211}
212
213// address_list = { (address ~ ("," ~ address)*) | obs_addr_list }
214fn address_list(input: Span) -> IResult<Span, AddressList> {
215    context(
216        "address_list",
217        alt((
218            map(separated_list1(char(','), address), AddressList),
219            obs_address_list,
220        )),
221    )(input)
222}
223
224// obs_addr_list = {  ((cfws? ~ ",")* ~ address ~ ("," ~ (address | cfws))*)+ }
225fn obs_address_list(input: Span) -> IResult<Span, AddressList> {
226    let (loc, entries) = context(
227        "obs_address_list",
228        many1(preceded(
229            many0(preceded(opt(cfws), char(','))),
230            tuple((
231                address,
232                many0(preceded(
233                    char(','),
234                    alt((map(address, Some), map(cfws, |_| None))),
235                )),
236            )),
237        )),
238    )(input)?;
239
240    let mut result: Vec<Address> = vec![];
241
242    for (first, boxes) in entries {
243        result.push(first);
244        for b in boxes {
245            if let Some(m) = b {
246                result.push(m);
247            }
248        }
249    }
250
251    Ok((loc, AddressList(result)))
252}
253
254// address = { mailbox | group }
255fn address(input: Span) -> IResult<Span, Address> {
256    context("address", alt((map(mailbox, Address::Mailbox), group)))(input)
257}
258
259// group = { display_name ~ ":" ~ group_list? ~ ";" ~ cfws? }
260fn group(input: Span) -> IResult<Span, Address> {
261    let (loc, (name, _, group_list, _)) = context(
262        "group",
263        terminated(
264            tuple((display_name, char(':'), opt(group_list), char(';'))),
265            opt(cfws),
266        ),
267    )(input)?;
268    Ok((
269        loc,
270        Address::Group {
271            name,
272            entries: group_list.unwrap_or_else(|| MailboxList(vec![])),
273        },
274    ))
275}
276
277// group_list = { mailbox_list | cfws | obs_group_list }
278fn group_list(input: Span) -> IResult<Span, MailboxList> {
279    context(
280        "group_list",
281        alt((
282            mailbox_list,
283            map(cfws, |_| MailboxList(vec![])),
284            obs_group_list,
285        )),
286    )(input)
287}
288
289// obs_group_list = @{ (cfws? ~ ",")+ ~ cfws? }
290fn obs_group_list(input: Span) -> IResult<Span, MailboxList> {
291    context(
292        "obs_group_list",
293        map(
294            terminated(many1(preceded(opt(cfws), char(','))), opt(cfws)),
295            |_| MailboxList(vec![]),
296        ),
297    )(input)
298}
299
300// name_addr = { display_name? ~ angle_addr }
301fn name_addr(input: Span) -> IResult<Span, Mailbox> {
302    context(
303        "name_addr",
304        map(tuple((opt(display_name), angle_addr)), |(name, address)| {
305            Mailbox { name, address }
306        }),
307    )(input)
308}
309
310// display_name = { phrase }
311fn display_name(input: Span) -> IResult<Span, String> {
312    context("display_name", phrase)(input)
313}
314
315// phrase = { (encoded_word | word)+ | obs_phrase }
316// obs_phrase = { (encoded_word | word) ~ (encoded_word | word | dot | cfws)* }
317fn phrase(input: Span) -> IResult<Span, String> {
318    let (loc, (a, b)): (Span, (String, Vec<Option<String>>)) = context(
319        "phrase",
320        tuple((
321            alt((encoded_word, word)),
322            many0(alt((
323                map(cfws, |_| None),
324                map(encoded_word, Option::Some),
325                map(word, Option::Some),
326                map(char('.'), |dot| Some(dot.to_string())),
327            ))),
328        )),
329    )(input)?;
330    let mut result = vec![];
331    result.push(a);
332    for item in b {
333        if let Some(item) = item {
334            result.push(item);
335        }
336    }
337    let result = result.join(" ");
338    Ok((loc, result))
339}
340
341// angle_addr = { cfws? ~ "<" ~ addr_spec ~ ">" ~ cfws? | obs_angle_addr }
342fn angle_addr(input: Span) -> IResult<Span, AddrSpec> {
343    context(
344        "angle_addr",
345        alt((
346            delimited(
347                opt(cfws),
348                delimited(char('<'), addr_spec, char('>')),
349                opt(cfws),
350            ),
351            obs_angle_addr,
352        )),
353    )(input)
354}
355
356// obs_angle_addr = { cfws? ~ "<" ~ obs_route ~ addr_spec ~ ">" ~ cfws? }
357fn obs_angle_addr(input: Span) -> IResult<Span, AddrSpec> {
358    context(
359        "obs_angle_addr",
360        delimited(
361            opt(cfws),
362            delimited(char('<'), preceded(obs_route, addr_spec), char('>')),
363            opt(cfws),
364        ),
365    )(input)
366}
367
368// obs_route = { obs_domain_list ~ ":" }
369// obs_domain_list = { (cfws | ",")* ~ "@" ~ domain ~ ("," ~ cfws? ~ ("@" ~ domain)?)* }
370fn obs_route(input: Span) -> IResult<Span, Span> {
371    context(
372        "obs_route",
373        recognize(terminated(
374            tuple((
375                many0(alt((cfws, recognize(char(','))))),
376                recognize(char('@')),
377                recognize(domain),
378                many0(tuple((
379                    char(','),
380                    opt(cfws),
381                    opt(tuple((char('@'), domain))),
382                ))),
383            )),
384            char(':'),
385        )),
386    )(input)
387}
388
389// addr_spec = { local_part ~ "@" ~ domain }
390fn addr_spec(input: Span) -> IResult<Span, AddrSpec> {
391    let (loc, (local_part, domain)) =
392        context("addr_spec", separated_pair(local_part, char('@'), domain))(input)?;
393    Ok((loc, AddrSpec { local_part, domain }))
394}
395
396fn parse_with<'a, R, F>(text: &'a str, parser: F) -> Result<R>
397where
398    F: Fn(Span<'a>) -> IResult<'a, Span<'a>, R>,
399{
400    let input = make_span(text);
401    let (_, result) =
402        all_consuming(parser)(input).map_err(|err| MailParsingError::from_nom(input, err))?;
403    Ok(result)
404}
405
406#[cfg(test)]
407#[test]
408fn test_addr_spec() {
409    k9::snapshot!(
410        parse_with("darth.vader@a.galaxy.far.far.away", addr_spec),
411        r#"
412Ok(
413    AddrSpec {
414        local_part: "darth.vader",
415        domain: "a.galaxy.far.far.away",
416    },
417)
418"#
419    );
420
421    k9::snapshot!(
422        parse_with("\"darth.vader\"@a.galaxy.far.far.away", addr_spec),
423        r#"
424Ok(
425    AddrSpec {
426        local_part: "darth.vader",
427        domain: "a.galaxy.far.far.away",
428    },
429)
430"#
431    );
432
433    k9::snapshot!(
434        parse_with("\"darth\".vader@a.galaxy.far.far.away", addr_spec),
435        r#"
436Err(
437    HeaderParse(
438        "0: at line 1:
439"darth".vader@a.galaxy.far.far.away
440       ^___________________________
441expected '@', found .
442
4431: at line 1, in addr_spec:
444"darth".vader@a.galaxy.far.far.away
445^__________________________________
446
447",
448    ),
449)
450"#
451    );
452
453    k9::snapshot!(
454        parse_with("a@[127.0.0.1]", addr_spec),
455        r#"
456Ok(
457    AddrSpec {
458        local_part: "a",
459        domain: "[127.0.0.1]",
460    },
461)
462"#
463    );
464
465    k9::snapshot!(
466        parse_with("a@[IPv6::1]", addr_spec),
467        r#"
468Ok(
469    AddrSpec {
470        local_part: "a",
471        domain: "[IPv6::1]",
472    },
473)
474"#
475    );
476}
477
478// atom = { cfws? ~ atext ~ cfws? }
479fn atom(input: Span) -> IResult<Span, String> {
480    let (loc, text) = context("atom", delimited(opt(cfws), atext, opt(cfws)))(input)?;
481    Ok((loc, text.to_string()))
482}
483
484// word = { atom | quoted_string }
485fn word(input: Span) -> IResult<Span, String> {
486    context("word", alt((atom, quoted_string)))(input)
487}
488
489// obs_local_part = { word ~ (dot ~ word)* }
490fn obs_local_part(input: Span) -> IResult<Span, String> {
491    let (loc, (word, dotted_words)) = context(
492        "obs_local_part",
493        tuple((word, many0(tuple((char('.'), word))))),
494    )(input)?;
495    let mut result = String::new();
496
497    result.push_str(&word);
498    for (dot, w) in dotted_words {
499        result.push(dot);
500        result.push_str(&w);
501    }
502
503    Ok((loc, result))
504}
505
506// local_part = { dot_atom | quoted_string | obs_local_part }
507fn local_part(input: Span) -> IResult<Span, String> {
508    context("local_part", alt((dot_atom, quoted_string, obs_local_part)))(input)
509}
510
511// domain = { dot_atom | domain_literal | obs_domain }
512fn domain(input: Span) -> IResult<Span, String> {
513    context("domain", alt((dot_atom, domain_literal, obs_domain)))(input)
514}
515
516// obs_domain = { atom ~ ( dot ~ atom)* }
517fn obs_domain(input: Span) -> IResult<Span, String> {
518    let (loc, (atom, dotted_atoms)) =
519        context("obs_domain", tuple((atom, many0(tuple((char('.'), atom))))))(input)?;
520    let mut result = String::new();
521
522    result.push_str(&atom);
523    for (dot, w) in dotted_atoms {
524        result.push(dot);
525        result.push_str(&w);
526    }
527
528    Ok((loc, result))
529}
530
531// domain_literal = { cfws? ~ "[" ~ (fws? ~ dtext)* ~ fws? ~ "]" ~ cfws? }
532fn domain_literal(input: Span) -> IResult<Span, String> {
533    let (loc, (bits, trailer)) = context(
534        "domain_literal",
535        delimited(
536            opt(cfws),
537            delimited(
538                char('['),
539                tuple((
540                    many0(tuple((opt(fws), alt((satisfy(is_dtext), quoted_pair))))),
541                    opt(fws),
542                )),
543                char(']'),
544            ),
545            opt(cfws),
546        ),
547    )(input)?;
548
549    let mut result = String::new();
550    result.push('[');
551    for (a, b) in bits {
552        if let Some(a) = a {
553            result.push_str(&a);
554        }
555        result.push(b);
556    }
557    if let Some(t) = trailer {
558        result.push_str(&t);
559    }
560    result.push(']');
561    Ok((loc, result))
562}
563
564// dot_atom_text = @{ atext ~ ("." ~ atext)* }
565fn dot_atom_text(input: Span) -> IResult<Span, String> {
566    let (loc, (a, b)) = context(
567        "dot_atom_text",
568        tuple((atext, many0(preceded(char('.'), atext)))),
569    )(input)?;
570    let mut result = String::new();
571    result.push_str(&a);
572    for item in b {
573        result.push('.');
574        result.push_str(&item);
575    }
576
577    Ok((loc, result))
578}
579
580// dot_atom = { cfws? ~ dot_atom_text ~ cfws? }
581fn dot_atom(input: Span) -> IResult<Span, String> {
582    context("dot_atom", delimited(opt(cfws), dot_atom_text, opt(cfws)))(input)
583}
584
585#[cfg(test)]
586#[test]
587fn test_dot_atom() {
588    k9::snapshot!(
589        parse_with("hello", dot_atom),
590        r#"
591Ok(
592    "hello",
593)
594"#
595    );
596
597    k9::snapshot!(
598        parse_with("hello.there", dot_atom),
599        r#"
600Ok(
601    "hello.there",
602)
603"#
604    );
605
606    k9::snapshot!(
607        parse_with("hello.", dot_atom),
608        r#"
609Err(
610    HeaderParse(
611        "0: at line 1, in Eof:
612hello.
613     ^
614
615",
616    ),
617)
618"#
619    );
620
621    k9::snapshot!(
622        parse_with("(wat)hello", dot_atom),
623        r#"
624Ok(
625    "hello",
626)
627"#
628    );
629}
630
631// cfws = { ( (fws? ~ comment)+ ~ fws?) | fws }
632fn cfws(input: Span) -> IResult<Span, Span> {
633    context(
634        "cfws",
635        recognize(alt((
636            recognize(tuple((many1(tuple((opt(fws), comment))), opt(fws)))),
637            fws,
638        ))),
639    )(input)
640}
641
642// comment = { "(" ~ (fws? ~ ccontent)* ~ fws? ~ ")" }
643fn comment(input: Span) -> IResult<Span, Span> {
644    context(
645        "comment",
646        recognize(tuple((
647            char('('),
648            many0(tuple((opt(fws), ccontent))),
649            opt(fws),
650            char(')'),
651        ))),
652    )(input)
653}
654
655#[cfg(test)]
656#[test]
657fn test_comment() {
658    k9::snapshot!(
659        parse_with("(wat)", comment),
660        r#"
661Ok(
662    LocatedSpan {
663        offset: 0,
664        line: 1,
665        fragment: "(wat)",
666        extra: (),
667    },
668)
669"#
670    );
671}
672
673// ccontent = { ctext | quoted_pair | comment | encoded_word }
674fn ccontent(input: Span) -> IResult<Span, Span> {
675    context(
676        "ccontent",
677        recognize(alt((
678            recognize(satisfy(is_ctext)),
679            recognize(quoted_pair),
680            comment,
681            recognize(encoded_word),
682        ))),
683    )(input)
684}
685
686fn is_quoted_pair(c: char) -> bool {
687    match c {
688        '\u{00}' | '\r' | '\n' | ' ' => true,
689        c => is_obs_no_ws_ctl(c) || is_vchar(c),
690    }
691}
692
693// quoted_pair = { ( "\\"  ~ (vchar | wsp)) | obs_qp }
694// obs_qp = { "\\" ~ ( "\u{00}" | obs_no_ws_ctl | "\r" | "\n") }
695fn quoted_pair(input: Span) -> IResult<Span, char> {
696    context("quoted_pair", preceded(char('\\'), satisfy(is_quoted_pair)))(input)
697}
698
699// encoded_word = { "=?" ~ charset ~ ("*" ~ language)? ~ "?" ~ encoding ~ "?" ~ encoded_text ~ "?=" }
700fn encoded_word(input: Span) -> IResult<Span, String> {
701    let (loc, (charset, _language, _, encoding, _, text)) = context(
702        "encoded_word",
703        delimited(
704            tag("=?"),
705            tuple((
706                charset,
707                opt(preceded(char('*'), language)),
708                char('?'),
709                encoding,
710                char('?'),
711                encoded_text,
712            )),
713            tag("?="),
714        ),
715    )(input)?;
716
717    let bytes = match *encoding.fragment() {
718        "B" | "b" => data_encoding::BASE64_MIME
719            .decode(text.as_bytes())
720            .map_err(|err| {
721                make_context_error(
722                    input,
723                    format!("encoded_word: base64 decode failed: {err:#}"),
724                )
725            })?,
726        "Q" | "q" => {
727            // for rfc2047 header encoding, _ can be used to represent a space
728            let munged = text.replace("_", " ");
729            // The quoted_printable crate will unhelpfully strip trailing space
730            // from the decoded input string, and we must track and restore it
731            let had_trailing_space = munged.ends_with(' ');
732            let mut decoded = quoted_printable::decode(munged, quoted_printable::ParseMode::Robust)
733                .map_err(|err| {
734                    make_context_error(
735                        input,
736                        format!("encoded_word: quoted printable decode failed: {err:#}"),
737                    )
738                })?;
739            if had_trailing_space && !decoded.ends_with(b" ") {
740                decoded.push(b' ');
741            }
742            decoded
743        }
744        encoding => {
745            return Err(make_context_error(
746                input,
747                format!(
748                    "encoded_word: invalid encoding '{encoding}', expected one of b, B, q or Q"
749                ),
750            ));
751        }
752    };
753
754    let charset = Charset::for_label_no_replacement(charset.as_bytes()).ok_or_else(|| {
755        make_context_error(
756            input,
757            format!("encoded_word: unsupported charset '{charset}'"),
758        )
759    })?;
760
761    let (decoded, _malformed) = charset.decode_without_bom_handling(&bytes);
762
763    Ok((loc, decoded.to_string()))
764}
765
766// charset = @{ (!"*" ~ token)+ }
767fn charset(input: Span) -> IResult<Span, Span> {
768    context("charset", take_while1(|c| c != '*' && is_token(c)))(input)
769}
770
771// language = @{ token+ }
772fn language(input: Span) -> IResult<Span, Span> {
773    context("language", take_while1(|c| c != '*' && is_token(c)))(input)
774}
775
776// encoding = @{ token+ }
777fn encoding(input: Span) -> IResult<Span, Span> {
778    context("encoding", take_while1(|c| c != '*' && is_token(c)))(input)
779}
780
781// encoded_text = @{ (!( " " | "?") ~ vchar)+ }
782fn encoded_text(input: Span) -> IResult<Span, Span> {
783    context(
784        "encoded_text",
785        take_while1(|c| is_vchar(c) && c != ' ' && c != '?'),
786    )(input)
787}
788
789// quoted_string = { cfws? ~ "\"" ~ (fws? ~ qcontent)* ~ fws? ~ "\"" ~ cfws? }
790fn quoted_string(input: Span) -> IResult<Span, String> {
791    let (loc, (bits, trailer)) = context(
792        "quoted_string",
793        delimited(
794            opt(cfws),
795            delimited(
796                char('"'),
797                tuple((many0(tuple((opt(fws), qcontent))), opt(fws))),
798                char('"'),
799            ),
800            opt(cfws),
801        ),
802    )(input)?;
803
804    let mut result = String::new();
805    for (a, b) in bits {
806        if let Some(a) = a {
807            result.push_str(&a);
808        }
809        result.push(b);
810    }
811    if let Some(t) = trailer {
812        result.push_str(&t);
813    }
814    Ok((loc, result))
815}
816
817// qcontent = { qtext | quoted_pair }
818fn qcontent(input: Span) -> IResult<Span, char> {
819    context("qcontent", alt((satisfy(is_qtext), quoted_pair)))(input)
820}
821
822fn content_id(input: Span) -> IResult<Span, MessageID> {
823    let (loc, id) = context("content_id", msg_id)(input)?;
824    Ok((loc, id))
825}
826
827fn msg_id(input: Span) -> IResult<Span, MessageID> {
828    let (loc, id) = context("msg_id", alt((strict_msg_id, relaxed_msg_id)))(input)?;
829    Ok((loc, id))
830}
831
832fn relaxed_msg_id(input: Span) -> IResult<Span, MessageID> {
833    let (loc, id) = context(
834        "msg_id",
835        delimited(
836            preceded(opt(cfws), char('<')),
837            many0(satisfy(not_angle)),
838            preceded(char('>'), opt(cfws)),
839        ),
840    )(input)?;
841
842    Ok((loc, MessageID(id.into_iter().collect())))
843}
844
845// msg_id_list = { msg_id+ }
846fn msg_id_list(input: Span) -> IResult<Span, Vec<MessageID>> {
847    context("msg_id_list", many1(msg_id))(input)
848}
849
850// id_left = { dot_atom_text | obs_id_left }
851// obs_id_left = { local_part }
852fn id_left(input: Span) -> IResult<Span, String> {
853    context("id_left", alt((dot_atom_text, local_part)))(input)
854}
855
856// id_right = { dot_atom_text | no_fold_literal | obs_id_right }
857// obs_id_right = { domain }
858fn id_right(input: Span) -> IResult<Span, String> {
859    context("id_right", alt((dot_atom_text, no_fold_literal, domain)))(input)
860}
861
862// no_fold_literal = { "[" ~ dtext* ~ "]" }
863fn no_fold_literal(input: Span) -> IResult<Span, String> {
864    context(
865        "no_fold_literal",
866        map(
867            recognize(tuple((tag("["), take_while(is_dtext), tag("]")))),
868            |s: Span| s.to_string(),
869        ),
870    )(input)
871}
872
873// msg_id = { cfws? ~ "<" ~ id_left ~ "@" ~ id_right ~ ">" ~ cfws? }
874fn strict_msg_id(input: Span) -> IResult<Span, MessageID> {
875    let (loc, (left, _, right)) = context(
876        "msg_id",
877        delimited(
878            preceded(opt(cfws), char('<')),
879            tuple((id_left, char('@'), id_right)),
880            preceded(char('>'), opt(cfws)),
881        ),
882    )(input)?;
883
884    Ok((loc, MessageID(format!("{left}@{right}"))))
885}
886
887// obs_unstruct = { (( "\r"* ~ "\n"* ~ ((encoded_word | obs_utext)~ "\r"* ~ "\n"*)+) | fws)+ }
888fn unstructured(input: Span) -> IResult<Span, String> {
889    #[derive(Debug)]
890    enum Word {
891        Encoded(String),
892        UText(char),
893        Fws,
894    }
895
896    let (loc, words) = context(
897        "unstructured",
898        many0(alt((
899            preceded(
900                map(take_while(|c| c == '\r' || c == '\n'), |_| Word::Fws),
901                terminated(
902                    alt((
903                        map(encoded_word, Word::Encoded),
904                        map(obs_utext, Word::UText),
905                    )),
906                    map(take_while(|c| c == '\r' || c == '\n'), |_| Word::Fws),
907                ),
908            ),
909            map(fws, |_| Word::Fws),
910        ))),
911    )(input)?;
912
913    #[derive(Debug)]
914    enum ProcessedWord {
915        Encoded(String),
916        Text(String),
917        Fws,
918    }
919    let mut processed = vec![];
920    for w in words {
921        match w {
922            Word::Encoded(p) => {
923                if processed.len() >= 2
924                    && matches!(processed.last(), Some(ProcessedWord::Fws))
925                    && matches!(processed[processed.len() - 2], ProcessedWord::Encoded(_))
926                {
927                    // Fws between encoded words is elided
928                    processed.pop();
929                }
930                processed.push(ProcessedWord::Encoded(p));
931            }
932            Word::Fws => {
933                // Collapse runs of Fws/newline to a single Fws
934                if !matches!(processed.last(), Some(ProcessedWord::Fws)) {
935                    processed.push(ProcessedWord::Fws);
936                }
937            }
938            Word::UText(c) => match processed.last_mut() {
939                Some(ProcessedWord::Text(prior)) => prior.push(c),
940                _ => processed.push(ProcessedWord::Text(c.to_string())),
941            },
942        }
943    }
944
945    let mut result = String::new();
946    for word in processed {
947        match word {
948            ProcessedWord::Encoded(s) | ProcessedWord::Text(s) => {
949                result.push_str(&s);
950            }
951            ProcessedWord::Fws => {
952                result.push(' ');
953            }
954        }
955    }
956
957    Ok((loc, result))
958}
959
960fn authentication_results(input: Span) -> IResult<Span, AuthenticationResults> {
961    context(
962        "authentication_results",
963        map(
964            tuple((
965                opt(cfws),
966                value,
967                opt(preceded(cfws, nom::character::complete::u32)),
968                alt((no_result, many1(resinfo))),
969                opt(cfws),
970            )),
971            |(_, serv_id, version, results, _)| AuthenticationResults {
972                serv_id,
973                version,
974                results,
975            },
976        ),
977    )(input)
978}
979
980fn no_result(input: Span) -> IResult<Span, Vec<AuthenticationResult>> {
981    context(
982        "no_result",
983        map(
984            tuple((opt(cfws), char(';'), opt(cfws), tag("none"))),
985            |_| vec![],
986        ),
987    )(input)
988}
989
990fn resinfo(input: Span) -> IResult<Span, AuthenticationResult> {
991    context(
992        "resinfo",
993        map(
994            tuple((
995                opt(cfws),
996                char(';'),
997                methodspec,
998                opt(preceded(cfws, reasonspec)),
999                opt(many1(propspec)),
1000            )),
1001            |(_, _, (method, method_version, result), reason, props)| AuthenticationResult {
1002                method,
1003                method_version,
1004                result,
1005                reason,
1006                props: match props {
1007                    None => BTreeMap::default(),
1008                    Some(props) => props.into_iter().collect(),
1009                },
1010            },
1011        ),
1012    )(input)
1013}
1014
1015fn methodspec(input: Span) -> IResult<Span, (String, Option<u32>, String)> {
1016    context(
1017        "methodspec",
1018        map(
1019            tuple((
1020                opt(cfws),
1021                tuple((keyword, opt(methodversion))),
1022                opt(cfws),
1023                char('='),
1024                opt(cfws),
1025                keyword,
1026            )),
1027            |(_, (method, methodversion), _, _, _, result)| (method, methodversion, result),
1028        ),
1029    )(input)
1030}
1031
1032// Taken from https://datatracker.ietf.org/doc/html/rfc8601 which says
1033// that this is the same as the SMTP Keyword token
1034fn keyword(input: Span) -> IResult<Span, String> {
1035    context(
1036        "keyword",
1037        map(
1038            take_while1(|c: char| c.is_ascii_alphanumeric() || c == '+'),
1039            |s: Span| s.to_string(),
1040        ),
1041    )(input)
1042}
1043
1044fn methodversion(input: Span) -> IResult<Span, u32> {
1045    context(
1046        "methodversion",
1047        preceded(
1048            tuple((opt(cfws), char('/'), opt(cfws))),
1049            nom::character::complete::u32,
1050        ),
1051    )(input)
1052}
1053
1054fn reasonspec(input: Span) -> IResult<Span, String> {
1055    context(
1056        "reason",
1057        map(
1058            tuple((tag("reason"), opt(cfws), char('='), opt(cfws), value)),
1059            |(_, _, _, _, value)| value,
1060        ),
1061    )(input)
1062}
1063
1064fn propspec(input: Span) -> IResult<Span, (String, String)> {
1065    context(
1066        "propspec",
1067        map(
1068            tuple((
1069                opt(cfws),
1070                keyword,
1071                opt(cfws),
1072                char('.'),
1073                opt(cfws),
1074                keyword,
1075                opt(cfws),
1076                char('='),
1077                opt(cfws),
1078                alt((
1079                    map(preceded(char('@'), domain), |d| format!("@{d}")),
1080                    map(separated_pair(local_part, char('@'), domain), |(u, d)| {
1081                        format!("{u}@{d}")
1082                    }),
1083                    domain,
1084                    // value must be last in this alternation
1085                    value,
1086                )),
1087                opt(cfws),
1088            )),
1089            |(_, ptype, _, _, _, property, _, _, _, value, _)| {
1090                (format!("{ptype}.{property}"), value)
1091            },
1092        ),
1093    )(input)
1094}
1095
1096// obs_utext = @{ "\u{00}" | obs_no_ws_ctl | vchar }
1097fn obs_utext(input: Span) -> IResult<Span, char> {
1098    context(
1099        "obs_utext",
1100        satisfy(|c| c == '\u{00}' || is_obs_no_ws_ctl(c) || is_vchar(c)),
1101    )(input)
1102}
1103
1104fn is_mime_token(c: char) -> bool {
1105    is_char(c) && c != ' ' && !is_ctl(c) && !is_tspecial(c)
1106}
1107
1108// mime_token = { (!(" " | ctl | tspecials) ~ char)+ }
1109fn mime_token(input: Span) -> IResult<Span, Span> {
1110    context("mime_token", take_while1(is_mime_token))(input)
1111}
1112
1113// RFC2045 modified by RFC2231 MIME header fields
1114// content_type = { cfws? ~ mime_type ~ cfws? ~ "/" ~ cfws? ~ subtype ~
1115//  cfws? ~ (";"? ~ cfws? ~ parameter ~ cfws?)*
1116// }
1117fn content_type(input: Span) -> IResult<Span, MimeParameters> {
1118    let (loc, (mime_type, _, _, _, mime_subtype, _, parameters)) = context(
1119        "content_type",
1120        preceded(
1121            opt(cfws),
1122            tuple((
1123                mime_token,
1124                opt(cfws),
1125                char('/'),
1126                opt(cfws),
1127                mime_token,
1128                opt(cfws),
1129                many0(preceded(
1130                    // Note that RFC 2231 is a bit of a mess, showing examples
1131                    // without `;` as a separator in the original text, but
1132                    // in the errata from several years later, corrects those
1133                    // to show the `;`.
1134                    // In the meantime, there are implementations that assume
1135                    // that the `;` is optional, so we therefore allow them
1136                    // to be optional here in our implementation
1137                    preceded(opt(char(';')), opt(cfws)),
1138                    terminated(parameter, opt(cfws)),
1139                )),
1140            )),
1141        ),
1142    )(input)?;
1143
1144    let value = format!("{mime_type}/{mime_subtype}");
1145    Ok((loc, MimeParameters { value, parameters }))
1146}
1147
1148fn content_transfer_encoding(input: Span) -> IResult<Span, MimeParameters> {
1149    let (loc, (value, _, parameters)) = context(
1150        "content_transfer_encoding",
1151        preceded(
1152            opt(cfws),
1153            tuple((
1154                mime_token,
1155                opt(cfws),
1156                many0(preceded(
1157                    // Note that RFC 2231 is a bit of a mess, showing examples
1158                    // without `;` as a separator in the original text, but
1159                    // in the errata from several years later, corrects those
1160                    // to show the `;`.
1161                    // In the meantime, there are implementations that assume
1162                    // that the `;` is optional, so we therefore allow them
1163                    // to be optional here in our implementation
1164                    preceded(opt(char(';')), opt(cfws)),
1165                    terminated(parameter, opt(cfws)),
1166                )),
1167            )),
1168        ),
1169    )(input)?;
1170
1171    Ok((
1172        loc,
1173        MimeParameters {
1174            value: value.to_string(),
1175            parameters,
1176        },
1177    ))
1178}
1179
1180// parameter = { regular_parameter | extended_parameter }
1181fn parameter(input: Span) -> IResult<Span, MimeParameter> {
1182    context(
1183        "parameter",
1184        alt((
1185            // Note that RFC2047 explicitly prohibits both of
1186            // these 2047 cases from appearing here, but that
1187            // major MUAs produce this sort of prohibited content
1188            // and we thus need to accommodate it
1189            param_with_unquoted_rfc2047,
1190            param_with_quoted_rfc2047,
1191            regular_parameter,
1192            extended_param_with_charset,
1193            extended_param_no_charset,
1194        )),
1195    )(input)
1196}
1197
1198fn param_with_unquoted_rfc2047(input: Span) -> IResult<Span, MimeParameter> {
1199    context(
1200        "param_with_unquoted_rfc2047",
1201        map(
1202            tuple((attribute, opt(cfws), char('='), opt(cfws), encoded_word)),
1203            |(name, _, _, _, value)| MimeParameter {
1204                name: name.to_string(),
1205                value,
1206                section: None,
1207                encoding: MimeParameterEncoding::UnquotedRfc2047,
1208                mime_charset: None,
1209                mime_language: None,
1210            },
1211        ),
1212    )(input)
1213}
1214
1215fn param_with_quoted_rfc2047(input: Span) -> IResult<Span, MimeParameter> {
1216    context(
1217        "param_with_quoted_rfc2047",
1218        map(
1219            tuple((
1220                attribute,
1221                opt(cfws),
1222                char('='),
1223                opt(cfws),
1224                delimited(char('"'), encoded_word, char('"')),
1225            )),
1226            |(name, _, _, _, value)| MimeParameter {
1227                name: name.to_string(),
1228                value,
1229                section: None,
1230                encoding: MimeParameterEncoding::QuotedRfc2047,
1231                mime_charset: None,
1232                mime_language: None,
1233            },
1234        ),
1235    )(input)
1236}
1237
1238fn extended_param_with_charset(input: Span) -> IResult<Span, MimeParameter> {
1239    context(
1240        "extended_param_with_charset",
1241        map(
1242            tuple((
1243                attribute,
1244                opt(section),
1245                char('*'),
1246                opt(cfws),
1247                char('='),
1248                opt(cfws),
1249                opt(mime_charset),
1250                char('\''),
1251                opt(mime_language),
1252                char('\''),
1253                map(
1254                    recognize(many0(alt((ext_octet, take_while1(is_attribute_char))))),
1255                    |s: Span| s.to_string(),
1256                ),
1257            )),
1258            |(name, section, _, _, _, _, mime_charset, _, mime_language, _, value)| MimeParameter {
1259                name: name.to_string(),
1260                section,
1261                mime_charset: mime_charset.map(|s| s.to_string()),
1262                mime_language: mime_language.map(|s| s.to_string()),
1263                encoding: MimeParameterEncoding::Rfc2231,
1264                value,
1265            },
1266        ),
1267    )(input)
1268}
1269
1270fn extended_param_no_charset(input: Span) -> IResult<Span, MimeParameter> {
1271    context(
1272        "extended_param_no_charset",
1273        map(
1274            tuple((
1275                attribute,
1276                opt(section),
1277                opt(char('*')),
1278                opt(cfws),
1279                char('='),
1280                opt(cfws),
1281                alt((
1282                    quoted_string,
1283                    map(
1284                        recognize(many0(alt((ext_octet, take_while1(is_attribute_char))))),
1285                        |s: Span| s.to_string(),
1286                    ),
1287                )),
1288            )),
1289            |(name, section, star, _, _, _, value)| MimeParameter {
1290                name: name.to_string(),
1291                section,
1292                mime_charset: None,
1293                mime_language: None,
1294                encoding: if star.is_some() {
1295                    MimeParameterEncoding::Rfc2231
1296                } else {
1297                    MimeParameterEncoding::None
1298                },
1299                value,
1300            },
1301        ),
1302    )(input)
1303}
1304
1305fn mime_charset(input: Span) -> IResult<Span, Span> {
1306    context(
1307        "mime_charset",
1308        take_while1(|c| is_mime_token(c) && c != '\''),
1309    )(input)
1310}
1311
1312fn mime_language(input: Span) -> IResult<Span, Span> {
1313    context(
1314        "mime_language",
1315        take_while1(|c| is_mime_token(c) && c != '\''),
1316    )(input)
1317}
1318
1319fn ext_octet(input: Span) -> IResult<Span, Span> {
1320    context(
1321        "ext_octet",
1322        recognize(tuple((
1323            char('%'),
1324            satisfy(|c| c.is_ascii_hexdigit()),
1325            satisfy(|c| c.is_ascii_hexdigit()),
1326        ))),
1327    )(input)
1328}
1329
1330// section = { "*" ~ ASCII_DIGIT+ }
1331fn section(input: Span) -> IResult<Span, u32> {
1332    context(
1333        "section",
1334        preceded(char('*'), nom::character::complete::u32),
1335    )(input)
1336}
1337
1338// regular_parameter = { attribute ~ cfws? ~ "=" ~ cfws? ~ value }
1339fn regular_parameter(input: Span) -> IResult<Span, MimeParameter> {
1340    context(
1341        "regular_parameter",
1342        map(
1343            tuple((attribute, opt(cfws), char('='), opt(cfws), value)),
1344            |(name, _, _, _, value)| MimeParameter {
1345                name: name.to_string(),
1346                value,
1347                section: None,
1348                encoding: MimeParameterEncoding::None,
1349                mime_charset: None,
1350                mime_language: None,
1351            },
1352        ),
1353    )(input)
1354}
1355
1356// attribute = { attribute_char+ }
1357// attribute_char = { !(" " | ctl | tspecials | "*" | "'" | "%") ~ char }
1358fn attribute(input: Span) -> IResult<Span, Span> {
1359    context("attribute", take_while1(is_attribute_char))(input)
1360}
1361
1362fn value(input: Span) -> IResult<Span, String> {
1363    context(
1364        "value",
1365        alt((map(mime_token, |s: Span| s.to_string()), quoted_string)),
1366    )(input)
1367}
1368
1369pub struct Parser;
1370
1371impl Parser {
1372    pub fn parse_mailbox_list_header(text: &str) -> Result<MailboxList> {
1373        parse_with(text, mailbox_list)
1374    }
1375
1376    pub fn parse_mailbox_header(text: &str) -> Result<Mailbox> {
1377        parse_with(text, mailbox)
1378    }
1379
1380    pub fn parse_address_list_header(text: &str) -> Result<AddressList> {
1381        parse_with(text, address_list)
1382    }
1383
1384    pub fn parse_msg_id_header(text: &str) -> Result<MessageID> {
1385        parse_with(text, msg_id)
1386    }
1387
1388    pub fn parse_msg_id_header_list(text: &str) -> Result<Vec<MessageID>> {
1389        parse_with(text, msg_id_list)
1390    }
1391
1392    pub fn parse_content_id_header(text: &str) -> Result<MessageID> {
1393        parse_with(text, content_id)
1394    }
1395
1396    pub fn parse_content_type_header(text: &str) -> Result<MimeParameters> {
1397        parse_with(text, content_type)
1398    }
1399
1400    pub fn parse_content_transfer_encoding_header(text: &str) -> Result<MimeParameters> {
1401        parse_with(text, content_transfer_encoding)
1402    }
1403
1404    pub fn parse_unstructured_header(text: &str) -> Result<String> {
1405        parse_with(text, unstructured)
1406    }
1407
1408    pub fn parse_authentication_results_header(text: &str) -> Result<AuthenticationResults> {
1409        parse_with(text, authentication_results)
1410    }
1411}
1412
1413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1414#[serde(deny_unknown_fields)]
1415pub struct AuthenticationResults {
1416    pub serv_id: String,
1417    pub version: Option<u32>,
1418    pub results: Vec<AuthenticationResult>,
1419}
1420
1421/// Emits a value that was parsed by `value`, into target
1422fn emit_value_token(value: &str, target: &mut String) {
1423    let use_quoted_string = !value.chars().all(|c| is_mime_token(c) || c == '@');
1424    if use_quoted_string {
1425        target.push('"');
1426        for c in value.chars() {
1427            if c == '"' || c == '\\' {
1428                target.push('\\');
1429            }
1430            target.push(c);
1431        }
1432        target.push('"');
1433    } else {
1434        target.push_str(value);
1435    }
1436}
1437
1438impl EncodeHeaderValue for AuthenticationResults {
1439    fn encode_value(&self) -> SharedString<'static> {
1440        let mut result = match self.version {
1441            Some(v) => format!("{} {v}", self.serv_id),
1442            None => self.serv_id.to_string(),
1443        };
1444        if self.results.is_empty() {
1445            result.push_str("; none");
1446        } else {
1447            for res in &self.results {
1448                result.push_str(";\r\n\t");
1449                emit_value_token(&res.method, &mut result);
1450                if let Some(v) = res.method_version {
1451                    result.push_str(&format!("/{v}"));
1452                }
1453                result.push('=');
1454                emit_value_token(&res.result, &mut result);
1455                if let Some(reason) = &res.reason {
1456                    result.push_str(" reason=");
1457                    emit_value_token(reason, &mut result);
1458                }
1459                for (k, v) in &res.props {
1460                    result.push_str(&format!("\r\n\t{k}="));
1461                    emit_value_token(v, &mut result);
1462                }
1463            }
1464        }
1465
1466        result.into()
1467    }
1468}
1469
1470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1471#[serde(deny_unknown_fields)]
1472pub struct AuthenticationResult {
1473    pub method: String,
1474    pub method_version: Option<u32>,
1475    pub result: String,
1476    pub reason: Option<String>,
1477    pub props: BTreeMap<String, String>,
1478}
1479
1480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1481#[serde(deny_unknown_fields)]
1482pub struct AddrSpec {
1483    pub local_part: String,
1484    pub domain: String,
1485}
1486
1487impl AddrSpec {
1488    pub fn new(local_part: &str, domain: &str) -> Self {
1489        Self {
1490            local_part: local_part.to_string(),
1491            domain: domain.to_string(),
1492        }
1493    }
1494
1495    pub fn parse(email: &str) -> Result<Self> {
1496        parse_with(email, addr_spec)
1497    }
1498}
1499
1500impl EncodeHeaderValue for AddrSpec {
1501    fn encode_value(&self) -> SharedString<'static> {
1502        let mut result = String::new();
1503
1504        let needs_quoting = !self.local_part.chars().all(|c| is_atext(c) || c == '.');
1505        if needs_quoting {
1506            result.push('"');
1507            // RFC5321 4.1.2 qtextSMTP:
1508            // within a quoted string, any ASCII graphic or space is permitted without
1509            // blackslash-quoting except double-quote and the backslash itself.
1510
1511            for c in self.local_part.chars() {
1512                if c == '"' || c == '\\' {
1513                    result.push('\\');
1514                }
1515                result.push(c);
1516            }
1517            result.push('"');
1518        } else {
1519            result.push_str(&self.local_part);
1520        }
1521        result.push('@');
1522        result.push_str(&self.domain);
1523
1524        result.into()
1525    }
1526}
1527
1528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1529#[serde(untagged)]
1530pub enum Address {
1531    Mailbox(Mailbox),
1532    Group { name: String, entries: MailboxList },
1533}
1534
1535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1536#[serde(deny_unknown_fields, transparent)]
1537pub struct AddressList(pub Vec<Address>);
1538
1539impl std::ops::Deref for AddressList {
1540    type Target = Vec<Address>;
1541    fn deref(&self) -> &Vec<Address> {
1542        &self.0
1543    }
1544}
1545
1546impl AddressList {
1547    pub fn extract_first_mailbox(&self) -> Option<&Mailbox> {
1548        let address = self.0.first()?;
1549        match address {
1550            Address::Mailbox(mailbox) => Some(mailbox),
1551            Address::Group { entries, .. } => entries.extract_first_mailbox(),
1552        }
1553    }
1554}
1555
1556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1557#[serde(deny_unknown_fields, transparent)]
1558pub struct MailboxList(pub Vec<Mailbox>);
1559
1560impl std::ops::Deref for MailboxList {
1561    type Target = Vec<Mailbox>;
1562    fn deref(&self) -> &Vec<Mailbox> {
1563        &self.0
1564    }
1565}
1566
1567impl MailboxList {
1568    pub fn extract_first_mailbox(&self) -> Option<&Mailbox> {
1569        self.0.first()
1570    }
1571}
1572
1573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1574#[serde(deny_unknown_fields)]
1575pub struct Mailbox {
1576    pub name: Option<String>,
1577    pub address: AddrSpec,
1578}
1579
1580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1581#[serde(transparent)]
1582pub struct MessageID(pub String);
1583
1584impl EncodeHeaderValue for MessageID {
1585    fn encode_value(&self) -> SharedString<'static> {
1586        format!("<{}>", self.0).into()
1587    }
1588}
1589
1590impl EncodeHeaderValue for Vec<MessageID> {
1591    fn encode_value(&self) -> SharedString<'static> {
1592        let mut result = String::new();
1593        for id in self {
1594            if !result.is_empty() {
1595                result.push_str("\r\n\t");
1596            }
1597            result.push_str(&format!("<{}>", id.0));
1598        }
1599        result.into()
1600    }
1601}
1602
1603// In theory, everyone would be aware of RFC 2231 and we can stop here,
1604// but in practice, things are messy.  At some point someone started
1605// to emit encoded-words insides quoted-string values, and for the sake
1606// of compatibility what we see now is technically illegal stuff like
1607// Content-Disposition: attachment; filename="=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?="
1608// being used to represent UTF-8 filenames.
1609// As such, in our RFC 2231 handling, we also need to accommodate
1610// these bogus representations, hence their presence in this enum
1611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1612pub(crate) enum MimeParameterEncoding {
1613    None,
1614    Rfc2231,
1615    UnquotedRfc2047,
1616    QuotedRfc2047,
1617}
1618
1619#[derive(Debug, Clone, PartialEq, Eq)]
1620struct MimeParameter {
1621    pub name: String,
1622    pub section: Option<u32>,
1623    pub mime_charset: Option<String>,
1624    pub mime_language: Option<String>,
1625    pub encoding: MimeParameterEncoding,
1626    pub value: String,
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Eq)]
1630pub struct MimeParameters {
1631    pub value: String,
1632    parameters: Vec<MimeParameter>,
1633}
1634
1635impl MimeParameters {
1636    pub fn new(value: &str) -> Self {
1637        Self {
1638            value: value.to_string(),
1639            parameters: vec![],
1640        }
1641    }
1642
1643    /// Decode all named parameters per RFC 2231 and return a map
1644    /// of the parameter names to parameters values.
1645    /// Incorrectly encoded parameters are silently ignored
1646    /// and are not returned in the resulting map.
1647    pub fn parameter_map(&self) -> BTreeMap<String, String> {
1648        let mut map = BTreeMap::new();
1649
1650        fn contains_key_ignore_case(map: &BTreeMap<String, String>, key: &str) -> bool {
1651            for k in map.keys() {
1652                if k.eq_ignore_ascii_case(key) {
1653                    return true;
1654                }
1655            }
1656            false
1657        }
1658
1659        for entry in &self.parameters {
1660            if !contains_key_ignore_case(&map, &entry.name) {
1661                if let Some(value) = self.get(&entry.name) {
1662                    map.insert(entry.name.to_string(), value);
1663                }
1664            }
1665        }
1666
1667        map
1668    }
1669
1670    /// Retrieve the value for a named parameter.
1671    /// This method will attempt to decode any %-encoded values
1672    /// per RFC 2231 and combine multi-element fields into a single
1673    /// contiguous value.
1674    /// Invalid charsets and encoding will be silently ignored.
1675    pub fn get(&self, name: &str) -> Option<String> {
1676        let mut elements: Vec<_> = self
1677            .parameters
1678            .iter()
1679            .filter(|p| p.name.eq_ignore_ascii_case(name))
1680            .collect();
1681        if elements.is_empty() {
1682            return None;
1683        }
1684        elements.sort_by(|a, b| a.section.cmp(&b.section));
1685
1686        let mut mime_charset = None;
1687        let mut result = String::new();
1688
1689        for ele in elements {
1690            if let Some(cset) = ele.mime_charset.as_deref() {
1691                mime_charset = Charset::for_label_no_replacement(cset.as_bytes());
1692            }
1693
1694            match ele.encoding {
1695                MimeParameterEncoding::Rfc2231 => {
1696                    if let Some(charset) = mime_charset.as_ref() {
1697                        let mut chars = ele.value.chars();
1698                        let mut bytes: Vec<u8> = vec![];
1699
1700                        fn char_to_bytes(c: char, bytes: &mut Vec<u8>) {
1701                            let mut buf = [0u8; 8];
1702                            let s = c.encode_utf8(&mut buf);
1703                            for b in s.bytes() {
1704                                bytes.push(b);
1705                            }
1706                        }
1707
1708                        'next_char: while let Some(c) = chars.next() {
1709                            match c {
1710                                '%' => {
1711                                    let mut value = 0u8;
1712                                    for _ in 0..2 {
1713                                        match chars.next() {
1714                                            Some(n) => match n {
1715                                                '0'..='9' => {
1716                                                    value <<= 4;
1717                                                    value |= n as u32 as u8 - b'0';
1718                                                }
1719                                                'a'..='f' => {
1720                                                    value <<= 4;
1721                                                    value |= (n as u32 as u8 - b'a') + 10;
1722                                                }
1723                                                'A'..='F' => {
1724                                                    value <<= 4;
1725                                                    value |= (n as u32 as u8 - b'A') + 10;
1726                                                }
1727                                                _ => {
1728                                                    char_to_bytes('%', &mut bytes);
1729                                                    char_to_bytes(n, &mut bytes);
1730                                                    break 'next_char;
1731                                                }
1732                                            },
1733                                            None => {
1734                                                char_to_bytes('%', &mut bytes);
1735                                                break 'next_char;
1736                                            }
1737                                        }
1738                                    }
1739
1740                                    bytes.push(value);
1741                                }
1742                                c => {
1743                                    char_to_bytes(c, &mut bytes);
1744                                }
1745                            }
1746                        }
1747
1748                        let (decoded, _malformed) = charset.decode_without_bom_handling(&bytes);
1749                        result.push_str(&decoded);
1750                    } else {
1751                        result.push_str(&ele.value);
1752                    }
1753                }
1754                MimeParameterEncoding::UnquotedRfc2047
1755                | MimeParameterEncoding::QuotedRfc2047
1756                | MimeParameterEncoding::None => {
1757                    result.push_str(&ele.value);
1758                }
1759            }
1760        }
1761
1762        Some(result)
1763    }
1764
1765    /// Remove the named parameter
1766    pub fn remove(&mut self, name: &str) {
1767        self.parameters
1768            .retain(|p| !p.name.eq_ignore_ascii_case(name));
1769    }
1770
1771    pub fn set(&mut self, name: &str, value: &str) {
1772        self.set_with_encoding(name, value, MimeParameterEncoding::None)
1773    }
1774
1775    pub(crate) fn set_with_encoding(
1776        &mut self,
1777        name: &str,
1778        value: &str,
1779        encoding: MimeParameterEncoding,
1780    ) {
1781        self.remove(name);
1782
1783        self.parameters.push(MimeParameter {
1784            name: name.to_string(),
1785            value: value.to_string(),
1786            section: None,
1787            mime_charset: None,
1788            mime_language: None,
1789            encoding,
1790        });
1791    }
1792
1793    pub fn is_multipart(&self) -> bool {
1794        self.value.starts_with("message/") || self.value.starts_with("multipart/")
1795    }
1796
1797    pub fn is_text(&self) -> bool {
1798        self.value.starts_with("text/")
1799    }
1800}
1801
1802impl EncodeHeaderValue for MimeParameters {
1803    fn encode_value(&self) -> SharedString<'static> {
1804        let mut result = self.value.to_string();
1805        let names: BTreeMap<&str, MimeParameterEncoding> = self
1806            .parameters
1807            .iter()
1808            .map(|p| (p.name.as_str(), p.encoding))
1809            .collect();
1810
1811        for (name, stated_encoding) in names {
1812            let value = self.get(name).expect("name to be present");
1813
1814            match stated_encoding {
1815                MimeParameterEncoding::UnquotedRfc2047 => {
1816                    let encoded = qp_encode(&value);
1817                    result.push_str(&format!(";\r\n\t{name}={encoded}"));
1818                }
1819                MimeParameterEncoding::QuotedRfc2047 => {
1820                    let encoded = qp_encode(&value);
1821                    result.push_str(&format!(";\r\n\t{name}=\"{encoded}\""));
1822                }
1823                MimeParameterEncoding::None | MimeParameterEncoding::Rfc2231 => {
1824                    let needs_encoding = value.chars().any(|c| !is_mime_token(c) || !c.is_ascii());
1825                    // Prefer to use quoted_string representation when possible, as it doesn't
1826                    // require any RFC 2231 encoding
1827                    let use_quoted_string = value
1828                        .chars()
1829                        .all(|c| (is_qtext(c) || is_quoted_pair(c)) && c.is_ascii());
1830
1831                    let mut params = vec![];
1832                    let mut chars = value.chars().peekable();
1833                    while chars.peek().is_some() {
1834                        let count = params.len();
1835                        let is_first = count == 0;
1836                        let prefix = if use_quoted_string {
1837                            "\""
1838                        } else if is_first && needs_encoding {
1839                            "UTF-8''"
1840                        } else {
1841                            ""
1842                        };
1843                        let limit = 74 - (name.len() + 4 + prefix.len());
1844
1845                        let mut encoded = String::new();
1846
1847                        while encoded.len() < limit {
1848                            let c = match chars.next() {
1849                                Some(c) => c,
1850                                None => break,
1851                            };
1852
1853                            if use_quoted_string {
1854                                if c == '"' || c == '\\' {
1855                                    encoded.push('\\');
1856                                }
1857                                encoded.push(c);
1858                            } else if is_mime_token(c) && (!needs_encoding || c != '%') {
1859                                encoded.push(c);
1860                            } else {
1861                                let mut buf = [0u8; 8];
1862                                let s = c.encode_utf8(&mut buf);
1863                                for b in s.bytes() {
1864                                    encoded.push('%');
1865                                    encoded.push(HEX_CHARS[(b as usize) >> 4] as char);
1866                                    encoded.push(HEX_CHARS[(b as usize) & 0x0f] as char);
1867                                }
1868                            }
1869                        }
1870
1871                        if use_quoted_string {
1872                            encoded.push('"');
1873                        }
1874
1875                        params.push(MimeParameter {
1876                            name: name.to_string(),
1877                            section: Some(count as u32),
1878                            mime_charset: if is_first {
1879                                Some("UTF-8".to_string())
1880                            } else {
1881                                None
1882                            },
1883                            mime_language: None,
1884                            encoding: if needs_encoding {
1885                                MimeParameterEncoding::Rfc2231
1886                            } else {
1887                                MimeParameterEncoding::None
1888                            },
1889                            value: encoded,
1890                        })
1891                    }
1892                    if params.len() == 1 {
1893                        params.last_mut().map(|p| p.section = None);
1894                    }
1895                    for p in params {
1896                        result.push_str(";\r\n\t");
1897                        let charset_tick = if !use_quoted_string
1898                            && (p.mime_charset.is_some() || p.mime_language.is_some())
1899                        {
1900                            "'"
1901                        } else {
1902                            ""
1903                        };
1904                        let lang_tick = if !use_quoted_string
1905                            && (p.mime_language.is_some() || p.mime_charset.is_some())
1906                        {
1907                            "'"
1908                        } else {
1909                            ""
1910                        };
1911
1912                        let section = p
1913                            .section
1914                            .map(|s| format!("*{s}"))
1915                            .unwrap_or_else(String::new);
1916
1917                        let uses_encoding =
1918                            if !use_quoted_string && p.encoding == MimeParameterEncoding::Rfc2231 {
1919                                "*"
1920                            } else {
1921                                ""
1922                            };
1923                        let charset = if use_quoted_string {
1924                            "\""
1925                        } else {
1926                            p.mime_charset.as_deref().unwrap_or("")
1927                        };
1928                        let lang = p.mime_language.as_deref().unwrap_or("");
1929
1930                        let line = format!(
1931                            "{name}{section}{uses_encoding}={charset}{charset_tick}{lang}{lang_tick}{value}",
1932                            name = &p.name,
1933                            value = &p.value
1934                        );
1935                        result.push_str(&line);
1936                    }
1937                }
1938            }
1939        }
1940        result.into()
1941    }
1942}
1943
1944static HEX_CHARS: &[u8] = b"0123456789ABCDEF";
1945
1946pub(crate) fn qp_encode(s: &str) -> String {
1947    let prefix = b"=?UTF-8?q?";
1948    let suffix = b"?=";
1949    let limit = 72 - (prefix.len() + suffix.len());
1950
1951    let mut result = Vec::with_capacity(s.len());
1952
1953    result.extend_from_slice(prefix);
1954    let mut line_length = 0;
1955
1956    enum Bytes<'a> {
1957        Passthru(&'a [u8]),
1958        Encode(&'a [u8]),
1959    }
1960
1961    // Iterate by char so that we don't confuse space (0x20) with a
1962    // utf8 subsequence and incorrectly encode the input string.
1963    for c in s.chars() {
1964        let mut bytes = [0u8; 4];
1965        let bytes = c.encode_utf8(&mut bytes).as_bytes();
1966
1967        let b = if (c.is_ascii_alphanumeric() || c.is_ascii_punctuation())
1968            && c != '?'
1969            && c != '='
1970            && c != ' '
1971            && c != '\t'
1972        {
1973            Bytes::Passthru(bytes)
1974        } else if c == ' ' {
1975            Bytes::Passthru(b"_")
1976        } else {
1977            Bytes::Encode(bytes)
1978        };
1979
1980        let need_len = match b {
1981            Bytes::Passthru(b) => b.len(),
1982            Bytes::Encode(b) => b.len() * 3,
1983        };
1984
1985        if need_len > limit - line_length {
1986            // Need to wrap
1987            result.extend_from_slice(suffix);
1988            result.extend_from_slice(b"\r\n\t");
1989            result.extend_from_slice(prefix);
1990            line_length = 0;
1991        }
1992
1993        match b {
1994            Bytes::Passthru(c) => {
1995                result.extend_from_slice(c);
1996            }
1997            Bytes::Encode(bytes) => {
1998                for &c in bytes {
1999                    result.push(b'=');
2000                    result.push(HEX_CHARS[(c as usize) >> 4]);
2001                    result.push(HEX_CHARS[(c as usize) & 0x0f]);
2002                }
2003            }
2004        }
2005
2006        line_length += need_len;
2007    }
2008
2009    if line_length > 0 {
2010        result.extend_from_slice(suffix);
2011    }
2012
2013    // Safety: we ensured that everything we output is in the ASCII
2014    // range, therefore the string is valid UTF-8
2015    unsafe { String::from_utf8_unchecked(result) }
2016}
2017
2018#[cfg(test)]
2019#[test]
2020fn test_qp_encode() {
2021    let encoded = qp_encode(
2022        "hello, I am a line that is this long, or maybe a little \
2023        bit longer than this, and that should get wrapped by the encoder",
2024    );
2025    k9::snapshot!(
2026        encoded,
2027        r#"
2028=?UTF-8?q?hello,_I_am_a_line_that_is_this_long,_or_maybe_a_little_bit_?=\r
2029\t=?UTF-8?q?longer_than_this,_and_that_should_get_wrapped_by_the_encoder?=
2030"#
2031    );
2032}
2033
2034/// Quote input string `s`, using a backslash escape,
2035/// any of the characters listed in needs_quote,
2036/// or if the string contains an @ or comma.
2037pub(crate) fn quote_string(s: &str, needs_quote: &str) -> String {
2038    const QUOTE_OVERALL: &str = "@,";
2039    if s.chars()
2040        .any(|c| needs_quote.contains(c) || QUOTE_OVERALL.contains(c))
2041    {
2042        let mut result = String::with_capacity(s.len() + 4);
2043        result.push('"');
2044        for c in s.chars() {
2045            if needs_quote.contains(c) {
2046                result.push('\\');
2047            }
2048            result.push(c);
2049        }
2050        result.push('"');
2051        result
2052    } else {
2053        s.to_string()
2054    }
2055}
2056
2057#[cfg(test)]
2058#[test]
2059fn test_quote_string() {
2060    let nq = "\\\"";
2061    k9::snapshot!(quote_string("hello", nq), "hello");
2062    k9::snapshot!(quote_string("hello there", nq), "hello there");
2063    k9::snapshot!(quote_string("hello, there", nq), "\"hello, there\"");
2064    k9::snapshot!(
2065        quote_string("hello \"there\"", nq),
2066        r#""hello \\"there\\"""#
2067    );
2068    k9::snapshot!(
2069        quote_string("hello c:\\backslash", nq),
2070        r#""hello c:\\\\backslash""#
2071    );
2072}
2073
2074impl EncodeHeaderValue for Mailbox {
2075    fn encode_value(&self) -> SharedString<'static> {
2076        match &self.name {
2077            Some(name) => {
2078                let mut value = if name.is_ascii() {
2079                    quote_string(name, "\\\"")
2080                } else {
2081                    qp_encode(name)
2082                };
2083
2084                value.push_str(" <");
2085                value.push_str(&self.address.encode_value());
2086                value.push('>');
2087                value.into()
2088            }
2089            None => format!("<{}>", self.address.encode_value()).into(),
2090        }
2091    }
2092}
2093
2094impl EncodeHeaderValue for MailboxList {
2095    fn encode_value(&self) -> SharedString<'static> {
2096        let mut result = String::new();
2097        for mailbox in &self.0 {
2098            if !result.is_empty() {
2099                result.push_str(",\r\n\t");
2100            }
2101            result.push_str(&mailbox.encode_value());
2102        }
2103        result.into()
2104    }
2105}
2106
2107impl EncodeHeaderValue for Address {
2108    fn encode_value(&self) -> SharedString<'static> {
2109        match self {
2110            Self::Mailbox(mbox) => mbox.encode_value(),
2111            Self::Group { name, entries } => {
2112                let mut result = format!("{name}:");
2113                result += &entries.encode_value();
2114                result.push(';');
2115                result.into()
2116            }
2117        }
2118    }
2119}
2120
2121impl EncodeHeaderValue for AddressList {
2122    fn encode_value(&self) -> SharedString<'static> {
2123        let mut result = String::new();
2124        for address in &self.0 {
2125            if !result.is_empty() {
2126                result.push_str(",\r\n\t");
2127            }
2128            result.push_str(&address.encode_value());
2129        }
2130        result.into()
2131    }
2132}
2133
2134#[cfg(test)]
2135mod test {
2136    use super::*;
2137    use crate::{Header, MessageConformance, MimePart};
2138
2139    #[test]
2140    fn mailbox_encodes_at() {
2141        let mbox = Mailbox {
2142            name: Some("foo@bar.com".to_string()),
2143            address: AddrSpec {
2144                local_part: "foo".to_string(),
2145                domain: "bar.com".to_string(),
2146            },
2147        };
2148        assert_eq!(mbox.encode_value(), "\"foo@bar.com\" <foo@bar.com>");
2149    }
2150
2151    #[test]
2152    fn mailbox_list_singular() {
2153        let message = concat!(
2154            "From:  Someone (hello) <someone@example.com>, other@example.com,\n",
2155            "  \"John \\\"Smith\\\"\" (comment) \"More Quotes\" (more comment) <someone(another comment)@crazy.example.com(woot)>\n",
2156            "\n",
2157            "I am the body"
2158        );
2159        let msg = MimePart::parse(message).unwrap();
2160        let list = match msg.headers().from() {
2161            Err(err) => panic!("Doh.\n{err:#}"),
2162            Ok(list) => list,
2163        };
2164
2165        k9::snapshot!(
2166            list,
2167            r#"
2168Some(
2169    MailboxList(
2170        [
2171            Mailbox {
2172                name: Some(
2173                    "Someone",
2174                ),
2175                address: AddrSpec {
2176                    local_part: "someone",
2177                    domain: "example.com",
2178                },
2179            },
2180            Mailbox {
2181                name: None,
2182                address: AddrSpec {
2183                    local_part: "other",
2184                    domain: "example.com",
2185                },
2186            },
2187            Mailbox {
2188                name: Some(
2189                    "John "Smith" More Quotes",
2190                ),
2191                address: AddrSpec {
2192                    local_part: "someone",
2193                    domain: "crazy.example.com",
2194                },
2195            },
2196        ],
2197    ),
2198)
2199"#
2200        );
2201    }
2202
2203    #[test]
2204    fn docomo_non_compliant_localpart() {
2205        let message = "Sender: hello..there@docomo.ne.jp\n\n\n";
2206        let msg = MimePart::parse(message).unwrap();
2207        let err = msg.headers().sender().unwrap_err();
2208        k9::snapshot!(
2209            err,
2210            r#"
2211InvalidHeaderValueDuringGet {
2212    header_name: "Sender",
2213    error: HeaderParse(
2214        "0: at line 1:
2215hello..there@docomo.ne.jp
2216     ^___________________
2217expected '@', found .
2218
22191: at line 1, in addr_spec:
2220hello..there@docomo.ne.jp
2221^________________________
2222
22232: at line 1, in mailbox:
2224hello..there@docomo.ne.jp
2225^________________________
2226
2227",
2228    ),
2229}
2230"#
2231        );
2232    }
2233
2234    #[test]
2235    fn sender() {
2236        let message = "Sender: someone@[127.0.0.1]\n\n\n";
2237        let msg = MimePart::parse(message).unwrap();
2238        let list = match msg.headers().sender() {
2239            Err(err) => panic!("Doh.\n{err:#}"),
2240            Ok(list) => list,
2241        };
2242        k9::snapshot!(
2243            list,
2244            r#"
2245Some(
2246    Mailbox {
2247        name: None,
2248        address: AddrSpec {
2249            local_part: "someone",
2250            domain: "[127.0.0.1]",
2251        },
2252    },
2253)
2254"#
2255        );
2256    }
2257
2258    #[test]
2259    fn domain_literal() {
2260        let message = "From: someone@[127.0.0.1]\n\n\n";
2261        let msg = MimePart::parse(message).unwrap();
2262        let list = match msg.headers().from() {
2263            Err(err) => panic!("Doh.\n{err:#}"),
2264            Ok(list) => list,
2265        };
2266        k9::snapshot!(
2267            list,
2268            r#"
2269Some(
2270    MailboxList(
2271        [
2272            Mailbox {
2273                name: None,
2274                address: AddrSpec {
2275                    local_part: "someone",
2276                    domain: "[127.0.0.1]",
2277                },
2278            },
2279        ],
2280    ),
2281)
2282"#
2283        );
2284    }
2285
2286    #[test]
2287    fn rfc6532() {
2288        let message = concat!(
2289            "From: Keith Moore <moore@cs.utk.edu>\n",
2290            "To: Keld Jørn Simonsen <keld@dkuug.dk>\n",
2291            "CC: André Pirard <PIRARD@vm1.ulg.ac.be>\n",
2292            "Subject: Hello André\n",
2293            "\n\n"
2294        );
2295        let msg = MimePart::parse(message).unwrap();
2296        let list = match msg.headers().from() {
2297            Err(err) => panic!("Doh.\n{err:#}"),
2298            Ok(list) => list,
2299        };
2300        k9::snapshot!(
2301            list,
2302            r#"
2303Some(
2304    MailboxList(
2305        [
2306            Mailbox {
2307                name: Some(
2308                    "Keith Moore",
2309                ),
2310                address: AddrSpec {
2311                    local_part: "moore",
2312                    domain: "cs.utk.edu",
2313                },
2314            },
2315        ],
2316    ),
2317)
2318"#
2319        );
2320
2321        let list = match msg.headers().to() {
2322            Err(err) => panic!("Doh.\n{err:#}"),
2323            Ok(list) => list,
2324        };
2325        k9::snapshot!(
2326            list,
2327            r#"
2328Some(
2329    AddressList(
2330        [
2331            Mailbox(
2332                Mailbox {
2333                    name: Some(
2334                        "Keld Jørn Simonsen",
2335                    ),
2336                    address: AddrSpec {
2337                        local_part: "keld",
2338                        domain: "dkuug.dk",
2339                    },
2340                },
2341            ),
2342        ],
2343    ),
2344)
2345"#
2346        );
2347
2348        let list = match msg.headers().cc() {
2349            Err(err) => panic!("Doh.\n{err:#}"),
2350            Ok(list) => list,
2351        };
2352        k9::snapshot!(
2353            list,
2354            r#"
2355Some(
2356    AddressList(
2357        [
2358            Mailbox(
2359                Mailbox {
2360                    name: Some(
2361                        "André Pirard",
2362                    ),
2363                    address: AddrSpec {
2364                        local_part: "PIRARD",
2365                        domain: "vm1.ulg.ac.be",
2366                    },
2367                },
2368            ),
2369        ],
2370    ),
2371)
2372"#
2373        );
2374        let list = match msg.headers().subject() {
2375            Err(err) => panic!("Doh.\n{err:#}"),
2376            Ok(list) => list,
2377        };
2378        k9::snapshot!(
2379            list,
2380            r#"
2381Some(
2382    "Hello André",
2383)
2384"#
2385        );
2386    }
2387
2388    #[test]
2389    fn rfc2047_bogus() {
2390        let message = concat!(
2391            "From: =?US-OSCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2392            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2393            "CC: =?ISO-8859-1?Q?Andr=E?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2394            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?=\n",
2395            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2396            "\n\n"
2397        );
2398        let msg = MimePart::parse(message).unwrap();
2399
2400        // Invalid charset causes encoded_word to fail and we will instead match
2401        // obs_utext and return it as it was
2402        k9::assert_equal!(
2403            msg.headers().from().unwrap().unwrap().0[0]
2404                .name
2405                .as_ref()
2406                .unwrap(),
2407            "=?US-OSCII?Q?Keith_Moore?="
2408        );
2409
2410        match &msg.headers().cc().unwrap().unwrap().0[0] {
2411            Address::Mailbox(mbox) => {
2412                // 'Andr=E9?=' is in the non-bogus example below, but above we
2413                // broke it as 'Andr=E?=', and instead of triggering a qp decode
2414                // error, it is passed through here as-is
2415                k9::assert_equal!(mbox.name.as_ref().unwrap(), "Andr=E Pirard");
2416            }
2417            wat => panic!("should not have {wat:?}"),
2418        }
2419
2420        // The invalid base64 (an I was replaced by an !) is interpreted as obs_utext
2421        // and passed through to us
2422        k9::assert_equal!(
2423            msg.headers().subject().unwrap().unwrap(),
2424            "Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?= u understand the example."
2425        );
2426    }
2427
2428    #[test]
2429    fn attachment_filename_mess_totally_bogus() {
2430        let message = concat!("Content-Disposition: attachment; filename=@\n", "\n\n");
2431        let msg = MimePart::parse(message).unwrap();
2432        eprintln!("{msg:#?}");
2433
2434        assert!(msg
2435            .conformance()
2436            .contains(MessageConformance::INVALID_MIME_HEADERS));
2437        msg.headers().content_disposition().unwrap_err();
2438
2439        // There is no Content-Disposition in the rebuilt message, because
2440        // there was no valid Content-Disposition in what we parsed
2441        let rebuilt = msg.rebuild().unwrap();
2442        k9::assert_equal!(rebuilt.headers().content_disposition(), Ok(None));
2443    }
2444
2445    #[test]
2446    fn attachment_filename_mess_aberrant() {
2447        let message = concat!(
2448            "Content-Disposition: attachment; filename= =?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\n",
2449            "\n\n"
2450        );
2451        let msg = MimePart::parse(message).unwrap();
2452
2453        let cd = msg.headers().content_disposition().unwrap().unwrap();
2454        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
2455
2456        let encoded = cd.encode_value();
2457        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?=");
2458    }
2459
2460    #[test]
2461    fn attachment_filename_mess_gmail() {
2462        let message = concat!(
2463            "Content-Disposition: attachment; filename=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
2464            "Content-Type: text/plain;\n",
2465            "   name=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
2466            "\n\n"
2467        );
2468        let msg = MimePart::parse(message).unwrap();
2469
2470        let cd = msg.headers().content_disposition().unwrap().unwrap();
2471        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
2472        let encoded = cd.encode_value();
2473        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?=\"");
2474
2475        let ct = msg.headers().content_type().unwrap().unwrap();
2476        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付");
2477    }
2478
2479    #[test]
2480    fn attachment_filename_mess_fastmail() {
2481        let message = concat!(
2482            "Content-Disposition: attachment;\n",
2483            "  filename*0*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98;\n",
2484            "  filename*1*=.txt\n",
2485            "Content-Type: text/plain;\n",
2486            "   name=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=\"\n",
2487            "   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",
2488            "\n\n"
2489        );
2490        let msg = MimePart::parse(message).unwrap();
2491
2492        let cd = msg.headers().content_disposition().unwrap().unwrap();
2493        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付.txt");
2494
2495        let ct = msg.headers().content_type().unwrap().unwrap();
2496        eprintln!("{ct:#?}");
2497        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付.txt");
2498        k9::assert_equal!(
2499            ct.get("x-name").unwrap(),
2500            "=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=bork"
2501        );
2502    }
2503
2504    #[test]
2505    fn rfc2047() {
2506        let message = concat!(
2507            "From: =?US-ASCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2508            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2509            "CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2510            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n",
2511            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2512            "\n\n"
2513        );
2514        let msg = MimePart::parse(message).unwrap();
2515        let list = match msg.headers().from() {
2516            Err(err) => panic!("Doh.\n{err:#}"),
2517            Ok(list) => list,
2518        };
2519        k9::snapshot!(
2520            list,
2521            r#"
2522Some(
2523    MailboxList(
2524        [
2525            Mailbox {
2526                name: Some(
2527                    "Keith Moore",
2528                ),
2529                address: AddrSpec {
2530                    local_part: "moore",
2531                    domain: "cs.utk.edu",
2532                },
2533            },
2534        ],
2535    ),
2536)
2537"#
2538        );
2539
2540        let list = match msg.headers().to() {
2541            Err(err) => panic!("Doh.\n{err:#}"),
2542            Ok(list) => list,
2543        };
2544        k9::snapshot!(
2545            list,
2546            r#"
2547Some(
2548    AddressList(
2549        [
2550            Mailbox(
2551                Mailbox {
2552                    name: Some(
2553                        "Keld Jørn Simonsen",
2554                    ),
2555                    address: AddrSpec {
2556                        local_part: "keld",
2557                        domain: "dkuug.dk",
2558                    },
2559                },
2560            ),
2561        ],
2562    ),
2563)
2564"#
2565        );
2566
2567        let list = match msg.headers().cc() {
2568            Err(err) => panic!("Doh.\n{err:#}"),
2569            Ok(list) => list,
2570        };
2571        k9::snapshot!(
2572            list,
2573            r#"
2574Some(
2575    AddressList(
2576        [
2577            Mailbox(
2578                Mailbox {
2579                    name: Some(
2580                        "André Pirard",
2581                    ),
2582                    address: AddrSpec {
2583                        local_part: "PIRARD",
2584                        domain: "vm1.ulg.ac.be",
2585                    },
2586                },
2587            ),
2588        ],
2589    ),
2590)
2591"#
2592        );
2593        let list = match msg.headers().subject() {
2594            Err(err) => panic!("Doh.\n{err:#}"),
2595            Ok(list) => list,
2596        };
2597        k9::snapshot!(
2598            list,
2599            r#"
2600Some(
2601    "Hello If you can read this you understand the example.",
2602)
2603"#
2604        );
2605
2606        k9::snapshot!(
2607            msg.rebuild().unwrap().to_message_string(),
2608            r#"
2609Content-Type: text/plain;\r
2610\tcharset="us-ascii"\r
2611Content-Transfer-Encoding: quoted-printable\r
2612From: Keith Moore <moore@cs.utk.edu>\r
2613To: =?UTF-8?q?Keld_J=C3=B8rn_Simonsen?= <keld@dkuug.dk>\r
2614Cc: =?UTF-8?q?Andr=C3=A9_Pirard?= <PIRARD@vm1.ulg.ac.be>\r
2615Subject: Hello If you can read this you understand the example.\r
2616\r
2617=0A\r
2618
2619"#
2620        );
2621    }
2622
2623    #[test]
2624    fn group_addresses() {
2625        let message = concat!(
2626            "To: A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;\n",
2627            "Cc: Undisclosed recipients:;\n",
2628            "\n\n\n"
2629        );
2630        let msg = MimePart::parse(message).unwrap();
2631        let list = match msg.headers().to() {
2632            Err(err) => panic!("Doh.\n{err:#}"),
2633            Ok(list) => list.unwrap(),
2634        };
2635
2636        k9::snapshot!(
2637            list.encode_value(),
2638            r#"
2639A Group:Ed Jones <c@a.test>,\r
2640\t<joe@where.test>,\r
2641\tJohn <jdoe@one.test>;
2642"#
2643        );
2644
2645        let round_trip = Header::new("To", list.clone());
2646        k9::assert_equal!(list, round_trip.as_address_list().unwrap());
2647
2648        k9::snapshot!(
2649            list,
2650            r#"
2651AddressList(
2652    [
2653        Group {
2654            name: "A Group",
2655            entries: MailboxList(
2656                [
2657                    Mailbox {
2658                        name: Some(
2659                            "Ed Jones",
2660                        ),
2661                        address: AddrSpec {
2662                            local_part: "c",
2663                            domain: "a.test",
2664                        },
2665                    },
2666                    Mailbox {
2667                        name: None,
2668                        address: AddrSpec {
2669                            local_part: "joe",
2670                            domain: "where.test",
2671                        },
2672                    },
2673                    Mailbox {
2674                        name: Some(
2675                            "John",
2676                        ),
2677                        address: AddrSpec {
2678                            local_part: "jdoe",
2679                            domain: "one.test",
2680                        },
2681                    },
2682                ],
2683            ),
2684        },
2685    ],
2686)
2687"#
2688        );
2689
2690        let list = match msg.headers().cc() {
2691            Err(err) => panic!("Doh.\n{err:#}"),
2692            Ok(list) => list,
2693        };
2694        k9::snapshot!(
2695            list,
2696            r#"
2697Some(
2698    AddressList(
2699        [
2700            Group {
2701                name: "Undisclosed recipients",
2702                entries: MailboxList(
2703                    [],
2704                ),
2705            },
2706        ],
2707    ),
2708)
2709"#
2710        );
2711    }
2712
2713    #[test]
2714    fn message_id() {
2715        let message = concat!(
2716            "Message-Id: <foo@example.com>\n",
2717            "References: <a@example.com> <b@example.com>\n",
2718            "  <\"legacy\"@example.com>\n",
2719            "  <literal@[127.0.0.1]>\n",
2720            "\n\n\n"
2721        );
2722        let msg = MimePart::parse(message).unwrap();
2723        let list = match msg.headers().message_id() {
2724            Err(err) => panic!("Doh.\n{err:#}"),
2725            Ok(list) => list,
2726        };
2727        k9::snapshot!(
2728            list,
2729            r#"
2730Some(
2731    MessageID(
2732        "foo@example.com",
2733    ),
2734)
2735"#
2736        );
2737
2738        let list = match msg.headers().references() {
2739            Err(err) => panic!("Doh.\n{err:#}"),
2740            Ok(list) => list,
2741        };
2742        k9::snapshot!(
2743            list,
2744            r#"
2745Some(
2746    [
2747        MessageID(
2748            "a@example.com",
2749        ),
2750        MessageID(
2751            "b@example.com",
2752        ),
2753        MessageID(
2754            "legacy@example.com",
2755        ),
2756        MessageID(
2757            "literal@[127.0.0.1]",
2758        ),
2759    ],
2760)
2761"#
2762        );
2763    }
2764
2765    #[test]
2766    fn content_type() {
2767        let message = "Content-Type: text/plain\n\n\n\n";
2768        let msg = MimePart::parse(message).unwrap();
2769        let params = match msg.headers().content_type() {
2770            Err(err) => panic!("Doh.\n{err:#}"),
2771            Ok(params) => params,
2772        };
2773        k9::snapshot!(
2774            params,
2775            r#"
2776Some(
2777    MimeParameters {
2778        value: "text/plain",
2779        parameters: [],
2780    },
2781)
2782"#
2783        );
2784
2785        let message = "Content-Type: text/plain; charset=us-ascii\n\n\n\n";
2786        let msg = MimePart::parse(message).unwrap();
2787        let params = match msg.headers().content_type() {
2788            Err(err) => panic!("Doh.\n{err:#}"),
2789            Ok(params) => params.unwrap(),
2790        };
2791
2792        k9::snapshot!(
2793            params.get("charset"),
2794            r#"
2795Some(
2796    "us-ascii",
2797)
2798"#
2799        );
2800        k9::snapshot!(
2801            params,
2802            r#"
2803MimeParameters {
2804    value: "text/plain",
2805    parameters: [
2806        MimeParameter {
2807            name: "charset",
2808            section: None,
2809            mime_charset: None,
2810            mime_language: None,
2811            encoding: None,
2812            value: "us-ascii",
2813        },
2814    ],
2815}
2816"#
2817        );
2818
2819        let message = "Content-Type: text/plain; charset=\"us-ascii\"\n\n\n\n";
2820        let msg = MimePart::parse(message).unwrap();
2821        let params = match msg.headers().content_type() {
2822            Err(err) => panic!("Doh.\n{err:#}"),
2823            Ok(params) => params,
2824        };
2825        k9::snapshot!(
2826            params,
2827            r#"
2828Some(
2829    MimeParameters {
2830        value: "text/plain",
2831        parameters: [
2832            MimeParameter {
2833                name: "charset",
2834                section: None,
2835                mime_charset: None,
2836                mime_language: None,
2837                encoding: None,
2838                value: "us-ascii",
2839            },
2840        ],
2841    },
2842)
2843"#
2844        );
2845    }
2846
2847    #[test]
2848    fn content_type_rfc2231() {
2849        // This example is taken from the errata for rfc2231.
2850        // <https://www.rfc-editor.org/errata/eid590>
2851        let message = concat!(
2852            "Content-Type: application/x-stuff;\n",
2853            "\ttitle*0*=us-ascii'en'This%20is%20even%20more%20;\n",
2854            "\ttitle*1*=%2A%2A%2Afun%2A%2A%2A%20;\n",
2855            "\ttitle*2=\"isn't it!\"\n",
2856            "\n\n\n"
2857        );
2858        let msg = MimePart::parse(message).unwrap();
2859        let mut params = match msg.headers().content_type() {
2860            Err(err) => panic!("Doh.\n{err:#}"),
2861            Ok(params) => params.unwrap(),
2862        };
2863
2864        let original_title = params.get("title");
2865        k9::snapshot!(
2866            &original_title,
2867            r#"
2868Some(
2869    "This is even more ***fun*** isn't it!",
2870)
2871"#
2872        );
2873
2874        k9::snapshot!(
2875            &params,
2876            r#"
2877MimeParameters {
2878    value: "application/x-stuff",
2879    parameters: [
2880        MimeParameter {
2881            name: "title",
2882            section: Some(
2883                0,
2884            ),
2885            mime_charset: Some(
2886                "us-ascii",
2887            ),
2888            mime_language: Some(
2889                "en",
2890            ),
2891            encoding: Rfc2231,
2892            value: "This%20is%20even%20more%20",
2893        },
2894        MimeParameter {
2895            name: "title",
2896            section: Some(
2897                1,
2898            ),
2899            mime_charset: None,
2900            mime_language: None,
2901            encoding: Rfc2231,
2902            value: "%2A%2A%2Afun%2A%2A%2A%20",
2903        },
2904        MimeParameter {
2905            name: "title",
2906            section: Some(
2907                2,
2908            ),
2909            mime_charset: None,
2910            mime_language: None,
2911            encoding: None,
2912            value: "isn't it!",
2913        },
2914    ],
2915}
2916"#
2917        );
2918
2919        k9::snapshot!(
2920            params.encode_value(),
2921            r#"
2922application/x-stuff;\r
2923\ttitle="This is even more ***fun*** isn't it!"
2924"#
2925        );
2926
2927        params.set("foo", "bar 💩");
2928
2929        params.set(
2930            "long",
2931            "this is some text that should wrap because \
2932                it should be a good bit longer than our target maximum \
2933                length for this sort of thing, and hopefully we see at \
2934                least three lines produced as a result of setting \
2935                this value in this way",
2936        );
2937
2938        params.set(
2939            "longernnamethananyoneshouldreallyuse",
2940            "this is some text that should wrap because \
2941                it should be a good bit longer than our target maximum \
2942                length for this sort of thing, and hopefully we see at \
2943                least three lines produced as a result of setting \
2944                this value in this way",
2945        );
2946
2947        k9::snapshot!(
2948            params.encode_value(),
2949            r#"
2950application/x-stuff;\r
2951\tfoo*=UTF-8''bar%20%F0%9F%92%A9;\r
2952\tlong*0="this is some text that should wrap because it should be a good bi";\r
2953\tlong*1="t longer than our target maximum length for this sort of thing, a";\r
2954\tlong*2="nd hopefully we see at least three lines produced as a result of ";\r
2955\tlong*3="setting this value in this way";\r
2956\tlongernnamethananyoneshouldreallyuse*0="this is some text that should wra";\r
2957\tlongernnamethananyoneshouldreallyuse*1="p because it should be a good bit";\r
2958\tlongernnamethananyoneshouldreallyuse*2=" longer than our target maximum l";\r
2959\tlongernnamethananyoneshouldreallyuse*3="ength for this sort of thing, and";\r
2960\tlongernnamethananyoneshouldreallyuse*4=" hopefully we see at least three ";\r
2961\tlongernnamethananyoneshouldreallyuse*5="lines produced as a result of set";\r
2962\tlongernnamethananyoneshouldreallyuse*6="ting this value in this way";\r
2963\ttitle="This is even more ***fun*** isn't it!"
2964"#
2965        );
2966    }
2967
2968    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.2>
2969    #[test]
2970    fn authentication_results_b_2() {
2971        let ar = Header::with_name_value("Authentication-Results", "example.org 1; none");
2972        let ar = ar.as_authentication_results().unwrap();
2973        k9::snapshot!(
2974            &ar,
2975            r#"
2976AuthenticationResults {
2977    serv_id: "example.org",
2978    version: Some(
2979        1,
2980    ),
2981    results: [],
2982}
2983"#
2984        );
2985
2986        k9::snapshot!(ar.encode_value(), "example.org 1; none");
2987    }
2988
2989    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.3>
2990    #[test]
2991    fn authentication_results_b_3() {
2992        let ar = Header::with_name_value(
2993            "Authentication-Results",
2994            "example.com; spf=pass smtp.mailfrom=example.net",
2995        );
2996        k9::snapshot!(
2997            ar.as_authentication_results(),
2998            r#"
2999Ok(
3000    AuthenticationResults {
3001        serv_id: "example.com",
3002        version: None,
3003        results: [
3004            AuthenticationResult {
3005                method: "spf",
3006                method_version: None,
3007                result: "pass",
3008                reason: None,
3009                props: {
3010                    "smtp.mailfrom": "example.net",
3011                },
3012            },
3013        ],
3014    },
3015)
3016"#
3017        );
3018    }
3019
3020    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.4>
3021    #[test]
3022    fn authentication_results_b_4() {
3023        let ar = Header::with_name_value(
3024            "Authentication-Results",
3025            concat!(
3026                "example.com;\n",
3027                "\tauth=pass (cram-md5) smtp.auth=sender@example.net;\n",
3028                "\tspf=pass smtp.mailfrom=example.net"
3029            ),
3030        );
3031        k9::snapshot!(
3032            ar.as_authentication_results(),
3033            r#"
3034Ok(
3035    AuthenticationResults {
3036        serv_id: "example.com",
3037        version: None,
3038        results: [
3039            AuthenticationResult {
3040                method: "auth",
3041                method_version: None,
3042                result: "pass",
3043                reason: None,
3044                props: {
3045                    "smtp.auth": "sender@example.net",
3046                },
3047            },
3048            AuthenticationResult {
3049                method: "spf",
3050                method_version: None,
3051                result: "pass",
3052                reason: None,
3053                props: {
3054                    "smtp.mailfrom": "example.net",
3055                },
3056            },
3057        ],
3058    },
3059)
3060"#
3061        );
3062
3063        let ar = Header::with_name_value(
3064            "Authentication-Results",
3065            "example.com; iprev=pass\n\tpolicy.iprev=192.0.2.200",
3066        );
3067        k9::snapshot!(
3068            ar.as_authentication_results(),
3069            r#"
3070Ok(
3071    AuthenticationResults {
3072        serv_id: "example.com",
3073        version: None,
3074        results: [
3075            AuthenticationResult {
3076                method: "iprev",
3077                method_version: None,
3078                result: "pass",
3079                reason: None,
3080                props: {
3081                    "policy.iprev": "192.0.2.200",
3082                },
3083            },
3084        ],
3085    },
3086)
3087"#
3088        );
3089    }
3090
3091    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.5>
3092    #[test]
3093    fn authentication_results_b_5() {
3094        let ar = Header::with_name_value(
3095            "Authentication-Results",
3096            "example.com;\n\tdkim=pass (good signature) header.d=example.com",
3097        );
3098        k9::snapshot!(
3099            ar.as_authentication_results(),
3100            r#"
3101Ok(
3102    AuthenticationResults {
3103        serv_id: "example.com",
3104        version: None,
3105        results: [
3106            AuthenticationResult {
3107                method: "dkim",
3108                method_version: None,
3109                result: "pass",
3110                reason: None,
3111                props: {
3112                    "header.d": "example.com",
3113                },
3114            },
3115        ],
3116    },
3117)
3118"#
3119        );
3120
3121        let ar = Header::with_name_value(
3122            "Authentication-Results",
3123            "example.com;\n\tauth=pass (cram-md5) smtp.auth=sender@example.com;\n\tspf=fail smtp.mailfrom=example.com"
3124        );
3125        let ar = ar.as_authentication_results().unwrap();
3126        k9::snapshot!(
3127            &ar,
3128            r#"
3129AuthenticationResults {
3130    serv_id: "example.com",
3131    version: None,
3132    results: [
3133        AuthenticationResult {
3134            method: "auth",
3135            method_version: None,
3136            result: "pass",
3137            reason: None,
3138            props: {
3139                "smtp.auth": "sender@example.com",
3140            },
3141        },
3142        AuthenticationResult {
3143            method: "spf",
3144            method_version: None,
3145            result: "fail",
3146            reason: None,
3147            props: {
3148                "smtp.mailfrom": "example.com",
3149            },
3150        },
3151    ],
3152}
3153"#
3154        );
3155
3156        k9::snapshot!(
3157            ar.encode_value(),
3158            r#"
3159example.com;\r
3160\tauth=pass\r
3161\tsmtp.auth=sender@example.com;\r
3162\tspf=fail\r
3163\tsmtp.mailfrom=example.com
3164"#
3165        );
3166    }
3167
3168    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.6>
3169    #[test]
3170    fn authentication_results_b_6() {
3171        let ar = Header::with_name_value(
3172            "Authentication-Results",
3173            concat!(
3174                "example.com;\n",
3175                "\tdkim=pass reason=\"good signature\"\n",
3176                "\theader.i=@mail-router.example.net;\n",
3177                "\tdkim=fail reason=\"bad signature\"\n",
3178                "\theader.i=@newyork.example.com"
3179            ),
3180        );
3181        let ar = match ar.as_authentication_results() {
3182            Err(err) => panic!("\n{err}"),
3183            Ok(ar) => ar,
3184        };
3185
3186        k9::snapshot!(
3187            &ar,
3188            r#"
3189AuthenticationResults {
3190    serv_id: "example.com",
3191    version: None,
3192    results: [
3193        AuthenticationResult {
3194            method: "dkim",
3195            method_version: None,
3196            result: "pass",
3197            reason: Some(
3198                "good signature",
3199            ),
3200            props: {
3201                "header.i": "@mail-router.example.net",
3202            },
3203        },
3204        AuthenticationResult {
3205            method: "dkim",
3206            method_version: None,
3207            result: "fail",
3208            reason: Some(
3209                "bad signature",
3210            ),
3211            props: {
3212                "header.i": "@newyork.example.com",
3213            },
3214        },
3215    ],
3216}
3217"#
3218        );
3219
3220        k9::snapshot!(
3221            ar.encode_value(),
3222            r#"
3223example.com;\r
3224\tdkim=pass reason="good signature"\r
3225\theader.i=@mail-router.example.net;\r
3226\tdkim=fail reason="bad signature"\r
3227\theader.i=@newyork.example.com
3228"#
3229        );
3230
3231        let ar = Header::with_name_value(
3232            "Authentication-Results",
3233            concat!(
3234                "example.net;\n",
3235                "\tdkim=pass (good signature) header.i=@newyork.example.com"
3236            ),
3237        );
3238        let ar = match ar.as_authentication_results() {
3239            Err(err) => panic!("\n{err}"),
3240            Ok(ar) => ar,
3241        };
3242
3243        k9::snapshot!(
3244            &ar,
3245            r#"
3246AuthenticationResults {
3247    serv_id: "example.net",
3248    version: None,
3249    results: [
3250        AuthenticationResult {
3251            method: "dkim",
3252            method_version: None,
3253            result: "pass",
3254            reason: None,
3255            props: {
3256                "header.i": "@newyork.example.com",
3257            },
3258        },
3259    ],
3260}
3261"#
3262        );
3263
3264        k9::snapshot!(
3265            ar.encode_value(),
3266            r#"
3267example.net;\r
3268\tdkim=pass\r
3269\theader.i=@newyork.example.com
3270"#
3271        );
3272    }
3273
3274    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.7>
3275    #[test]
3276    fn authentication_results_b_7() {
3277        let ar = Header::with_name_value(
3278            "Authentication-Results",
3279            concat!(
3280                "foo.example.net (foobar) 1 (baz);\n",
3281                "\tdkim (Because I like it) / 1 (One yay) = (wait for it) fail\n",
3282                "\tpolicy (A dot can go here) . (like that) expired\n",
3283                "\t(this surprised me) = (as I wasn't expecting it) 1362471462"
3284            ),
3285        );
3286        let ar = match ar.as_authentication_results() {
3287            Err(err) => panic!("\n{err}"),
3288            Ok(ar) => ar,
3289        };
3290
3291        k9::snapshot!(
3292            &ar,
3293            r#"
3294AuthenticationResults {
3295    serv_id: "foo.example.net",
3296    version: Some(
3297        1,
3298    ),
3299    results: [
3300        AuthenticationResult {
3301            method: "dkim",
3302            method_version: Some(
3303                1,
3304            ),
3305            result: "fail",
3306            reason: None,
3307            props: {
3308                "policy.expired": "1362471462",
3309            },
3310        },
3311    ],
3312}
3313"#
3314        );
3315
3316        k9::snapshot!(
3317            ar.encode_value(),
3318            r#"
3319foo.example.net 1;\r
3320\tdkim/1=fail\r
3321\tpolicy.expired=1362471462
3322"#
3323        );
3324    }
3325}