Skip to main content

mail_builder/headers/
address.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{
8    Header,
9    fold::{FOLD_TARGET, FoldWriter},
10    rfc2047::write_phrase,
11};
12use crate::writer::Writer;
13use std::borrow::Cow;
14
15/// RFC5322 e-mail address
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
17pub struct EmailAddress<'x> {
18    pub name: Option<Cow<'x, str>>,
19    pub email: Cow<'x, str>,
20}
21
22/// RFC5322 grouped e-mail addresses
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
24pub struct GroupedAddresses<'x> {
25    pub name: Option<Cow<'x, str>>,
26    pub addresses: Vec<Address<'x>>,
27}
28
29/// RFC5322 address
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
31pub enum Address<'x> {
32    Address(EmailAddress<'x>),
33    Group(GroupedAddresses<'x>),
34    List(Vec<Address<'x>>),
35}
36
37impl<'x> Address<'x> {
38    /// Create an RFC5322 e-mail address
39    pub fn new_address(
40        name: Option<impl Into<Cow<'x, str>>>,
41        email: impl Into<Cow<'x, str>>,
42    ) -> Self {
43        Address::Address(EmailAddress {
44            name: name.map(|v| v.into()),
45            email: email.into(),
46        })
47    }
48
49    /// Create an RFC5322 grouped e-mail address
50    pub fn new_group(name: Option<impl Into<Cow<'x, str>>>, addresses: Vec<Address<'x>>) -> Self {
51        Address::Group(GroupedAddresses {
52            name: name.map(|v| v.into()),
53            addresses,
54        })
55    }
56
57    /// Create an address list
58    pub fn new_list(items: Vec<Address<'x>>) -> Self {
59        Address::List(items)
60    }
61
62    pub fn unwrap_address(&self) -> &EmailAddress<'x> {
63        match self {
64            Address::Address(address) => address,
65            _ => panic!("Address is not an EmailAddress"),
66        }
67    }
68}
69
70impl<'x> From<(&'x str, &'x str)> for Address<'x> {
71    fn from(value: (&'x str, &'x str)) -> Self {
72        Address::Address(EmailAddress {
73            name: Some(value.0.into()),
74            email: value.1.into(),
75        })
76    }
77}
78
79impl From<(String, String)> for Address<'_> {
80    fn from(value: (String, String)) -> Self {
81        Address::Address(EmailAddress {
82            name: Some(value.0.into()),
83            email: value.1.into(),
84        })
85    }
86}
87
88impl<'x> From<&'x str> for Address<'x> {
89    fn from(value: &'x str) -> Self {
90        Address::Address(EmailAddress {
91            name: None,
92            email: value.into(),
93        })
94    }
95}
96
97impl From<String> for Address<'_> {
98    fn from(value: String) -> Self {
99        Address::Address(EmailAddress {
100            name: None,
101            email: value.into(),
102        })
103    }
104}
105
106impl<'x, T> From<Vec<T>> for Address<'x>
107where
108    T: Into<Address<'x>>,
109{
110    fn from(value: Vec<T>) -> Self {
111        Address::new_list(value.into_iter().map(|x| x.into()).collect())
112    }
113}
114
115impl<'x, T, U> From<(U, Vec<T>)> for Address<'x>
116where
117    T: Into<Address<'x>>,
118    U: Into<Cow<'x, str>>,
119{
120    fn from(value: (U, Vec<T>)) -> Self {
121        Address::Group(GroupedAddresses {
122            name: Some(value.0.into()),
123            addresses: value.1.into_iter().map(|x| x.into()).collect(),
124        })
125    }
126}
127
128const TAIL_NONE: &[u8] = b"";
129const TAIL_COMMA: &[u8] = b",";
130
131impl Header for Address<'_> {
132    fn write_header(&self, output: &mut impl Writer, column: usize) {
133        if let Address::Address(address) = self
134            && address.name.is_none()
135            && column + address.email.len() + 2 <= FOLD_TARGET
136        {
137            output.write_byte(b'<');
138            output.write(address.email.as_bytes());
139            output.write(b">\r\n");
140            return;
141        }
142
143        let mut folder = FoldWriter::new(output, column);
144        self.write_value(&mut folder, TAIL_NONE);
145        folder.finish();
146    }
147}
148
149impl Address<'_> {
150    #[inline(always)]
151    fn writes_nothing(&self, in_group: bool) -> bool {
152        match self {
153            Address::Address(_) => false,
154            Address::Group(group) => {
155                let named = group.name.is_some();
156                (in_group || !named)
157                    && group
158                        .addresses
159                        .iter()
160                        .all(|address| address.writes_nothing(in_group || named))
161            }
162            Address::List(list) => list.iter().all(|address| address.writes_nothing(in_group)),
163        }
164    }
165
166    fn write_value<W: Writer>(&self, folder: &mut FoldWriter<'_, W>, tail: &[u8]) {
167        match self {
168            Address::Address(address) => address.write_mailbox(folder, tail),
169            Address::Group(group) => group.write_group(folder, tail),
170            Address::List(list) => write_list(folder, list, tail, false),
171        }
172    }
173}
174
175fn write_list<W: Writer>(
176    folder: &mut FoldWriter<'_, W>,
177    list: &[Address<'_>],
178    tail: &[u8],
179    in_group: bool,
180) {
181    let Some(last) = list
182        .iter()
183        .rposition(|address| !address.writes_nothing(in_group))
184    else {
185        return;
186    };
187
188    for (pos, address) in list.iter().enumerate() {
189        if pos > last {
190            return;
191        }
192        if address.writes_nothing(in_group) {
193            continue;
194        }
195
196        let item_tail = if pos == last { tail } else { TAIL_COMMA };
197
198        match address {
199            Address::Address(address) => address.write_mailbox(folder, item_tail),
200            Address::Group(group) if in_group => {
201                write_list(folder, &group.addresses, item_tail, true)
202            }
203            Address::Group(group) => group.write_group(folder, item_tail),
204            Address::List(nested) => write_list(folder, nested, item_tail, in_group),
205        }
206
207        if pos == last {
208            return;
209        }
210        folder.space();
211    }
212}
213
214impl EmailAddress<'_> {
215    #[inline]
216    pub(crate) fn write_mailbox<W: Writer>(&self, folder: &mut FoldWriter<'_, W>, tail: &[u8]) {
217        if let Some(name) = &self.name {
218            write_phrase(folder, name, TAIL_NONE);
219            folder.space();
220        }
221
222        folder.begin_atom(self.email.len() + 2 + tail.len());
223        folder.write_byte(b'<');
224        folder.write(self.email.as_bytes());
225        match tail {
226            [] => folder.write_byte(b'>'),
227            [b','] => folder.write(b">,"),
228            [b';'] => folder.write(b">;"),
229            _ => {
230                folder.write_byte(b'>');
231                folder.write_tail(tail);
232            }
233        }
234    }
235}
236
237impl Header for EmailAddress<'_> {
238    fn write_header(&self, output: &mut impl Writer, column: usize) {
239        let mut folder = FoldWriter::new(output, column);
240        self.write_mailbox(&mut folder, TAIL_NONE);
241        folder.finish();
242    }
243}
244
245impl GroupedAddresses<'_> {
246    pub(crate) fn write_group<W: Writer>(&self, folder: &mut FoldWriter<'_, W>, tail: &[u8]) {
247        let Some(name) = &self.name else {
248            return write_list(folder, &self.addresses, tail, false);
249        };
250
251        let is_empty = self
252            .addresses
253            .iter()
254            .all(|address| address.writes_nothing(true));
255        let (name_tail, list_tail): (&[u8], &[u8]) = if tail.is_empty() {
256            (b":;", b";")
257        } else {
258            (b":;,", b";,")
259        };
260        let name_tail = if is_empty { name_tail } else { b":" };
261
262        write_phrase(folder, name, name_tail);
263
264        if !is_empty {
265            folder.space();
266            write_list(folder, &self.addresses, list_tail, true);
267        }
268    }
269}
270
271impl Header for GroupedAddresses<'_> {
272    fn write_header(&self, output: &mut impl Writer, column: usize) {
273        let mut folder = FoldWriter::new(output, column);
274        self.write_group(&mut folder, TAIL_NONE);
275        folder.finish();
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use mail_parser::MessageParser;
283
284    #[test]
285    fn lines_with_encoded_words_stay_within_76_characters() {
286        let mut output = b"Bcc: ".to_vec();
287        Address::new_address(Some("¡El ñandú comió ñoquis!"), "addr1@example.com")
288            .write_header(&mut output, 5);
289        let header = String::from_utf8(output).unwrap();
290        for line in header.trim_end().split("\r\n") {
291            assert!(
292                !line.contains("=?") || line.len() <= 76,
293                "line of {} bytes: {line:?}",
294                line.len()
295            );
296        }
297        assert_eq!(
298            header,
299            "Bcc: =?utf-8?B?wqFFbCDDsWFuZMO6IGNvbWnDsyDDsW9xdWlzIQ==?=\r\n <addr1@example.com>\r\n"
300        );
301    }
302
303    #[test]
304    fn nameless_groups_are_written_as_their_mailboxes() {
305        let header = build(Address::new_group(
306            None::<&str>,
307            vec![
308                Address::new_address(None::<&str>, "a@x.test"),
309                Address::new_address(None::<&str>, "b@x.test"),
310            ],
311        ));
312        assert_eq!(header, "<a@x.test>, <b@x.test>\r\n");
313
314        let header = build(Address::new_list(vec![
315            Address::new_group(
316                Some("List 1"),
317                vec![Address::new_address(None::<&str>, "a@x.test")],
318            ),
319            Address::new_group(
320                None::<&str>,
321                vec![
322                    Address::new_address(None::<&str>, "b@x.test"),
323                    Address::new_address(None::<&str>, "c@x.test"),
324                ],
325            ),
326        ]));
327        assert_eq!(
328            header,
329            "\"List 1\": <a@x.test>;, <b@x.test>, <c@x.test>\r\n"
330        );
331        assert_eq!(
332            parse(&header),
333            vec![
334                (None, Some("a@x.test".to_string())),
335                (None, Some("b@x.test".to_string())),
336                (None, Some("c@x.test".to_string())),
337            ]
338        );
339
340        let header = build(Address::new_group(None::<&str>, vec![]));
341        assert_eq!(header, "\r\n");
342
343        let header = build(Address::new_list(vec![
344            Address::new_group(None::<&str>, vec![]),
345            Address::new_address(None::<&str>, "a@x.test"),
346            Address::new_group(None::<&str>, vec![]),
347        ]));
348        assert_eq!(header, "<a@x.test>\r\n");
349    }
350
351    #[test]
352    fn group_with_empty_nested_group_last_is_terminated() {
353        let header = build(Address::new_group(
354            Some("Team"),
355            vec![
356                Address::new_address(None::<&str>, "a@b.com"),
357                Address::new_group(Some("Inner"), vec![]),
358            ],
359        ));
360        assert_eq!(header, "\"Team\": <a@b.com>;\r\n");
361        let header = build(Address::new_group(
362            Some("Team"),
363            vec![Address::new_list(vec![Address::new_group(
364                Some("Inner"),
365                vec![],
366            )])],
367        ));
368        assert_eq!(header, "\"Team\":;\r\n");
369    }
370
371    fn build(address: Address<'_>) -> String {
372        let mut output = Vec::new();
373        address.write_header(&mut output, 4);
374        String::from_utf8(output).unwrap()
375    }
376
377    fn parse(header: &str) -> Vec<(Option<String>, Option<String>)> {
378        let raw = format!("Cc: {header}\r\n");
379        let message = MessageParser::new().parse_headers(raw.as_bytes()).unwrap();
380        message
381            .cc()
382            .unwrap()
383            .iter()
384            .map(|addr| {
385                (
386                    addr.name().map(str::to_string),
387                    addr.address().map(str::to_string),
388                )
389            })
390            .collect()
391    }
392
393    #[test]
394    fn encoded_display_name_is_not_wrapped_in_quotes() {
395        let header = build(Address::new_address(
396            Some("Anna Müller"),
397            "anna@example.com",
398        ));
399        assert!(
400            !header.contains("\"=?"),
401            "quoted encoded-word in {header:?}"
402        );
403        assert!(header.contains("=?utf-8?"), "not encoded in {header:?}");
404        assert_eq!(
405            parse(&header),
406            vec![(
407                Some("Anna Müller".to_string()),
408                Some("anna@example.com".to_string())
409            )]
410        );
411    }
412
413    #[test]
414    fn base64_display_name_is_not_wrapped_in_quotes() {
415        let name = "Δοκιμή, Εταιρεία";
416        let header = build(Address::new_address(Some(name), "info@example.org"));
417        assert!(header.contains("=?utf-8?B?"), "not base64 in {header:?}");
418        assert!(
419            !header.contains("\"=?"),
420            "quoted encoded-word in {header:?}"
421        );
422        assert_eq!(
423            parse(&header),
424            vec![(Some(name.to_string()), Some("info@example.org".to_string()))]
425        );
426    }
427
428    #[test]
429    fn display_name_containing_quotes_and_comma_round_trips() {
430        let name = "\"Steuerberater, Wirtschaftsprüfer\"";
431        let header = build(Address::new_list(vec![
432            Address::new_address(Some("Anna Müller"), "anna@example.com"),
433            Address::new_address(Some(name), "kanzlei@example.org"),
434        ]));
435
436        assert!(
437            !header.contains("\"=?"),
438            "quoted encoded-word in {header:?}"
439        );
440
441        assert_eq!(
442            parse(&header),
443            vec![
444                (
445                    Some("Anna Müller".to_string()),
446                    Some("anna@example.com".to_string())
447                ),
448                (
449                    Some(name.to_string()),
450                    Some("kanzlei@example.org".to_string())
451                ),
452            ],
453            "{header:?}"
454        );
455    }
456
457    #[test]
458    fn comma_in_encoded_display_name_does_not_split_list() {
459        let header = build(Address::new_list(vec![
460            Address::new_address(Some("Müller, Anna"), "anna@example.com"),
461            Address::new_address(Some("Beispiel GmbH"), "info@example.org"),
462        ]));
463        assert_eq!(
464            header.matches(',').count(),
465            1,
466            "comma left unescaped inside encoded-word in {header:?}"
467        );
468
469        let parsed = parse(&header);
470        assert_eq!(parsed.len(), 2, "{header:?}");
471        assert_eq!(parsed[0].0.as_deref(), Some("Müller, Anna"), "{header:?}");
472    }
473
474    #[test]
475    fn phrase_encoded_word_escapes_specials() {
476        let header = build(Address::new_address(
477            Some("Meier (Kanzlei) <x>; [y] \"z\" @ w\\v: u, tü"),
478            "info@example.org",
479        ));
480        assert!(header.contains("?Q?"), "not Q encoded in {header:?}");
481
482        let encoded = header
483            .split_once("?Q?")
484            .unwrap()
485            .1
486            .split_once("?=")
487            .unwrap()
488            .0;
489
490        for ch in [
491            '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '"', '.',
492        ] {
493            assert!(!encoded.contains(ch), "raw {ch:?} in {encoded:?}");
494        }
495
496        assert_eq!(
497            parse(&header),
498            vec![(
499                Some("Meier (Kanzlei) <x>; [y] \"z\" @ w\\v: u, tü".to_string()),
500                Some("info@example.org".to_string())
501            )]
502        );
503    }
504
505    #[test]
506    fn ascii_display_name_with_comma_uses_quoted_string() {
507        let header = build(Address::new_address(Some("Doe, John"), "john@example.com"));
508        assert!(header.contains("\"Doe, John\""), "{header:?}");
509        assert_eq!(
510            parse(&header),
511            vec![(
512                Some("Doe, John".to_string()),
513                Some("john@example.com".to_string())
514            )]
515        );
516    }
517
518    #[test]
519    fn ascii_display_name_with_quotes_is_escaped() {
520        let name = "John \"JD\" Doe";
521        let header = build(Address::new_address(Some(name), "john@example.com"));
522        assert_eq!(
523            parse(&header),
524            vec![(Some(name.to_string()), Some("john@example.com".to_string()))],
525            "{header:?}"
526        );
527    }
528
529    #[test]
530    fn group_name_with_specials_round_trips() {
531        let header = build(Address::new_group(
532            Some("Büro, Empfang"),
533            vec![Address::new_address(
534                Some("Anna Müller"),
535                "anna@example.com",
536            )],
537        ));
538        assert!(
539            !header.contains("\"=?"),
540            "quoted encoded-word in {header:?}"
541        );
542
543        let raw = format!("Cc: {header}\r\n");
544        let message = MessageParser::new().parse_headers(raw.as_bytes()).unwrap();
545        let groups = message.cc().unwrap().as_group().expect("not a group");
546
547        assert_eq!(groups.len(), 1, "{header:?}");
548        assert_eq!(
549            groups[0].name.as_deref(),
550            Some("Büro, Empfang"),
551            "{header:?}"
552        );
553        assert_eq!(groups[0].addresses.len(), 1, "{header:?}");
554        assert_eq!(
555            groups[0].addresses[0].name.as_deref(),
556            Some("Anna Müller"),
557            "{header:?}"
558        );
559    }
560}