mailparsing/
parsed_header.rs

1use crate::headermap::EncodeHeaderValue;
2use crate::rfc5322_parser::{qp_encode, Parser};
3use crate::{
4    AddressList, AuthenticationResults, MailParsingError, Mailbox, MailboxList, MessageID,
5    MimeParameters, Result, SharedString,
6};
7use bstr::BString;
8use chrono::{DateTime, FixedOffset};
9
10/// The parsed, typed value of a header, as distinct from the raw wire form
11/// held by `Header`. A header's name determines the grammar its bytes are
12/// parsed with (see `ParsedHeader::structured`), so callers reach a value
13/// of the correct type without choosing a parser themselves.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum ParsedHeader {
16    MailboxList(MailboxList),
17    Mailbox(Mailbox),
18    AddressList(AddressList),
19    MessageId(MessageID),
20    MessageIdList(Vec<MessageID>),
21    MimeParameters(MimeParameters),
22    Date(DateTime<FixedOffset>),
23    AuthenticationResults(AuthenticationResults),
24    Unstructured(BString),
25}
26
27impl ParsedHeader {
28    /// Parse `value` as the header named by `name`, selecting the grammar
29    /// from the name. An unrecognized name is treated as unstructured.
30    pub fn structured(name: &[u8], value: &[u8]) -> Result<Self> {
31        Ok(match grammar_for_name(name) {
32            Grammar::MailboxList => Self::MailboxList(Parser::parse_mailbox_list_header(value)?),
33            Grammar::Mailbox => Self::Mailbox(Parser::parse_mailbox_header(value)?),
34            Grammar::AddressList => Self::AddressList(Parser::parse_address_list_header(value)?),
35            Grammar::MessageId => Self::MessageId(Parser::parse_msg_id_header(value)?),
36            Grammar::ContentId => Self::MessageId(Parser::parse_content_id_header(value)?),
37            Grammar::MessageIdList => Self::MessageIdList(Parser::parse_msg_id_header_list(value)?),
38            Grammar::ContentType => Self::MimeParameters(Parser::parse_content_type_header(value)?),
39            // No dedicated Content-Disposition grammar exists. This reuses the
40            // CTE parameter grammar, kept unchanged from the old rebuild()
41            // dispatch.
42            Grammar::ContentTransferEncoding | Grammar::ContentDisposition => {
43                Self::MimeParameters(Parser::parse_content_transfer_encoding_header(value)?)
44            }
45            Grammar::Date => {
46                let value = std::str::from_utf8(value).map_err(|_| MailParsingError::EightBit)?;
47                Self::Date(crate::parse_rfc2822_date(value).map_err(MailParsingError::ChronoError)?)
48            }
49            Grammar::AuthenticationResults => {
50                Self::AuthenticationResults(Parser::parse_authentication_results_header(value)?)
51            }
52            Grammar::Unstructured => Self::Unstructured(Parser::parse_unstructured_header(value)?),
53        })
54    }
55}
56
57impl EncodeHeaderValue for ParsedHeader {
58    fn encode_value(&self) -> SharedString<'static> {
59        match self {
60            Self::MailboxList(v) => v.encode_value(),
61            Self::Mailbox(v) => v.encode_value(),
62            Self::AddressList(v) => v.encode_value(),
63            Self::MessageId(v) => v.encode_value(),
64            Self::MessageIdList(v) => v.encode_value(),
65            Self::MimeParameters(v) => v.encode_value(),
66            Self::Date(v) => v.encode_value(),
67            Self::AuthenticationResults(v) => v.encode_value(),
68            Self::Unstructured(v) => encode_unstructured_value(v.as_slice()),
69        }
70    }
71}
72
73/// Encode a free-text header value: fold a plain-ASCII value at whitespace,
74/// or wrap a value with non-ASCII bytes in an RFC 2047 encoded-word.
75/// `Header::new_unstructured` and `ParsedHeader`'s unstructured arm share
76/// this so a value encodes the same either way.
77pub(crate) fn encode_unstructured_value(value: &[u8]) -> SharedString<'static> {
78    match std::str::from_utf8(value) {
79        Ok(value) if value.is_ascii() => kumo_wrap::wrap_bytes(value).into(),
80        Ok(value) => qp_encode(value.as_bytes()).into(),
81        Err(_) => kumo_wrap::wrap_bytes(value).into(),
82    }
83}
84
85/// The grammar a header's value is parsed with. Several names share a
86/// grammar, and several grammars yield the same `ParsedHeader` value type,
87/// so this stays private: callers key off the header name, never this tag.
88#[derive(Clone, Copy, PartialEq, Eq)]
89enum Grammar {
90    MailboxList,
91    Mailbox,
92    AddressList,
93    MessageId,
94    ContentId,
95    MessageIdList,
96    ContentType,
97    ContentTransferEncoding,
98    ContentDisposition,
99    Date,
100    AuthenticationResults,
101    Unstructured,
102}
103
104/// The header names with a defined grammar or canonical spelling. This is
105/// the single source of truth behind `grammar_for_name`,
106/// `canonical_header_name`, and `is_address_header_name`. The unstructured
107/// entries earn their place by fixing the canonical casing of headers whose
108/// value is otherwise free text.
109const KNOWN_HEADERS: &[(&str, Grammar)] = &[
110    ("From", Grammar::MailboxList),
111    ("Resent-From", Grammar::MailboxList),
112    ("Sender", Grammar::Mailbox),
113    ("Resent-Sender", Grammar::Mailbox),
114    ("Reply-To", Grammar::AddressList),
115    ("To", Grammar::AddressList),
116    ("Cc", Grammar::AddressList),
117    ("Bcc", Grammar::AddressList),
118    ("Resent-To", Grammar::AddressList),
119    ("Resent-Cc", Grammar::AddressList),
120    ("Resent-Bcc", Grammar::AddressList),
121    ("Date", Grammar::Date),
122    ("Message-ID", Grammar::MessageId),
123    ("Content-ID", Grammar::ContentId),
124    ("References", Grammar::MessageIdList),
125    ("Content-Type", Grammar::ContentType),
126    (
127        "Content-Transfer-Encoding",
128        Grammar::ContentTransferEncoding,
129    ),
130    ("Content-Disposition", Grammar::ContentDisposition),
131    ("Authentication-Results", Grammar::AuthenticationResults),
132    ("Subject", Grammar::Unstructured),
133    ("Comments", Grammar::Unstructured),
134    ("MIME-Version", Grammar::Unstructured),
135];
136
137fn grammar_for_name(name: &[u8]) -> Grammar {
138    KNOWN_HEADERS
139        .iter()
140        .find(|(known, _)| known.as_bytes().eq_ignore_ascii_case(name))
141        .map(|(_, grammar)| *grammar)
142        .unwrap_or(Grammar::Unstructured)
143}
144
145/// The canonical spelling of `name`, or None when it isn't a known header.
146pub fn canonical_header_name(name: &[u8]) -> Option<&'static str> {
147    KNOWN_HEADERS
148        .iter()
149        .find(|(known, _)| known.as_bytes().eq_ignore_ascii_case(name))
150        .map(|(known, _)| *known)
151}
152
153/// True when `name` names a header whose value is one or more addresses
154/// (a mailbox, mailbox list, or address list).
155pub fn is_address_header_name(name: &[u8]) -> bool {
156    matches!(
157        grammar_for_name(name),
158        Grammar::MailboxList | Grammar::Mailbox | Grammar::AddressList
159    )
160}
161
162#[cfg(test)]
163mod test {
164    use super::*;
165
166    #[test]
167    fn address_header_names() {
168        // Matched case-insensitively; From/To/Bcc are address headers,
169        // Subject/Date/unknown are not.
170        assert!(is_address_header_name(b"from"));
171        assert!(is_address_header_name(b"To"));
172        assert!(is_address_header_name(b"BCC"));
173        assert!(is_address_header_name(b"Sender"));
174        assert!(!is_address_header_name(b"Subject"));
175        assert!(!is_address_header_name(b"Date"));
176        assert!(!is_address_header_name(b"X-Custom"));
177    }
178
179    #[test]
180    fn canonical_names() {
181        k9::assert_equal!(canonical_header_name(b"message-id"), Some("Message-ID"));
182        k9::assert_equal!(canonical_header_name(b"REPLY-TO"), Some("Reply-To"));
183        k9::assert_equal!(canonical_header_name(b"X-Custom"), None);
184    }
185
186    #[test]
187    fn structured_dispatch() {
188        // The name selects the grammar: To parses as an address list, an
189        // unknown name is unstructured.
190        let to = ParsedHeader::structured(b"To", b"a@example.com, b@example.com").unwrap();
191        match to {
192            ParsedHeader::AddressList(list) => {
193                k9::assert_equal!(list.len(), 2);
194            }
195            other => panic!("expected AddressList, got {other:?}"),
196        }
197
198        let custom = ParsedHeader::structured(b"X-Custom", b"anything at all").unwrap();
199        assert!(matches!(custom, ParsedHeader::Unstructured(_)));
200    }
201}