1use super::{
8 Header,
9 fold::{FOLD_TARGET, FoldWriter},
10 rfc2047::write_phrase,
11};
12use crate::writer::Writer;
13use std::borrow::Cow;
14
15#[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#[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#[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 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 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 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}