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, if any
2035/// of the characters is NOT atext.  When quoting, the input
2036/// string is enclosed in quotes.
2037fn quote_string(s: &str) -> String {
2038    if s.chars().any(|c| !is_atext(c)) {
2039        let mut result = String::with_capacity(s.len() + 4);
2040        result.push('"');
2041        for c in s.chars() {
2042            if c != ' ' && !is_qtext(c) && !is_atext(c) {
2043                result.push('\\');
2044            }
2045            result.push(c);
2046        }
2047        result.push('"');
2048        result
2049    } else {
2050        s.to_string()
2051    }
2052}
2053
2054#[cfg(test)]
2055#[test]
2056fn test_quote_string() {
2057    k9::snapshot!(
2058        quote_string("TEST [ne_pas_repondre]"),
2059        r#""TEST [ne_pas_repondre]""#
2060    );
2061    k9::snapshot!(quote_string("hello"), "hello");
2062    k9::snapshot!(quote_string("hello there"), r#""hello there""#);
2063    k9::snapshot!(quote_string("hello, there"), "\"hello, there\"");
2064    k9::snapshot!(quote_string("hello \"there\""), r#""hello \\"there\\"""#);
2065    k9::snapshot!(
2066        quote_string("hello c:\\backslash"),
2067        r#""hello c:\\\\backslash""#
2068    );
2069}
2070
2071impl EncodeHeaderValue for Mailbox {
2072    fn encode_value(&self) -> SharedString<'static> {
2073        match &self.name {
2074            Some(name) => {
2075                let mut value = if name.is_ascii() {
2076                    quote_string(name)
2077                } else {
2078                    qp_encode(name)
2079                };
2080
2081                value.push_str(" <");
2082                value.push_str(&self.address.encode_value());
2083                value.push('>');
2084                value.into()
2085            }
2086            None => format!("<{}>", self.address.encode_value()).into(),
2087        }
2088    }
2089}
2090
2091impl EncodeHeaderValue for MailboxList {
2092    fn encode_value(&self) -> SharedString<'static> {
2093        let mut result = String::new();
2094        for mailbox in &self.0 {
2095            if !result.is_empty() {
2096                result.push_str(",\r\n\t");
2097            }
2098            result.push_str(&mailbox.encode_value());
2099        }
2100        result.into()
2101    }
2102}
2103
2104impl EncodeHeaderValue for Address {
2105    fn encode_value(&self) -> SharedString<'static> {
2106        match self {
2107            Self::Mailbox(mbox) => mbox.encode_value(),
2108            Self::Group { name, entries } => {
2109                let mut result = format!("{name}:");
2110                result += &entries.encode_value();
2111                result.push(';');
2112                result.into()
2113            }
2114        }
2115    }
2116}
2117
2118impl EncodeHeaderValue for AddressList {
2119    fn encode_value(&self) -> SharedString<'static> {
2120        let mut result = String::new();
2121        for address in &self.0 {
2122            if !result.is_empty() {
2123                result.push_str(",\r\n\t");
2124            }
2125            result.push_str(&address.encode_value());
2126        }
2127        result.into()
2128    }
2129}
2130
2131#[cfg(test)]
2132mod test {
2133    use super::*;
2134    use crate::{Header, MessageConformance, MimePart};
2135
2136    #[test]
2137    fn mailbox_encodes_at() {
2138        let mbox = Mailbox {
2139            name: Some("foo@bar.com".to_string()),
2140            address: AddrSpec {
2141                local_part: "foo".to_string(),
2142                domain: "bar.com".to_string(),
2143            },
2144        };
2145        assert_eq!(mbox.encode_value(), "\"foo@bar.com\" <foo@bar.com>");
2146    }
2147
2148    #[test]
2149    fn mailbox_list_singular() {
2150        let message = concat!(
2151            "From:  Someone (hello) <someone@example.com>, other@example.com,\n",
2152            "  \"John \\\"Smith\\\"\" (comment) \"More Quotes\" (more comment) <someone(another comment)@crazy.example.com(woot)>\n",
2153            "\n",
2154            "I am the body"
2155        );
2156        let msg = MimePart::parse(message).unwrap();
2157        let list = match msg.headers().from() {
2158            Err(err) => panic!("Doh.\n{err:#}"),
2159            Ok(list) => list,
2160        };
2161
2162        k9::snapshot!(
2163            list,
2164            r#"
2165Some(
2166    MailboxList(
2167        [
2168            Mailbox {
2169                name: Some(
2170                    "Someone",
2171                ),
2172                address: AddrSpec {
2173                    local_part: "someone",
2174                    domain: "example.com",
2175                },
2176            },
2177            Mailbox {
2178                name: None,
2179                address: AddrSpec {
2180                    local_part: "other",
2181                    domain: "example.com",
2182                },
2183            },
2184            Mailbox {
2185                name: Some(
2186                    "John "Smith" More Quotes",
2187                ),
2188                address: AddrSpec {
2189                    local_part: "someone",
2190                    domain: "crazy.example.com",
2191                },
2192            },
2193        ],
2194    ),
2195)
2196"#
2197        );
2198    }
2199
2200    #[test]
2201    fn docomo_non_compliant_localpart() {
2202        let message = "Sender: hello..there@docomo.ne.jp\n\n\n";
2203        let msg = MimePart::parse(message).unwrap();
2204        let err = msg.headers().sender().unwrap_err();
2205        k9::snapshot!(
2206            err,
2207            r#"
2208InvalidHeaderValueDuringGet {
2209    header_name: "Sender",
2210    error: HeaderParse(
2211        "0: at line 1:
2212hello..there@docomo.ne.jp
2213     ^___________________
2214expected '@', found .
2215
22161: at line 1, in addr_spec:
2217hello..there@docomo.ne.jp
2218^________________________
2219
22202: at line 1, in mailbox:
2221hello..there@docomo.ne.jp
2222^________________________
2223
2224",
2225    ),
2226}
2227"#
2228        );
2229    }
2230
2231    #[test]
2232    fn sender() {
2233        let message = "Sender: someone@[127.0.0.1]\n\n\n";
2234        let msg = MimePart::parse(message).unwrap();
2235        let list = match msg.headers().sender() {
2236            Err(err) => panic!("Doh.\n{err:#}"),
2237            Ok(list) => list,
2238        };
2239        k9::snapshot!(
2240            list,
2241            r#"
2242Some(
2243    Mailbox {
2244        name: None,
2245        address: AddrSpec {
2246            local_part: "someone",
2247            domain: "[127.0.0.1]",
2248        },
2249    },
2250)
2251"#
2252        );
2253    }
2254
2255    #[test]
2256    fn domain_literal() {
2257        let message = "From: someone@[127.0.0.1]\n\n\n";
2258        let msg = MimePart::parse(message).unwrap();
2259        let list = match msg.headers().from() {
2260            Err(err) => panic!("Doh.\n{err:#}"),
2261            Ok(list) => list,
2262        };
2263        k9::snapshot!(
2264            list,
2265            r#"
2266Some(
2267    MailboxList(
2268        [
2269            Mailbox {
2270                name: None,
2271                address: AddrSpec {
2272                    local_part: "someone",
2273                    domain: "[127.0.0.1]",
2274                },
2275            },
2276        ],
2277    ),
2278)
2279"#
2280        );
2281    }
2282
2283    #[test]
2284    fn rfc6532() {
2285        let message = concat!(
2286            "From: Keith Moore <moore@cs.utk.edu>\n",
2287            "To: Keld Jørn Simonsen <keld@dkuug.dk>\n",
2288            "CC: André Pirard <PIRARD@vm1.ulg.ac.be>\n",
2289            "Subject: Hello André\n",
2290            "\n\n"
2291        );
2292        let msg = MimePart::parse(message).unwrap();
2293        let list = match msg.headers().from() {
2294            Err(err) => panic!("Doh.\n{err:#}"),
2295            Ok(list) => list,
2296        };
2297        k9::snapshot!(
2298            list,
2299            r#"
2300Some(
2301    MailboxList(
2302        [
2303            Mailbox {
2304                name: Some(
2305                    "Keith Moore",
2306                ),
2307                address: AddrSpec {
2308                    local_part: "moore",
2309                    domain: "cs.utk.edu",
2310                },
2311            },
2312        ],
2313    ),
2314)
2315"#
2316        );
2317
2318        let list = match msg.headers().to() {
2319            Err(err) => panic!("Doh.\n{err:#}"),
2320            Ok(list) => list,
2321        };
2322        k9::snapshot!(
2323            list,
2324            r#"
2325Some(
2326    AddressList(
2327        [
2328            Mailbox(
2329                Mailbox {
2330                    name: Some(
2331                        "Keld Jørn Simonsen",
2332                    ),
2333                    address: AddrSpec {
2334                        local_part: "keld",
2335                        domain: "dkuug.dk",
2336                    },
2337                },
2338            ),
2339        ],
2340    ),
2341)
2342"#
2343        );
2344
2345        let list = match msg.headers().cc() {
2346            Err(err) => panic!("Doh.\n{err:#}"),
2347            Ok(list) => list,
2348        };
2349        k9::snapshot!(
2350            list,
2351            r#"
2352Some(
2353    AddressList(
2354        [
2355            Mailbox(
2356                Mailbox {
2357                    name: Some(
2358                        "André Pirard",
2359                    ),
2360                    address: AddrSpec {
2361                        local_part: "PIRARD",
2362                        domain: "vm1.ulg.ac.be",
2363                    },
2364                },
2365            ),
2366        ],
2367    ),
2368)
2369"#
2370        );
2371        let list = match msg.headers().subject() {
2372            Err(err) => panic!("Doh.\n{err:#}"),
2373            Ok(list) => list,
2374        };
2375        k9::snapshot!(
2376            list,
2377            r#"
2378Some(
2379    "Hello André",
2380)
2381"#
2382        );
2383    }
2384
2385    #[test]
2386    fn rfc2047_bogus() {
2387        let message = concat!(
2388            "From: =?US-OSCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2389            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2390            "CC: =?ISO-8859-1?Q?Andr=E?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2391            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?=\n",
2392            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2393            "\n\n"
2394        );
2395        let msg = MimePart::parse(message).unwrap();
2396
2397        // Invalid charset causes encoded_word to fail and we will instead match
2398        // obs_utext and return it as it was
2399        k9::assert_equal!(
2400            msg.headers().from().unwrap().unwrap().0[0]
2401                .name
2402                .as_ref()
2403                .unwrap(),
2404            "=?US-OSCII?Q?Keith_Moore?="
2405        );
2406
2407        match &msg.headers().cc().unwrap().unwrap().0[0] {
2408            Address::Mailbox(mbox) => {
2409                // 'Andr=E9?=' is in the non-bogus example below, but above we
2410                // broke it as 'Andr=E?=', and instead of triggering a qp decode
2411                // error, it is passed through here as-is
2412                k9::assert_equal!(mbox.name.as_ref().unwrap(), "Andr=E Pirard");
2413            }
2414            wat => panic!("should not have {wat:?}"),
2415        }
2416
2417        // The invalid base64 (an I was replaced by an !) is interpreted as obs_utext
2418        // and passed through to us
2419        k9::assert_equal!(
2420            msg.headers().subject().unwrap().unwrap(),
2421            "Hello =?ISO-8859-1?B?SWYgeW91IGNhb!ByZWFkIHRoaXMgeW8=?= u understand the example."
2422        );
2423    }
2424
2425    #[test]
2426    fn attachment_filename_mess_totally_bogus() {
2427        let message = concat!("Content-Disposition: attachment; filename=@\n", "\n\n");
2428        let msg = MimePart::parse(message).unwrap();
2429        eprintln!("{msg:#?}");
2430
2431        assert!(msg
2432            .conformance()
2433            .contains(MessageConformance::INVALID_MIME_HEADERS));
2434        msg.headers().content_disposition().unwrap_err();
2435
2436        // There is no Content-Disposition in the rebuilt message, because
2437        // there was no valid Content-Disposition in what we parsed
2438        let rebuilt = msg.rebuild().unwrap();
2439        k9::assert_equal!(rebuilt.headers().content_disposition(), Ok(None));
2440    }
2441
2442    #[test]
2443    fn attachment_filename_mess_aberrant() {
2444        let message = concat!(
2445            "Content-Disposition: attachment; filename= =?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\n",
2446            "\n\n"
2447        );
2448        let msg = MimePart::parse(message).unwrap();
2449
2450        let cd = msg.headers().content_disposition().unwrap().unwrap();
2451        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
2452
2453        let encoded = cd.encode_value();
2454        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?=");
2455    }
2456
2457    #[test]
2458    fn attachment_filename_mess_gmail() {
2459        let message = concat!(
2460            "Content-Disposition: attachment; filename=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
2461            "Content-Type: text/plain;\n",
2462            "   name=\"=?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\"\n",
2463            "\n\n"
2464        );
2465        let msg = MimePart::parse(message).unwrap();
2466
2467        let cd = msg.headers().content_disposition().unwrap().unwrap();
2468        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付");
2469        let encoded = cd.encode_value();
2470        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?=\"");
2471
2472        let ct = msg.headers().content_type().unwrap().unwrap();
2473        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付");
2474    }
2475
2476    #[test]
2477    fn attachment_filename_mess_fastmail() {
2478        let message = concat!(
2479            "Content-Disposition: attachment;\n",
2480            "  filename*0*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98;\n",
2481            "  filename*1*=.txt\n",
2482            "Content-Type: text/plain;\n",
2483            "   name=\"=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=\"\n",
2484            "   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",
2485            "\n\n"
2486        );
2487        let msg = MimePart::parse(message).unwrap();
2488
2489        let cd = msg.headers().content_disposition().unwrap().unwrap();
2490        k9::assert_equal!(cd.get("filename").unwrap(), "日本語の添付.txt");
2491
2492        let ct = msg.headers().content_type().unwrap().unwrap();
2493        eprintln!("{ct:#?}");
2494        k9::assert_equal!(ct.get("name").unwrap(), "日本語の添付.txt");
2495        k9::assert_equal!(
2496            ct.get("x-name").unwrap(),
2497            "=?UTF-8?Q?=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE=E6=B7=BB=E4=BB=98.txt?=bork"
2498        );
2499    }
2500
2501    #[test]
2502    fn rfc2047() {
2503        let message = concat!(
2504            "From: =?US-ASCII?Q?Keith_Moore?= <moore@cs.utk.edu>\n",
2505            "To: =?ISO-8859-1*en-us?Q?Keld_J=F8rn_Simonsen?= <keld@dkuug.dk>\n",
2506            "CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>\n",
2507            "Subject: Hello =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\n",
2508            "  =?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\n",
2509            "\n\n"
2510        );
2511        let msg = MimePart::parse(message).unwrap();
2512        let list = match msg.headers().from() {
2513            Err(err) => panic!("Doh.\n{err:#}"),
2514            Ok(list) => list,
2515        };
2516        k9::snapshot!(
2517            list,
2518            r#"
2519Some(
2520    MailboxList(
2521        [
2522            Mailbox {
2523                name: Some(
2524                    "Keith Moore",
2525                ),
2526                address: AddrSpec {
2527                    local_part: "moore",
2528                    domain: "cs.utk.edu",
2529                },
2530            },
2531        ],
2532    ),
2533)
2534"#
2535        );
2536
2537        let list = match msg.headers().to() {
2538            Err(err) => panic!("Doh.\n{err:#}"),
2539            Ok(list) => list,
2540        };
2541        k9::snapshot!(
2542            list,
2543            r#"
2544Some(
2545    AddressList(
2546        [
2547            Mailbox(
2548                Mailbox {
2549                    name: Some(
2550                        "Keld Jørn Simonsen",
2551                    ),
2552                    address: AddrSpec {
2553                        local_part: "keld",
2554                        domain: "dkuug.dk",
2555                    },
2556                },
2557            ),
2558        ],
2559    ),
2560)
2561"#
2562        );
2563
2564        let list = match msg.headers().cc() {
2565            Err(err) => panic!("Doh.\n{err:#}"),
2566            Ok(list) => list,
2567        };
2568        k9::snapshot!(
2569            list,
2570            r#"
2571Some(
2572    AddressList(
2573        [
2574            Mailbox(
2575                Mailbox {
2576                    name: Some(
2577                        "André Pirard",
2578                    ),
2579                    address: AddrSpec {
2580                        local_part: "PIRARD",
2581                        domain: "vm1.ulg.ac.be",
2582                    },
2583                },
2584            ),
2585        ],
2586    ),
2587)
2588"#
2589        );
2590        let list = match msg.headers().subject() {
2591            Err(err) => panic!("Doh.\n{err:#}"),
2592            Ok(list) => list,
2593        };
2594        k9::snapshot!(
2595            list,
2596            r#"
2597Some(
2598    "Hello If you can read this you understand the example.",
2599)
2600"#
2601        );
2602
2603        k9::snapshot!(
2604            msg.rebuild().unwrap().to_message_string(),
2605            r#"
2606Content-Type: text/plain;\r
2607\tcharset="us-ascii"\r
2608Content-Transfer-Encoding: quoted-printable\r
2609From: "Keith Moore" <moore@cs.utk.edu>\r
2610To: =?UTF-8?q?Keld_J=C3=B8rn_Simonsen?= <keld@dkuug.dk>\r
2611Cc: =?UTF-8?q?Andr=C3=A9_Pirard?= <PIRARD@vm1.ulg.ac.be>\r
2612Subject: Hello If you can read this you understand the example.\r
2613\r
2614=0A\r
2615
2616"#
2617        );
2618    }
2619
2620    #[test]
2621    fn group_addresses() {
2622        let message = concat!(
2623            "To: A Group:Ed Jones <c@a.test>,joe@where.test,John <jdoe@one.test>;\n",
2624            "Cc: Undisclosed recipients:;\n",
2625            "\n\n\n"
2626        );
2627        let msg = MimePart::parse(message).unwrap();
2628        let list = match msg.headers().to() {
2629            Err(err) => panic!("Doh.\n{err:#}"),
2630            Ok(list) => list.unwrap(),
2631        };
2632
2633        k9::snapshot!(
2634            list.encode_value(),
2635            r#"
2636A Group:"Ed Jones" <c@a.test>,\r
2637\t<joe@where.test>,\r
2638\tJohn <jdoe@one.test>;
2639"#
2640        );
2641
2642        let round_trip = Header::new("To", list.clone());
2643        k9::assert_equal!(list, round_trip.as_address_list().unwrap());
2644
2645        k9::snapshot!(
2646            list,
2647            r#"
2648AddressList(
2649    [
2650        Group {
2651            name: "A Group",
2652            entries: MailboxList(
2653                [
2654                    Mailbox {
2655                        name: Some(
2656                            "Ed Jones",
2657                        ),
2658                        address: AddrSpec {
2659                            local_part: "c",
2660                            domain: "a.test",
2661                        },
2662                    },
2663                    Mailbox {
2664                        name: None,
2665                        address: AddrSpec {
2666                            local_part: "joe",
2667                            domain: "where.test",
2668                        },
2669                    },
2670                    Mailbox {
2671                        name: Some(
2672                            "John",
2673                        ),
2674                        address: AddrSpec {
2675                            local_part: "jdoe",
2676                            domain: "one.test",
2677                        },
2678                    },
2679                ],
2680            ),
2681        },
2682    ],
2683)
2684"#
2685        );
2686
2687        let list = match msg.headers().cc() {
2688            Err(err) => panic!("Doh.\n{err:#}"),
2689            Ok(list) => list,
2690        };
2691        k9::snapshot!(
2692            list,
2693            r#"
2694Some(
2695    AddressList(
2696        [
2697            Group {
2698                name: "Undisclosed recipients",
2699                entries: MailboxList(
2700                    [],
2701                ),
2702            },
2703        ],
2704    ),
2705)
2706"#
2707        );
2708    }
2709
2710    #[test]
2711    fn message_id() {
2712        let message = concat!(
2713            "Message-Id: <foo@example.com>\n",
2714            "References: <a@example.com> <b@example.com>\n",
2715            "  <\"legacy\"@example.com>\n",
2716            "  <literal@[127.0.0.1]>\n",
2717            "\n\n\n"
2718        );
2719        let msg = MimePart::parse(message).unwrap();
2720        let list = match msg.headers().message_id() {
2721            Err(err) => panic!("Doh.\n{err:#}"),
2722            Ok(list) => list,
2723        };
2724        k9::snapshot!(
2725            list,
2726            r#"
2727Some(
2728    MessageID(
2729        "foo@example.com",
2730    ),
2731)
2732"#
2733        );
2734
2735        let list = match msg.headers().references() {
2736            Err(err) => panic!("Doh.\n{err:#}"),
2737            Ok(list) => list,
2738        };
2739        k9::snapshot!(
2740            list,
2741            r#"
2742Some(
2743    [
2744        MessageID(
2745            "a@example.com",
2746        ),
2747        MessageID(
2748            "b@example.com",
2749        ),
2750        MessageID(
2751            "legacy@example.com",
2752        ),
2753        MessageID(
2754            "literal@[127.0.0.1]",
2755        ),
2756    ],
2757)
2758"#
2759        );
2760    }
2761
2762    #[test]
2763    fn content_type() {
2764        let message = "Content-Type: text/plain\n\n\n\n";
2765        let msg = MimePart::parse(message).unwrap();
2766        let params = match msg.headers().content_type() {
2767            Err(err) => panic!("Doh.\n{err:#}"),
2768            Ok(params) => params,
2769        };
2770        k9::snapshot!(
2771            params,
2772            r#"
2773Some(
2774    MimeParameters {
2775        value: "text/plain",
2776        parameters: [],
2777    },
2778)
2779"#
2780        );
2781
2782        let message = "Content-Type: text/plain; charset=us-ascii\n\n\n\n";
2783        let msg = MimePart::parse(message).unwrap();
2784        let params = match msg.headers().content_type() {
2785            Err(err) => panic!("Doh.\n{err:#}"),
2786            Ok(params) => params.unwrap(),
2787        };
2788
2789        k9::snapshot!(
2790            params.get("charset"),
2791            r#"
2792Some(
2793    "us-ascii",
2794)
2795"#
2796        );
2797        k9::snapshot!(
2798            params,
2799            r#"
2800MimeParameters {
2801    value: "text/plain",
2802    parameters: [
2803        MimeParameter {
2804            name: "charset",
2805            section: None,
2806            mime_charset: None,
2807            mime_language: None,
2808            encoding: None,
2809            value: "us-ascii",
2810        },
2811    ],
2812}
2813"#
2814        );
2815
2816        let message = "Content-Type: text/plain; charset=\"us-ascii\"\n\n\n\n";
2817        let msg = MimePart::parse(message).unwrap();
2818        let params = match msg.headers().content_type() {
2819            Err(err) => panic!("Doh.\n{err:#}"),
2820            Ok(params) => params,
2821        };
2822        k9::snapshot!(
2823            params,
2824            r#"
2825Some(
2826    MimeParameters {
2827        value: "text/plain",
2828        parameters: [
2829            MimeParameter {
2830                name: "charset",
2831                section: None,
2832                mime_charset: None,
2833                mime_language: None,
2834                encoding: None,
2835                value: "us-ascii",
2836            },
2837        ],
2838    },
2839)
2840"#
2841        );
2842    }
2843
2844    #[test]
2845    fn content_type_rfc2231() {
2846        // This example is taken from the errata for rfc2231.
2847        // <https://www.rfc-editor.org/errata/eid590>
2848        let message = concat!(
2849            "Content-Type: application/x-stuff;\n",
2850            "\ttitle*0*=us-ascii'en'This%20is%20even%20more%20;\n",
2851            "\ttitle*1*=%2A%2A%2Afun%2A%2A%2A%20;\n",
2852            "\ttitle*2=\"isn't it!\"\n",
2853            "\n\n\n"
2854        );
2855        let msg = MimePart::parse(message).unwrap();
2856        let mut params = match msg.headers().content_type() {
2857            Err(err) => panic!("Doh.\n{err:#}"),
2858            Ok(params) => params.unwrap(),
2859        };
2860
2861        let original_title = params.get("title");
2862        k9::snapshot!(
2863            &original_title,
2864            r#"
2865Some(
2866    "This is even more ***fun*** isn't it!",
2867)
2868"#
2869        );
2870
2871        k9::snapshot!(
2872            &params,
2873            r#"
2874MimeParameters {
2875    value: "application/x-stuff",
2876    parameters: [
2877        MimeParameter {
2878            name: "title",
2879            section: Some(
2880                0,
2881            ),
2882            mime_charset: Some(
2883                "us-ascii",
2884            ),
2885            mime_language: Some(
2886                "en",
2887            ),
2888            encoding: Rfc2231,
2889            value: "This%20is%20even%20more%20",
2890        },
2891        MimeParameter {
2892            name: "title",
2893            section: Some(
2894                1,
2895            ),
2896            mime_charset: None,
2897            mime_language: None,
2898            encoding: Rfc2231,
2899            value: "%2A%2A%2Afun%2A%2A%2A%20",
2900        },
2901        MimeParameter {
2902            name: "title",
2903            section: Some(
2904                2,
2905            ),
2906            mime_charset: None,
2907            mime_language: None,
2908            encoding: None,
2909            value: "isn't it!",
2910        },
2911    ],
2912}
2913"#
2914        );
2915
2916        k9::snapshot!(
2917            params.encode_value(),
2918            r#"
2919application/x-stuff;\r
2920\ttitle="This is even more ***fun*** isn't it!"
2921"#
2922        );
2923
2924        params.set("foo", "bar 💩");
2925
2926        params.set(
2927            "long",
2928            "this is some text that should wrap because \
2929                it should be a good bit longer than our target maximum \
2930                length for this sort of thing, and hopefully we see at \
2931                least three lines produced as a result of setting \
2932                this value in this way",
2933        );
2934
2935        params.set(
2936            "longernnamethananyoneshouldreallyuse",
2937            "this is some text that should wrap because \
2938                it should be a good bit longer than our target maximum \
2939                length for this sort of thing, and hopefully we see at \
2940                least three lines produced as a result of setting \
2941                this value in this way",
2942        );
2943
2944        k9::snapshot!(
2945            params.encode_value(),
2946            r#"
2947application/x-stuff;\r
2948\tfoo*=UTF-8''bar%20%F0%9F%92%A9;\r
2949\tlong*0="this is some text that should wrap because it should be a good bi";\r
2950\tlong*1="t longer than our target maximum length for this sort of thing, a";\r
2951\tlong*2="nd hopefully we see at least three lines produced as a result of ";\r
2952\tlong*3="setting this value in this way";\r
2953\tlongernnamethananyoneshouldreallyuse*0="this is some text that should wra";\r
2954\tlongernnamethananyoneshouldreallyuse*1="p because it should be a good bit";\r
2955\tlongernnamethananyoneshouldreallyuse*2=" longer than our target maximum l";\r
2956\tlongernnamethananyoneshouldreallyuse*3="ength for this sort of thing, and";\r
2957\tlongernnamethananyoneshouldreallyuse*4=" hopefully we see at least three ";\r
2958\tlongernnamethananyoneshouldreallyuse*5="lines produced as a result of set";\r
2959\tlongernnamethananyoneshouldreallyuse*6="ting this value in this way";\r
2960\ttitle="This is even more ***fun*** isn't it!"
2961"#
2962        );
2963    }
2964
2965    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.2>
2966    #[test]
2967    fn authentication_results_b_2() {
2968        let ar = Header::with_name_value("Authentication-Results", "example.org 1; none");
2969        let ar = ar.as_authentication_results().unwrap();
2970        k9::snapshot!(
2971            &ar,
2972            r#"
2973AuthenticationResults {
2974    serv_id: "example.org",
2975    version: Some(
2976        1,
2977    ),
2978    results: [],
2979}
2980"#
2981        );
2982
2983        k9::snapshot!(ar.encode_value(), "example.org 1; none");
2984    }
2985
2986    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.3>
2987    #[test]
2988    fn authentication_results_b_3() {
2989        let ar = Header::with_name_value(
2990            "Authentication-Results",
2991            "example.com; spf=pass smtp.mailfrom=example.net",
2992        );
2993        k9::snapshot!(
2994            ar.as_authentication_results(),
2995            r#"
2996Ok(
2997    AuthenticationResults {
2998        serv_id: "example.com",
2999        version: None,
3000        results: [
3001            AuthenticationResult {
3002                method: "spf",
3003                method_version: None,
3004                result: "pass",
3005                reason: None,
3006                props: {
3007                    "smtp.mailfrom": "example.net",
3008                },
3009            },
3010        ],
3011    },
3012)
3013"#
3014        );
3015    }
3016
3017    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.4>
3018    #[test]
3019    fn authentication_results_b_4() {
3020        let ar = Header::with_name_value(
3021            "Authentication-Results",
3022            concat!(
3023                "example.com;\n",
3024                "\tauth=pass (cram-md5) smtp.auth=sender@example.net;\n",
3025                "\tspf=pass smtp.mailfrom=example.net"
3026            ),
3027        );
3028        k9::snapshot!(
3029            ar.as_authentication_results(),
3030            r#"
3031Ok(
3032    AuthenticationResults {
3033        serv_id: "example.com",
3034        version: None,
3035        results: [
3036            AuthenticationResult {
3037                method: "auth",
3038                method_version: None,
3039                result: "pass",
3040                reason: None,
3041                props: {
3042                    "smtp.auth": "sender@example.net",
3043                },
3044            },
3045            AuthenticationResult {
3046                method: "spf",
3047                method_version: None,
3048                result: "pass",
3049                reason: None,
3050                props: {
3051                    "smtp.mailfrom": "example.net",
3052                },
3053            },
3054        ],
3055    },
3056)
3057"#
3058        );
3059
3060        let ar = Header::with_name_value(
3061            "Authentication-Results",
3062            "example.com; iprev=pass\n\tpolicy.iprev=192.0.2.200",
3063        );
3064        k9::snapshot!(
3065            ar.as_authentication_results(),
3066            r#"
3067Ok(
3068    AuthenticationResults {
3069        serv_id: "example.com",
3070        version: None,
3071        results: [
3072            AuthenticationResult {
3073                method: "iprev",
3074                method_version: None,
3075                result: "pass",
3076                reason: None,
3077                props: {
3078                    "policy.iprev": "192.0.2.200",
3079                },
3080            },
3081        ],
3082    },
3083)
3084"#
3085        );
3086    }
3087
3088    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.5>
3089    #[test]
3090    fn authentication_results_b_5() {
3091        let ar = Header::with_name_value(
3092            "Authentication-Results",
3093            "example.com;\n\tdkim=pass (good signature) header.d=example.com",
3094        );
3095        k9::snapshot!(
3096            ar.as_authentication_results(),
3097            r#"
3098Ok(
3099    AuthenticationResults {
3100        serv_id: "example.com",
3101        version: None,
3102        results: [
3103            AuthenticationResult {
3104                method: "dkim",
3105                method_version: None,
3106                result: "pass",
3107                reason: None,
3108                props: {
3109                    "header.d": "example.com",
3110                },
3111            },
3112        ],
3113    },
3114)
3115"#
3116        );
3117
3118        let ar = Header::with_name_value(
3119            "Authentication-Results",
3120            "example.com;\n\tauth=pass (cram-md5) smtp.auth=sender@example.com;\n\tspf=fail smtp.mailfrom=example.com"
3121        );
3122        let ar = ar.as_authentication_results().unwrap();
3123        k9::snapshot!(
3124            &ar,
3125            r#"
3126AuthenticationResults {
3127    serv_id: "example.com",
3128    version: None,
3129    results: [
3130        AuthenticationResult {
3131            method: "auth",
3132            method_version: None,
3133            result: "pass",
3134            reason: None,
3135            props: {
3136                "smtp.auth": "sender@example.com",
3137            },
3138        },
3139        AuthenticationResult {
3140            method: "spf",
3141            method_version: None,
3142            result: "fail",
3143            reason: None,
3144            props: {
3145                "smtp.mailfrom": "example.com",
3146            },
3147        },
3148    ],
3149}
3150"#
3151        );
3152
3153        k9::snapshot!(
3154            ar.encode_value(),
3155            r#"
3156example.com;\r
3157\tauth=pass\r
3158\tsmtp.auth=sender@example.com;\r
3159\tspf=fail\r
3160\tsmtp.mailfrom=example.com
3161"#
3162        );
3163    }
3164
3165    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.6>
3166    #[test]
3167    fn authentication_results_b_6() {
3168        let ar = Header::with_name_value(
3169            "Authentication-Results",
3170            concat!(
3171                "example.com;\n",
3172                "\tdkim=pass reason=\"good signature\"\n",
3173                "\theader.i=@mail-router.example.net;\n",
3174                "\tdkim=fail reason=\"bad signature\"\n",
3175                "\theader.i=@newyork.example.com"
3176            ),
3177        );
3178        let ar = match ar.as_authentication_results() {
3179            Err(err) => panic!("\n{err}"),
3180            Ok(ar) => ar,
3181        };
3182
3183        k9::snapshot!(
3184            &ar,
3185            r#"
3186AuthenticationResults {
3187    serv_id: "example.com",
3188    version: None,
3189    results: [
3190        AuthenticationResult {
3191            method: "dkim",
3192            method_version: None,
3193            result: "pass",
3194            reason: Some(
3195                "good signature",
3196            ),
3197            props: {
3198                "header.i": "@mail-router.example.net",
3199            },
3200        },
3201        AuthenticationResult {
3202            method: "dkim",
3203            method_version: None,
3204            result: "fail",
3205            reason: Some(
3206                "bad signature",
3207            ),
3208            props: {
3209                "header.i": "@newyork.example.com",
3210            },
3211        },
3212    ],
3213}
3214"#
3215        );
3216
3217        k9::snapshot!(
3218            ar.encode_value(),
3219            r#"
3220example.com;\r
3221\tdkim=pass reason="good signature"\r
3222\theader.i=@mail-router.example.net;\r
3223\tdkim=fail reason="bad signature"\r
3224\theader.i=@newyork.example.com
3225"#
3226        );
3227
3228        let ar = Header::with_name_value(
3229            "Authentication-Results",
3230            concat!(
3231                "example.net;\n",
3232                "\tdkim=pass (good signature) header.i=@newyork.example.com"
3233            ),
3234        );
3235        let ar = match ar.as_authentication_results() {
3236            Err(err) => panic!("\n{err}"),
3237            Ok(ar) => ar,
3238        };
3239
3240        k9::snapshot!(
3241            &ar,
3242            r#"
3243AuthenticationResults {
3244    serv_id: "example.net",
3245    version: None,
3246    results: [
3247        AuthenticationResult {
3248            method: "dkim",
3249            method_version: None,
3250            result: "pass",
3251            reason: None,
3252            props: {
3253                "header.i": "@newyork.example.com",
3254            },
3255        },
3256    ],
3257}
3258"#
3259        );
3260
3261        k9::snapshot!(
3262            ar.encode_value(),
3263            r#"
3264example.net;\r
3265\tdkim=pass\r
3266\theader.i=@newyork.example.com
3267"#
3268        );
3269    }
3270
3271    /// <https://datatracker.ietf.org/doc/html/rfc8601#appendix-B.7>
3272    #[test]
3273    fn authentication_results_b_7() {
3274        let ar = Header::with_name_value(
3275            "Authentication-Results",
3276            concat!(
3277                "foo.example.net (foobar) 1 (baz);\n",
3278                "\tdkim (Because I like it) / 1 (One yay) = (wait for it) fail\n",
3279                "\tpolicy (A dot can go here) . (like that) expired\n",
3280                "\t(this surprised me) = (as I wasn't expecting it) 1362471462"
3281            ),
3282        );
3283        let ar = match ar.as_authentication_results() {
3284            Err(err) => panic!("\n{err}"),
3285            Ok(ar) => ar,
3286        };
3287
3288        k9::snapshot!(
3289            &ar,
3290            r#"
3291AuthenticationResults {
3292    serv_id: "foo.example.net",
3293    version: Some(
3294        1,
3295    ),
3296    results: [
3297        AuthenticationResult {
3298            method: "dkim",
3299            method_version: Some(
3300                1,
3301            ),
3302            result: "fail",
3303            reason: None,
3304            props: {
3305                "policy.expired": "1362471462",
3306            },
3307        },
3308    ],
3309}
3310"#
3311        );
3312
3313        k9::snapshot!(
3314            ar.encode_value(),
3315            r#"
3316foo.example.net 1;\r
3317\tdkim/1=fail\r
3318\tpolicy.expired=1362471462
3319"#
3320        );
3321    }
3322}