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::Header;
8use crate::encoders::encode::rfc2047_encode_phrase;
9use std::borrow::Cow;
10
11/// RFC5322 e-mail address
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
13pub struct EmailAddress<'x> {
14    pub name: Option<Cow<'x, str>>,
15    pub email: Cow<'x, str>,
16}
17
18/// RFC5322 grouped e-mail addresses
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
20pub struct GroupedAddresses<'x> {
21    pub name: Option<Cow<'x, str>>,
22    pub addresses: Vec<Address<'x>>,
23}
24
25/// RFC5322 address
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
27pub enum Address<'x> {
28    Address(EmailAddress<'x>),
29    Group(GroupedAddresses<'x>),
30    List(Vec<Address<'x>>),
31}
32
33impl<'x> Address<'x> {
34    /// Create an RFC5322 e-mail address
35    pub fn new_address(
36        name: Option<impl Into<Cow<'x, str>>>,
37        email: impl Into<Cow<'x, str>>,
38    ) -> Self {
39        Address::Address(EmailAddress {
40            name: name.map(|v| v.into()),
41            email: email.into(),
42        })
43    }
44
45    /// Create an RFC5322 grouped e-mail address
46    pub fn new_group(name: Option<impl Into<Cow<'x, str>>>, addresses: Vec<Address<'x>>) -> Self {
47        Address::Group(GroupedAddresses {
48            name: name.map(|v| v.into()),
49            addresses,
50        })
51    }
52
53    /// Create an address list
54    pub fn new_list(items: Vec<Address<'x>>) -> Self {
55        Address::List(items)
56    }
57
58    pub fn unwrap_address(&self) -> &EmailAddress<'x> {
59        match self {
60            Address::Address(address) => address,
61            _ => panic!("Address is not an EmailAddress"),
62        }
63    }
64}
65
66impl<'x> From<(&'x str, &'x str)> for Address<'x> {
67    fn from(value: (&'x str, &'x str)) -> Self {
68        Address::Address(EmailAddress {
69            name: Some(value.0.into()),
70            email: value.1.into(),
71        })
72    }
73}
74
75impl From<(String, String)> for Address<'_> {
76    fn from(value: (String, String)) -> Self {
77        Address::Address(EmailAddress {
78            name: Some(value.0.into()),
79            email: value.1.into(),
80        })
81    }
82}
83
84impl<'x> From<&'x str> for Address<'x> {
85    fn from(value: &'x str) -> Self {
86        Address::Address(EmailAddress {
87            name: None,
88            email: value.into(),
89        })
90    }
91}
92
93impl From<String> for Address<'_> {
94    fn from(value: String) -> Self {
95        Address::Address(EmailAddress {
96            name: None,
97            email: value.into(),
98        })
99    }
100}
101
102impl<'x, T> From<Vec<T>> for Address<'x>
103where
104    T: Into<Address<'x>>,
105{
106    fn from(value: Vec<T>) -> Self {
107        Address::new_list(value.into_iter().map(|x| x.into()).collect())
108    }
109}
110
111impl<'x, T, U> From<(U, Vec<T>)> for Address<'x>
112where
113    T: Into<Address<'x>>,
114    U: Into<Cow<'x, str>>,
115{
116    fn from(value: (U, Vec<T>)) -> Self {
117        Address::Group(GroupedAddresses {
118            name: Some(value.0.into()),
119            addresses: value.1.into_iter().map(|x| x.into()).collect(),
120        })
121    }
122}
123
124impl Header for Address<'_> {
125    fn write_header(
126        &self,
127        mut output: impl std::io::Write,
128        mut bytes_written: usize,
129    ) -> std::io::Result<usize> {
130        match self {
131            Address::Address(address) => {
132                address.write_header(&mut output, bytes_written)?;
133            }
134            Address::Group(group) => {
135                group.write_header(&mut output, bytes_written)?;
136            }
137            Address::List(list) => {
138                for (pos, address) in list.iter().enumerate() {
139                    if bytes_written
140                        + (match address {
141                            Address::Address(address) => {
142                                address.email.len()
143                                    + address.name.as_ref().map_or(0, |n| n.len() + 3)
144                                    + 2
145                            }
146                            Address::Group(group) => {
147                                group.name.as_ref().map_or(0, |name| name.len() + 2)
148                            }
149                            Address::List(_) => 0,
150                        })
151                        >= 76
152                    {
153                        output.write_all(b"\r\n\t")?;
154                        bytes_written = 1;
155                    }
156
157                    match address {
158                        Address::Address(address) => {
159                            bytes_written += address.write_header(&mut output, bytes_written)?;
160                            if pos < list.len() - 1 {
161                                output.write_all(b", ")?;
162                                bytes_written += 1;
163                            }
164                        }
165                        Address::Group(group) => {
166                            bytes_written += group.write_header(&mut output, bytes_written)?;
167                            if pos < list.len() - 1 {
168                                output.write_all(b" ")?;
169                                bytes_written += 1;
170                            }
171                        }
172                        Address::List(_) => unreachable!(),
173                    }
174                }
175            }
176        }
177        output.write_all(b"\r\n")?;
178        Ok(0)
179    }
180}
181
182impl Header for EmailAddress<'_> {
183    fn write_header(
184        &self,
185        mut output: impl std::io::Write,
186        mut bytes_written: usize,
187    ) -> std::io::Result<usize> {
188        if let Some(name) = &self.name {
189            bytes_written += rfc2047_encode_phrase(name, &mut output)?;
190            if bytes_written + self.email.len() + 2 >= 76 {
191                output.write_all(b"\r\n\t")?;
192                bytes_written = 1;
193            } else {
194                output.write_all(b" ")?;
195                bytes_written += 1;
196            }
197        }
198
199        output.write_all(b"<")?;
200        output.write_all(self.email.as_bytes())?;
201        output.write_all(b">")?;
202
203        Ok(bytes_written + self.email.len() + 2)
204    }
205}
206
207impl Header for GroupedAddresses<'_> {
208    fn write_header(
209        &self,
210        mut output: impl std::io::Write,
211        mut bytes_written: usize,
212    ) -> std::io::Result<usize> {
213        if let Some(name) = &self.name {
214            bytes_written += rfc2047_encode_phrase(name, &mut output)? + 2;
215            output.write_all(b": ")?;
216        }
217
218        for (pos, address) in self.addresses.iter().enumerate() {
219            let address = address.unwrap_address();
220
221            if bytes_written
222                + address.email.len()
223                + address.name.as_ref().map_or(0, |n| n.len() + 3)
224                + 2
225                >= 76
226            {
227                output.write_all(b"\r\n\t")?;
228                bytes_written = 1;
229            }
230
231            bytes_written += address.write_header(&mut output, bytes_written)?;
232            if pos < self.addresses.len() - 1 {
233                output.write_all(b", ")?;
234                bytes_written += 2;
235            }
236        }
237
238        output.write_all(b";")?;
239        bytes_written += 1;
240
241        Ok(bytes_written)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use mail_parser::MessageParser;
249
250    fn build(address: Address<'_>) -> String {
251        let mut output = Vec::new();
252        address.write_header(&mut output, 4).unwrap();
253        String::from_utf8(output).unwrap()
254    }
255
256    fn parse(header: &str) -> Vec<(Option<String>, Option<String>)> {
257        let raw = format!("Cc: {header}\r\n");
258        let message = MessageParser::new().parse_headers(raw.as_bytes()).unwrap();
259        message
260            .cc()
261            .unwrap()
262            .iter()
263            .map(|addr| {
264                (
265                    addr.name().map(str::to_string),
266                    addr.address().map(str::to_string),
267                )
268            })
269            .collect()
270    }
271
272    #[test]
273    fn encoded_display_name_is_not_wrapped_in_quotes() {
274        let header = build(Address::new_address(
275            Some("Anna Müller"),
276            "anna@example.com",
277        ));
278        assert!(
279            !header.contains("\"=?"),
280            "quoted encoded-word in {header:?}"
281        );
282        assert!(header.contains("=?utf-8?"), "not encoded in {header:?}");
283        assert_eq!(
284            parse(&header),
285            vec![(
286                Some("Anna Müller".to_string()),
287                Some("anna@example.com".to_string())
288            )]
289        );
290    }
291
292    #[test]
293    fn base64_display_name_is_not_wrapped_in_quotes() {
294        let name = "Δοκιμή, Εταιρεία";
295        let header = build(Address::new_address(Some(name), "info@example.org"));
296        assert!(header.contains("=?utf-8?B?"), "not base64 in {header:?}");
297        assert!(
298            !header.contains("\"=?"),
299            "quoted encoded-word in {header:?}"
300        );
301        assert_eq!(
302            parse(&header),
303            vec![(Some(name.to_string()), Some("info@example.org".to_string()))]
304        );
305    }
306
307    #[test]
308    fn display_name_containing_quotes_and_comma_round_trips() {
309        let name = "\"Steuerberater, Wirtschaftsprüfer\"";
310        let header = build(Address::new_list(vec![
311            Address::new_address(Some("Anna Müller"), "anna@example.com"),
312            Address::new_address(Some(name), "kanzlei@example.org"),
313        ]));
314
315        assert!(
316            !header.contains("\"=?"),
317            "quoted encoded-word in {header:?}"
318        );
319
320        assert_eq!(
321            parse(&header),
322            vec![
323                (
324                    Some("Anna Müller".to_string()),
325                    Some("anna@example.com".to_string())
326                ),
327                (
328                    Some(name.to_string()),
329                    Some("kanzlei@example.org".to_string())
330                ),
331            ],
332            "{header:?}"
333        );
334    }
335
336    #[test]
337    fn comma_in_encoded_display_name_does_not_split_list() {
338        let header = build(Address::new_list(vec![
339            Address::new_address(Some("Müller, Anna"), "anna@example.com"),
340            Address::new_address(Some("Beispiel GmbH"), "info@example.org"),
341        ]));
342        assert_eq!(
343            header.matches(',').count(),
344            1,
345            "comma left unescaped inside encoded-word in {header:?}"
346        );
347
348        let parsed = parse(&header);
349        assert_eq!(parsed.len(), 2, "{header:?}");
350        assert_eq!(parsed[0].0.as_deref(), Some("Müller, Anna"), "{header:?}");
351    }
352
353    #[test]
354    fn phrase_encoded_word_escapes_specials() {
355        let header = build(Address::new_address(
356            Some("Meier (Kanzlei) <x>; [y] \"z\" @ w\\v: u, tü"),
357            "info@example.org",
358        ));
359        assert!(header.contains("?Q?"), "not Q encoded in {header:?}");
360
361        let encoded = header
362            .split_once("?Q?")
363            .unwrap()
364            .1
365            .split_once("?=")
366            .unwrap()
367            .0;
368
369        for ch in [
370            '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '"', '.',
371        ] {
372            assert!(!encoded.contains(ch), "raw {ch:?} in {encoded:?}");
373        }
374
375        assert_eq!(
376            parse(&header),
377            vec![(
378                Some("Meier (Kanzlei) <x>; [y] \"z\" @ w\\v: u, tü".to_string()),
379                Some("info@example.org".to_string())
380            )]
381        );
382    }
383
384    #[test]
385    fn ascii_display_name_with_comma_uses_quoted_string() {
386        let header = build(Address::new_address(Some("Doe, John"), "john@example.com"));
387        assert!(header.contains("\"Doe, John\""), "{header:?}");
388        assert_eq!(
389            parse(&header),
390            vec![(
391                Some("Doe, John".to_string()),
392                Some("john@example.com".to_string())
393            )]
394        );
395    }
396
397    #[test]
398    fn ascii_display_name_with_quotes_is_escaped() {
399        let name = "John \"JD\" Doe";
400        let header = build(Address::new_address(Some(name), "john@example.com"));
401        assert_eq!(
402            parse(&header),
403            vec![(Some(name.to_string()), Some("john@example.com".to_string()))],
404            "{header:?}"
405        );
406    }
407
408    #[test]
409    fn group_name_with_specials_round_trips() {
410        let header = build(Address::new_group(
411            Some("Büro, Empfang"),
412            vec![Address::new_address(
413                Some("Anna Müller"),
414                "anna@example.com",
415            )],
416        ));
417        assert!(
418            !header.contains("\"=?"),
419            "quoted encoded-word in {header:?}"
420        );
421
422        let raw = format!("Cc: {header}\r\n");
423        let message = MessageParser::new().parse_headers(raw.as_bytes()).unwrap();
424        let groups = message.cc().unwrap().as_group().expect("not a group");
425
426        assert_eq!(groups.len(), 1, "{header:?}");
427        assert_eq!(
428            groups[0].name.as_deref(),
429            Some("Büro, Empfang"),
430            "{header:?}"
431        );
432        assert_eq!(groups[0].addresses.len(), 1, "{header:?}");
433        assert_eq!(
434            groups[0].addresses[0].name.as_deref(),
435            Some("Anna Müller"),
436            "{header:?}"
437        );
438    }
439}