1use std::collections::HashSet;
38use std::fmt;
39
40use anyhow::{bail, ensure, Result};
41use mail_builder::headers::address::Address;
42use mail_builder::headers::message_id::MessageId;
43use mail_builder::mime::make_boundary;
44use mail_builder::MessageBuilder;
45use mail_parser::MessageParser;
46
47use crate::gmail::messages_api::header_value;
48use crate::gmail::types::Message;
49
50pub const REPLY_HEADERS: [&str; 8] = [
53 "Subject",
54 "Message-ID",
55 "References",
56 "In-Reply-To",
57 "From",
58 "Reply-To",
59 "To",
60 "Cc",
61];
62
63const SENT_LABEL: &str = "SENT";
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Mailbox {
69 pub name: Option<String>,
71 pub email: String,
73}
74
75impl Mailbox {
76 pub fn parse(input: &str) -> Result<Self> {
85 ensure!(
86 !input.chars().any(char::is_control),
87 "invalid address {input:?}: control characters (such as a line break) are not allowed"
88 );
89 let trimmed = input.trim();
90 let (name, email) = match (trimmed.rfind('<'), trimmed.strip_suffix('>')) {
91 (Some(open), Some(without_close)) => {
92 let name = unquote(trimmed[..open].trim());
93 let email = without_close[open + 1..].trim();
94 ((!name.is_empty()).then_some(name), email)
95 }
96 _ => (None, trimmed),
97 };
98 Self::checked(name, email)
99 .map_err(|reason| anyhow::anyhow!("invalid address {input:?}: {reason}"))
100 }
101
102 pub fn from_parts(name: Option<&str>, email: &str) -> Result<Self> {
109 let name = name
112 .map(|name| name.trim_matches(' '))
113 .filter(|name| !name.is_empty())
114 .map(str::to_string);
115 Self::checked(name, email.trim_matches(' '))
116 .map_err(|reason| anyhow::anyhow!("invalid address {email:?}: {reason}"))
117 }
118
119 fn checked(name: Option<String>, email: &str) -> std::result::Result<Self, &'static str> {
123 if email
124 .chars()
125 .chain(name.iter().flat_map(|name| name.chars()))
126 .any(char::is_control)
127 {
128 return Err("control characters (such as a line break) are not allowed");
129 }
130 validate_email(email)?;
131 Ok(Self {
132 name,
133 email: email.to_string(),
134 })
135 }
136
137 fn to_address(&self) -> Address<'_> {
138 Address::new_address(self.name.as_deref(), self.email.as_str())
139 }
140}
141
142impl fmt::Display for Mailbox {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 match &self.name {
147 Some(name) => write!(f, "{name} <{}>", self.email),
148 None => f.write_str(&self.email),
149 }
150 }
151}
152
153fn unquote(name: &str) -> String {
156 let Some(inner) = name
157 .strip_prefix('"')
158 .and_then(|inner| inner.strip_suffix('"'))
159 else {
160 return name.to_string();
161 };
162 let mut out = String::with_capacity(inner.len());
163 let mut chars = inner.chars();
164 while let Some(c) = chars.next() {
165 match c {
166 '\\' => out.extend(chars.next()),
167 c => out.push(c),
168 }
169 }
170 out
171}
172
173fn validate_email(email: &str) -> std::result::Result<(), &'static str> {
177 let Some((local, domain)) = email.rsplit_once('@') else {
178 return Err("expected `addr@domain` or `Name <addr@domain>`");
179 };
180 if local.is_empty() || domain.is_empty() {
181 return Err("expected `addr@domain` or `Name <addr@domain>`");
182 }
183 if email
184 .chars()
185 .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | ',' | ';' | '"'))
186 {
187 return Err("the address contains whitespace or a delimiter");
188 }
189 Ok(())
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Attachment {
195 pub filename: String,
197 pub content_type: String,
199 pub data: Vec<u8>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Default)]
205pub struct ReplyContext {
206 pub thread_id: String,
209 pub message_id: Option<String>,
211 pub references: Vec<String>,
213 pub in_reply_to: Vec<String>,
215 pub subject: String,
217 pub from: Vec<Mailbox>,
219 pub reply_to: Vec<Mailbox>,
221 pub to: Vec<Mailbox>,
223 pub cc: Vec<Mailbox>,
225 pub sent_by_me: bool,
228 pub skipped: Vec<(&'static str, String)>,
231}
232
233impl ReplyContext {
234 pub fn from_message(message: &Message) -> Result<Self> {
237 let Some(thread_id) = message.thread_id.clone().filter(|id| !id.is_empty()) else {
238 bail!(
239 "message {} has no thread id, so a reply to it can't be filed in its thread",
240 message.id
241 );
242 };
243 let payload = message.payload.as_ref();
244 let ids = |name| parse_msg_ids(&header_value(payload, name).unwrap_or_default());
245 let mut skipped = Vec::new();
246 let mut addresses = |name: &'static str| {
247 let (mailboxes, bad) = parse_address_header(name, &all_header_values(payload, name));
248 skipped.extend(bad.into_iter().map(|bad| (name, bad)));
249 mailboxes
250 };
251 Ok(Self {
252 thread_id,
253 message_id: ids("Message-ID").into_iter().next(),
254 references: ids("References"),
255 in_reply_to: ids("In-Reply-To"),
256 subject: header_value(payload, "Subject").unwrap_or_default(),
257 from: addresses("From"),
258 reply_to: addresses("Reply-To"),
259 to: addresses("To"),
260 cc: addresses("Cc"),
261 sent_by_me: message.label_ids.iter().any(|label| label == SENT_LABEL),
262 skipped,
263 })
264 }
265
266 #[must_use]
280 pub fn default_recipients(
281 &self,
282 reply_all: bool,
283 exclude: &[String],
284 ) -> (Vec<Mailbox>, Vec<Mailbox>) {
285 let primary = if self.sent_by_me {
286 &self.to
287 } else if self.reply_to.is_empty() {
288 &self.from
289 } else {
290 &self.reply_to
291 };
292 let mut to_candidates: Vec<&Mailbox> = primary.iter().collect();
293 let mut cc_candidates: Vec<&Mailbox> = Vec::new();
294 if reply_all {
295 if !self.sent_by_me {
296 to_candidates.extend(&self.to);
297 }
298 cc_candidates.extend(&self.cc);
299 }
300 let mut seen: HashSet<String> = exclude.iter().map(|email| email.to_lowercase()).collect();
301 let mut keep_new = |candidates: Vec<&Mailbox>| -> Vec<Mailbox> {
302 candidates
303 .into_iter()
304 .filter(|mailbox| seen.insert(mailbox.email.to_lowercase()))
305 .cloned()
306 .collect()
307 };
308 let to = keep_new(to_candidates);
309 let cc = keep_new(cc_candidates);
310 (to, cc)
311 }
312
313 #[must_use]
317 pub fn reply_references(&self) -> Vec<String> {
318 let parents = if self.references.is_empty() {
319 &self.in_reply_to
320 } else {
321 &self.references
322 };
323 let mut out: Vec<String> = Vec::with_capacity(parents.len() + 1);
324 for id in parents.iter().chain(self.message_id.as_ref()) {
325 if !out.contains(id) {
326 out.push(id.clone());
327 }
328 }
329 out
330 }
331
332 #[must_use]
335 pub fn reply_subject(&self) -> String {
336 let subject = self.subject.trim();
337 if strip_reply_prefixes(subject).len() < subject.len() {
338 subject.to_string()
339 } else if subject.is_empty() {
340 "Re:".to_string()
341 } else {
342 format!("Re: {subject}")
343 }
344 }
345}
346
347fn strip_reply_prefixes(subject: &str) -> &str {
350 let mut rest = subject.trim();
351 loop {
352 let Some(after_re) = rest
353 .get(..2)
354 .filter(|prefix| prefix.eq_ignore_ascii_case("re"))
355 .map(|_| rest[2..].trim_start())
356 else {
357 return rest;
358 };
359 match after_re.strip_prefix(':') {
360 Some(after_colon) => rest = after_colon.trim_start(),
361 None => return rest,
362 }
363 }
364}
365
366#[must_use]
372pub fn subject_matches_reply(subject: &str, reply: &ReplyContext) -> bool {
373 strip_reply_prefixes(subject).eq_ignore_ascii_case(strip_reply_prefixes(&reply.subject))
374}
375
376fn parse_msg_ids(value: &str) -> Vec<String> {
380 let mut ids = Vec::new();
381 let mut rest = value;
382 while let Some(open) = rest.find('<') {
383 let Some(close) = rest[open..].find('>') else {
384 break;
385 };
386 let id = rest[open + 1..open + close].trim();
387 if !id.is_empty() {
388 ids.push(id.to_string());
389 }
390 rest = &rest[open + close + 1..];
391 }
392 if ids.is_empty() && !value.contains('<') {
393 ids.extend(value.split_whitespace().map(str::to_string));
394 }
395 ids
396}
397
398fn all_header_values(payload: Option<&serde_json::Value>, name: &str) -> String {
402 payload
403 .and_then(|payload| payload.get("headers"))
404 .and_then(serde_json::Value::as_array)
405 .into_iter()
406 .flatten()
407 .filter(|header| {
408 header
409 .get("name")
410 .and_then(serde_json::Value::as_str)
411 .is_some_and(|n| n.eq_ignore_ascii_case(name))
412 })
413 .filter_map(|header| header.get("value").and_then(serde_json::Value::as_str))
414 .collect::<Vec<_>>()
415 .join(", ")
416}
417
418fn parse_address_header(name: &str, value: &str) -> (Vec<Mailbox>, Vec<String>) {
428 let value: String = value
433 .replace("\r\n", "")
434 .chars()
435 .map(|c| {
436 if matches!(c, '\r' | '\n' | '\t') {
437 ' '
438 } else {
439 c
440 }
441 })
442 .collect();
443 let raw = format!("{name}: {value}\r\n\r\n");
444 let Some(parsed) = MessageParser::default().parse_headers(raw.as_bytes()) else {
445 return (Vec::new(), Vec::new());
446 };
447 let address = match name {
448 "From" => parsed.from(),
449 "Reply-To" => parsed.reply_to(),
450 "To" => parsed.to(),
451 "Cc" => parsed.cc(),
452 _ => None,
453 };
454 let mut mailboxes = Vec::new();
455 let mut skipped = Vec::new();
456 for addr in address.into_iter().flat_map(mail_parser::Address::iter) {
457 match Mailbox::from_parts(addr.name(), addr.address().unwrap_or_default()) {
458 Ok(mailbox) => mailboxes.push(mailbox),
459 Err(_) => skipped.push(
460 addr.address()
461 .or(addr.name())
462 .unwrap_or_default()
463 .to_string(),
464 ),
465 }
466 }
467 (mailboxes, skipped)
468}
469
470#[derive(Debug, Clone, Default)]
473pub struct Composition {
474 pub from: Option<Mailbox>,
479 pub to: Vec<Mailbox>,
481 pub cc: Vec<Mailbox>,
483 pub bcc: Vec<Mailbox>,
486 pub subject: String,
488 pub body: String,
490 pub html_body: Option<String>,
493 pub attachments: Vec<Attachment>,
496 pub reply: Option<ReplyContext>,
498 pub message_id_domain: Option<String>,
502}
503
504#[must_use]
510pub fn message_id_domain(email: &str) -> Option<String> {
511 let (_, domain) = email.rsplit_once('@')?;
512 is_plain_domain(domain).then(|| domain.to_ascii_lowercase())
513}
514
515fn is_plain_domain(domain: &str) -> bool {
520 domain.len() <= 253
521 && !domain
522 .rsplit('.')
523 .next()
524 .is_some_and(|tld| tld.chars().all(|c| c.is_ascii_digit()))
525 && domain.split('.').all(|label| {
526 (1..=63).contains(&label.len())
527 && !label.starts_with('-')
528 && !label.ends_with('-')
529 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
530 })
531}
532
533pub fn check_subject(subject: &str) -> Result<()> {
538 ensure!(
541 !subject.contains(['\r', '\n', '\0']),
542 "the subject contains a line break or NUL character"
543 );
544 Ok(())
545}
546
547impl Composition {
548 pub fn build(&self) -> Result<Vec<u8>> {
555 check_subject(&self.subject)?;
556 for attachment in &self.attachments {
557 ensure!(
558 !attachment.content_type.chars().any(char::is_control),
559 "attachment {:?} has an invalid content type",
560 attachment.filename
561 );
562 }
563
564 let mut builder = MessageBuilder::new();
565 if let Some(domain) = &self.message_id_domain {
566 ensure!(
567 is_plain_domain(domain),
568 "invalid Message-ID domain {domain:?}"
569 );
570 builder = builder.message_id(format!("{}@{domain}", make_boundary(".")));
571 }
572 if let Some(from) = &self.from {
573 builder = builder.from(from.to_address());
574 }
575 for (header, mailboxes) in [("To", &self.to), ("Cc", &self.cc), ("Bcc", &self.bcc)] {
576 if mailboxes.is_empty() {
577 continue;
578 }
579 let list = Address::new_list(mailboxes.iter().map(Mailbox::to_address).collect());
580 builder = match header {
581 "To" => builder.to(list),
582 "Cc" => builder.cc(list),
583 _ => builder.bcc(list),
584 };
585 }
586 if !self.subject.is_empty() {
587 builder = builder.subject(self.subject.as_str());
588 }
589 if let Some(reply) = &self.reply {
590 if let Some(parent) = &reply.message_id {
591 builder = builder.in_reply_to(parent.as_str());
592 }
593 let references = reply.reply_references();
594 if !references.is_empty() {
595 builder = builder.references(MessageId::new_list(references.into_iter()));
596 }
597 }
598 builder = builder.text_body(self.body.as_str());
599 if let Some(html) = &self.html_body {
600 builder = builder.html_body(html.as_str());
601 }
602 for attachment in &self.attachments {
603 builder = builder.attachment(
604 attachment.content_type.as_str(),
605 attachment.filename.as_str(),
606 attachment.data.as_slice(),
607 );
608 }
609 builder
610 .write_to_vec()
611 .map_err(|err| anyhow::anyhow!("Failed to build the message: {err}"))
612 }
613}
614
615pub fn plain_text_from_html(html: &str) -> Result<String> {
623 htmd::HtmlToMarkdown::builder()
624 .skip_tags(vec!["head", "title", "style", "script"])
625 .build()
626 .convert(html)
627 .map_err(|err| anyhow::anyhow!("could not derive a plain-text body from the HTML: {err}"))
628}
629
630#[must_use]
636pub fn inline_image_warning(html: &str) -> Option<String> {
637 html.to_ascii_lowercase().contains("cid:").then(|| {
638 "the HTML refers to inline images (`cid:`), which aren't attached; they will show as \
639 broken images"
640 .to_string()
641 })
642}
643
644#[cfg(test)]
645#[allow(clippy::unwrap_used, clippy::expect_used)]
646mod tests {
647 use super::*;
648 use mail_parser::{MessageParser, MimeHeaders};
649
650 #[test]
651 fn parse_address_header_handles_an_unparsable_header() {
652 assert_eq!(
656 parse_address_header("", "alice@example.com"),
657 (Vec::new(), Vec::new())
658 );
659 }
660
661 #[test]
662 fn parse_address_header_ignores_an_unrecognized_header_name() {
663 assert_eq!(
667 parse_address_header("X-Custom", "alice@example.com"),
668 (Vec::new(), Vec::new())
669 );
670 }
671
672 fn mailbox(input: &str) -> Mailbox {
673 Mailbox::parse(input).unwrap()
674 }
675
676 fn plain(subject: &str) -> Composition {
677 Composition {
678 to: vec![mailbox("alice@example.com")],
679 subject: subject.to_string(),
680 body: "Hello Alice.\n".to_string(),
681 ..Composition::default()
682 }
683 }
684
685 fn built(composition: &Composition) -> String {
686 String::from_utf8(composition.build().unwrap()).unwrap()
687 }
688
689 fn raw_header(message: &str, name: &str) -> Option<String> {
692 let headers = message.split("\r\n\r\n").next().unwrap();
693 let unfolded = headers.replace("\r\n ", " ").replace("\r\n\t", " ");
694 unfolded.lines().find_map(|line| {
695 let (key, value) = line.split_once(':')?;
696 key.eq_ignore_ascii_case(name)
697 .then(|| value.trim().to_string())
698 })
699 }
700
701 #[test]
704 fn parse_accepts_a_bare_address() {
705 assert_eq!(
706 mailbox(" alice@example.com "),
707 Mailbox {
708 name: None,
709 email: "alice@example.com".to_string()
710 }
711 );
712 }
713
714 #[test]
715 fn parse_splits_a_display_name_from_the_address() {
716 assert_eq!(
717 mailbox("Alice Example <alice@example.com>"),
718 Mailbox {
719 name: Some("Alice Example".to_string()),
720 email: "alice@example.com".to_string()
721 }
722 );
723 }
724
725 #[test]
726 fn parse_unquotes_a_quoted_name_containing_a_comma() {
727 let parsed = mailbox(r#""Doe, Jane \"JD\" \x" <jane@example.com>"#);
728 assert_eq!(parsed.name.as_deref(), Some(r#"Doe, Jane "JD" x"#));
729 assert_eq!(parsed.email, "jane@example.com");
730 }
731
732 #[test]
733 fn parse_treats_an_empty_name_as_none() {
734 assert_eq!(mailbox("<bob@example.com>").name, None);
735 }
736
737 #[test]
738 fn parse_rejects_malformed_addresses() {
739 for input in [
740 "",
741 "alice",
742 "@example.com",
743 "alice@",
744 "alice smith@example.com",
745 "a@example.com, b@example.com",
746 "Alice <alice>",
747 ] {
748 assert!(Mailbox::parse(input).is_err(), "{input:?} was accepted");
749 }
750 }
751
752 #[test]
753 fn parse_rejects_line_breaks_that_would_inject_a_header() {
754 for input in [
755 "alice@example.com\r\nBcc: eve@example.com",
756 "Alice\n<alice@example.com>",
757 ] {
758 let err = Mailbox::parse(input).unwrap_err().to_string();
759 assert!(err.contains("control characters"), "{err}");
760 }
761 }
762
763 #[test]
766 fn build_plain_message_is_a_single_text_part_without_from() {
767 let message = built(&plain("Hello"));
768 assert_eq!(
769 raw_header(&message, "To").as_deref(),
770 Some("<alice@example.com>")
771 );
772 assert_eq!(raw_header(&message, "Subject").as_deref(), Some("Hello"));
773 assert!(raw_header(&message, "From").is_none());
774 assert!(raw_header(&message, "Date").is_some());
775 assert!(raw_header(&message, "MIME-Version").is_some());
776 assert!(raw_header(&message, "Message-ID")
778 .unwrap()
779 .ends_with("@localhost>"));
780 assert!(raw_header(&message, "Content-Type")
781 .unwrap()
782 .starts_with("text/plain"));
783 assert!(!message.contains("multipart"));
784
785 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
786 assert_eq!(parsed.body_text(0).as_deref(), Some("Hello Alice.\r\n"));
788 }
789
790 fn with_domain(domain: &str) -> Composition {
791 Composition {
792 message_id_domain: Some(domain.to_string()),
793 ..plain("Hello")
794 }
795 }
796
797 #[test]
798 fn build_writes_a_message_id_in_the_given_domain() {
799 let message = built(&with_domain("example.org"));
800 assert_eq!(message.matches("Message-ID:").count(), 1, "{message}");
801 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
802 let id = parsed.message_id().unwrap();
803 let (left, right) = id.split_once('@').unwrap();
804 assert_eq!(right, "example.org");
805 assert!(!left.is_empty() && !left.starts_with('.') && !left.ends_with('.'));
807 assert!(left.chars().all(|c| c.is_ascii_alphanumeric() || c == '.'));
808 }
809
810 #[test]
811 fn build_writes_a_fresh_message_id_each_time() {
812 let composition = with_domain("example.org");
813 assert_ne!(
814 raw_header(&built(&composition), "Message-ID"),
815 raw_header(&built(&composition), "Message-ID")
816 );
817 }
818
819 #[test]
820 fn build_rejects_a_message_id_domain_that_could_inject() {
821 for domain in ["", "example.org>\r\nBcc: eve@example.com", "a..b", "a b"] {
822 let err = with_domain(domain).build().unwrap_err().to_string();
823 assert!(err.contains("invalid Message-ID domain"), "{err}");
824 }
825 }
826
827 #[test]
828 fn message_id_domain_takes_the_part_after_the_last_at() {
829 assert_eq!(
830 message_id_domain("Me@Example.ORG").as_deref(),
831 Some("example.org")
832 );
833 assert_eq!(
834 message_id_domain("\"a@b\"@mail.example.com").as_deref(),
835 Some("mail.example.com")
836 );
837 let longest = format!("me@{}.org", "a".repeat(63));
838 assert!(message_id_domain(&longest).is_some());
839 let long_label = format!("me@{}.org", "a".repeat(64));
840 let long_domain = format!("me@{}org", "a.".repeat(126));
841 for email in [
842 "no-at-sign",
843 "me@",
844 "me@[127.0.0.1]",
845 "me@127.0.0.1",
846 "me@a..b",
847 "me@exa mple.org",
848 "me@-example.org",
849 "me@example-.org",
850 &long_label,
851 &long_domain,
852 ] {
853 assert_eq!(message_id_domain(email), None, "{email}");
854 }
855 }
856
857 #[test]
858 fn build_writes_cc_and_bcc_headers() {
859 let composition = Composition {
860 cc: vec![mailbox("Carol <carol@example.com>")],
861 bcc: vec![mailbox("dave@example.com"), mailbox("erin@example.com")],
862 ..plain("Hi")
863 };
864 let parsed_bytes = composition.build().unwrap();
865 let parsed = MessageParser::default().parse(&parsed_bytes).unwrap();
866 let emails = |list: Option<&mail_parser::Address<'_>>| -> Vec<String> {
867 list.unwrap()
868 .iter()
869 .map(|a| a.address().unwrap().to_string())
870 .collect()
871 };
872 assert_eq!(emails(parsed.cc()), ["carol@example.com"]);
873 assert_eq!(
874 emails(parsed.bcc()),
875 ["dave@example.com", "erin@example.com"]
876 );
877 assert_eq!(parsed.cc().unwrap().first().unwrap().name(), Some("Carol"));
878 }
879
880 #[test]
881 fn build_writes_an_encoded_from_when_one_is_given() {
882 let composition = Composition {
883 from: Some(mailbox("Zoë Ångström <zoe@example.org>")),
884 ..plain("Hi")
885 };
886 let message = built(&composition);
887 assert!(message.is_ascii());
888 let raw_from = raw_header(&message, "From").unwrap();
889 assert!(raw_from.contains("=?utf-8?"), "{raw_from}");
890 assert!(raw_from.ends_with("<zoe@example.org>"), "{raw_from}");
891 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
892 let from = parsed.from().unwrap().first().unwrap();
893 assert_eq!(from.name(), Some("Zoë Ångström"));
894 assert_eq!(from.address(), Some("zoe@example.org"));
895 }
896
897 #[test]
898 fn build_omits_empty_recipient_headers_and_subject() {
899 let composition = Composition {
900 subject: String::new(),
901 ..plain("")
902 };
903 let message = built(&composition);
904 assert!(raw_header(&message, "Cc").is_none());
905 assert!(raw_header(&message, "Bcc").is_none());
906 assert!(raw_header(&message, "Subject").is_none());
907 }
908
909 #[test]
910 fn build_encodes_non_ascii_subject_and_name_as_rfc2047_encoded_words() {
911 let composition = Composition {
912 to: vec![mailbox("Zoë Ångström <zoe@example.com>")],
913 ..plain("Grüße — 你好")
914 };
915 let message = built(&composition);
916 assert!(message.is_ascii());
918 assert!(raw_header(&message, "Subject")
919 .unwrap()
920 .starts_with("=?utf-8?"));
921 assert!(raw_header(&message, "To").unwrap().contains("=?utf-8?"));
922 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
924 assert_eq!(parsed.subject(), Some("Grüße — 你好"));
925 let to = parsed.to().unwrap().first().unwrap();
926 assert_eq!(to.name(), Some("Zoë Ångström"));
927 assert_eq!(to.address(), Some("zoe@example.com"));
928 }
929
930 #[test]
931 fn build_encodes_a_non_ascii_body_losslessly() {
932 let composition = Composition {
933 body: "Ça va? Ünïcödé body.\n".to_string(),
934 ..plain("Hi")
935 };
936 let message = built(&composition);
937 assert!(message.is_ascii());
938 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
939 assert_eq!(
940 parsed.body_text(0).as_deref(),
941 Some("Ça va? Ünïcödé body.\n")
942 );
943 }
944
945 #[test]
946 fn build_with_attachments_is_multipart_mixed_with_base64_parts() {
947 let binary: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
948 let composition = Composition {
949 attachments: vec![
950 Attachment {
951 filename: "data.bin".to_string(),
952 content_type: "application/octet-stream".to_string(),
953 data: binary.clone(),
954 },
955 Attachment {
956 filename: "Jahresabschluß.pdf".to_string(),
957 content_type: "application/pdf".to_string(),
958 data: b"%PDF-1.4".to_vec(),
959 },
960 ],
961 ..plain("With files")
962 };
963 let message = built(&composition);
964 assert!(raw_header(&message, "Content-Type")
965 .unwrap()
966 .starts_with("multipart/mixed"));
967 assert!(message.contains("Content-Transfer-Encoding: base64"));
968
969 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
970 assert_eq!(parsed.body_text(0).as_deref(), Some("Hello Alice.\r\n"));
971 assert_eq!(parsed.attachment_count(), 2);
972 let first = parsed.attachment(0).unwrap();
973 assert_eq!(first.attachment_name(), Some("data.bin"));
974 assert_eq!(first.contents(), binary.as_slice());
975 let second = parsed.attachment(1).unwrap();
976 assert_eq!(second.attachment_name(), Some("Jahresabschluß.pdf"));
977 assert_eq!(second.content_type().unwrap().ctype(), "application");
978 assert_eq!(second.content_type().unwrap().subtype(), Some("pdf"));
979 assert_eq!(second.contents(), b"%PDF-1.4");
980 }
981
982 fn with_html(html: &str) -> Composition {
983 Composition {
984 html_body: Some(html.to_string()),
985 ..plain("Styled")
986 }
987 }
988
989 fn content_type_at(message: &str, mime_type: &str) -> usize {
991 message
992 .find(&format!("Content-Type: {mime_type}"))
993 .unwrap_or_else(|| panic!("no {mime_type} part in {message}"))
994 }
995
996 #[test]
997 fn build_with_html_is_multipart_alternative_plain_text_first() {
998 let message = built(&with_html("<p>Hello <b>Alice</b>.</p>"));
999 assert!(raw_header(&message, "Content-Type")
1000 .unwrap()
1001 .starts_with("multipart/alternative"));
1002 assert!(content_type_at(&message, "text/plain") < content_type_at(&message, "text/html"));
1004 assert!(!message.contains("multipart/mixed"));
1005
1006 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
1007 assert_eq!(parsed.body_text(0).as_deref(), Some("Hello Alice.\r\n"));
1008 assert_eq!(
1009 parsed.body_html(0).as_deref(),
1010 Some("<p>Hello <b>Alice</b>.</p>")
1011 );
1012 }
1013
1014 #[test]
1015 fn build_with_html_and_an_attachment_nests_the_alternative_in_mixed() {
1016 let composition = Composition {
1017 attachments: vec![Attachment {
1018 filename: "q3.pdf".to_string(),
1019 content_type: "application/pdf".to_string(),
1020 data: b"%PDF-1.4".to_vec(),
1021 }],
1022 ..with_html("<p>Figures attached.</p>")
1023 };
1024 let message = built(&composition);
1025 assert!(raw_header(&message, "Content-Type")
1026 .unwrap()
1027 .starts_with("multipart/mixed"));
1028 let alternative = content_type_at(&message, "multipart/alternative");
1029 assert!(alternative < content_type_at(&message, "text/plain"));
1030 assert!(
1031 content_type_at(&message, "text/html") < content_type_at(&message, "application/pdf")
1032 );
1033
1034 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
1035 assert_eq!(parsed.body_text(0).as_deref(), Some("Hello Alice.\r\n"));
1036 assert_eq!(
1037 parsed.body_html(0).as_deref(),
1038 Some("<p>Figures attached.</p>")
1039 );
1040 assert_eq!(parsed.attachment_count(), 1);
1041 assert_eq!(parsed.attachment(0).unwrap().contents(), b"%PDF-1.4");
1042 }
1043
1044 #[test]
1045 fn build_encodes_a_non_ascii_html_body_losslessly() {
1046 let message = built(&with_html("<p>Grüße, “Ünïcödé” — ✓</p>"));
1047 assert!(message.is_ascii());
1048 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
1049 assert_eq!(
1050 parsed.body_html(0).as_deref(),
1051 Some("<p>Grüße, “Ünïcödé” — ✓</p>")
1052 );
1053 }
1054
1055 #[test]
1056 fn build_reply_with_html_keeps_the_threading_headers() {
1057 let composition = Composition {
1058 reply: Some(ReplyContext {
1059 thread_id: "t".to_string(),
1060 message_id: Some("b@example.com".to_string()),
1061 ..ReplyContext::default()
1062 }),
1063 ..with_html("<p>Thanks!</p>")
1064 };
1065 let message = built(&composition);
1066 assert_eq!(
1067 raw_header(&message, "In-Reply-To").as_deref(),
1068 Some("<b@example.com>")
1069 );
1070 assert_eq!(
1071 raw_header(&message, "References").as_deref(),
1072 Some("<b@example.com>")
1073 );
1074 }
1075
1076 #[test]
1077 fn plain_text_from_html_keeps_links_and_emphasis_as_markdown() {
1078 let text = plain_text_from_html(
1079 "<p>See <a href=\"https://example.com/q3\">the report</a>, <b>today</b>.</p>",
1080 )
1081 .unwrap();
1082 assert_eq!(text, "See [the report](https://example.com/q3), **today**.");
1083 }
1084
1085 #[test]
1086 fn plain_text_from_html_leaves_out_a_documents_head_styles_and_scripts() {
1087 let text = plain_text_from_html(
1088 "<!DOCTYPE html><html><head><title>Q3 Note</title>\
1089 <style>p { color: red }</style></head>\
1090 <body><style>.x { y: z }</style><p>Hello <b>Alice</b>.</p>\
1091 <script>alert(1)</script></body></html>",
1092 )
1093 .unwrap();
1094 assert_eq!(text, "Hello **Alice**.");
1095 }
1096
1097 #[test]
1098 fn inline_image_warning_fires_on_cid_references_in_any_case() {
1099 assert!(inline_image_warning("<img src=\"CID:logo@x\">")
1100 .unwrap()
1101 .contains("inline images"));
1102 assert!(inline_image_warning("<div style=\"background: url(cid:bg)\">").is_some());
1103 assert_eq!(
1104 inline_image_warning("<img src=\"https://example.com/a.png\">"),
1105 None
1106 );
1107 }
1108
1109 #[test]
1110 fn build_rejects_a_subject_with_a_line_break() {
1111 let err = plain("Hi\r\nBcc: eve@example.com")
1112 .build()
1113 .unwrap_err()
1114 .to_string();
1115 assert!(err.contains("subject"), "{err}");
1116 }
1117
1118 #[test]
1119 fn build_rejects_an_attachment_content_type_with_a_line_break() {
1120 let composition = Composition {
1121 attachments: vec![Attachment {
1122 filename: "a.txt".to_string(),
1123 content_type: "text/plain\r\nX-Evil: 1".to_string(),
1124 data: Vec::new(),
1125 }],
1126 ..plain("Hi")
1127 };
1128 assert!(composition.build().is_err());
1129 }
1130
1131 fn original(headers: &[(&str, &str)]) -> Message {
1134 let headers: Vec<serde_json::Value> = headers
1135 .iter()
1136 .map(|(name, value)| serde_json::json!({"name": name, "value": value}))
1137 .collect();
1138 Message {
1139 id: "m-orig".to_string(),
1140 thread_id: Some("t-orig".to_string()),
1141 payload: Some(serde_json::json!({ "headers": headers })),
1142 ..Message::default()
1143 }
1144 }
1145
1146 #[test]
1147 fn reply_context_reads_ids_without_angle_brackets() {
1148 let reply = ReplyContext::from_message(&original(&[
1149 ("Subject", "Quarterly report"),
1150 ("Message-Id", "<c@example.com>"),
1151 ("References", "<a@example.com>\r\n <b@example.com>"),
1152 ("In-Reply-To", "<b@example.com>"),
1153 ]))
1154 .unwrap();
1155 assert_eq!(
1156 reply,
1157 ReplyContext {
1158 thread_id: "t-orig".to_string(),
1159 message_id: Some("c@example.com".to_string()),
1160 references: vec!["a@example.com".to_string(), "b@example.com".to_string()],
1161 in_reply_to: vec!["b@example.com".to_string()],
1162 subject: "Quarterly report".to_string(),
1163 ..ReplyContext::default()
1164 }
1165 );
1166 }
1167
1168 #[test]
1171 fn from_parts_trims_and_drops_an_empty_name() {
1172 assert_eq!(
1173 Mailbox::from_parts(Some(" "), " bob@example.com ").unwrap(),
1174 Mailbox {
1175 name: None,
1176 email: "bob@example.com".to_string()
1177 }
1178 );
1179 assert_eq!(
1180 Mailbox::from_parts(Some(" Bob "), "bob@example.com")
1181 .unwrap()
1182 .name
1183 .as_deref(),
1184 Some("Bob")
1185 );
1186 }
1187
1188 #[test]
1189 fn from_parts_rejects_control_characters_and_bad_addresses() {
1190 for (name, email) in [
1191 (Some("Eve\r\nBcc: x@example.com"), "eve@example.com"),
1192 (Some("Eve\u{1b}[31m"), "eve@example.com"),
1193 (None, "eve@example.com\n"),
1194 (None, ""),
1195 (None, "no-at-sign"),
1196 ] {
1197 assert!(
1198 Mailbox::from_parts(name, email).is_err(),
1199 "{name:?} {email:?} was accepted"
1200 );
1201 }
1202 }
1203
1204 #[test]
1205 fn mailbox_displays_name_and_address() {
1206 assert_eq!(mailbox("Alice <a@x.org>").to_string(), "Alice <a@x.org>");
1207 assert_eq!(mailbox("a@x.org").to_string(), "a@x.org");
1208 }
1209
1210 fn emails(mailboxes: &[Mailbox]) -> Vec<&str> {
1213 mailboxes.iter().map(|m| m.email.as_str()).collect()
1214 }
1215
1216 fn recipients_of(headers: &[(&str, &str)]) -> ReplyContext {
1217 ReplyContext::from_message(&original(headers)).unwrap()
1218 }
1219
1220 #[test]
1221 fn reply_context_reads_the_address_headers_and_sent_label() {
1222 let mut message = original(&[
1223 ("From", "Alice <alice@example.com>"),
1224 ("Reply-To", "list@example.com"),
1225 ("To", "me@example.org, Bob <bob@example.com>"),
1226 ("Cc", "carol@example.com"),
1227 ]);
1228 message.label_ids = vec!["INBOX".to_string(), "SENT".to_string()];
1229 let reply = ReplyContext::from_message(&message).unwrap();
1230 assert_eq!(reply.from, [mailbox("Alice <alice@example.com>")]);
1231 assert_eq!(emails(&reply.reply_to), ["list@example.com"]);
1232 assert_eq!(emails(&reply.to), ["me@example.org", "bob@example.com"]);
1233 assert_eq!(emails(&reply.cc), ["carol@example.com"]);
1234 assert!(reply.sent_by_me);
1235 assert!(reply.skipped.is_empty());
1236 assert!(!recipients_of(&[]).sent_by_me);
1237 }
1238
1239 #[test]
1240 fn reply_context_decodes_encoded_words_and_quoted_names() {
1241 let reply = recipients_of(&[
1242 (
1243 "From",
1244 "=?UTF-8?Q?Zo=C3=AB_=C3=85ngstr=C3=B6m?= <zoe@example.com>",
1245 ),
1246 (
1247 "To",
1248 r#""Doe, Jane" <jane@example.com>, bob@example.com (Bob)"#,
1249 ),
1250 ]);
1251 assert_eq!(reply.from[0].name.as_deref(), Some("Zoë Ångström"));
1252 assert_eq!(reply.from[0].email, "zoe@example.com");
1253 assert_eq!(reply.to[0].name.as_deref(), Some("Doe, Jane"));
1254 assert_eq!(emails(&reply.to), ["jane@example.com", "bob@example.com"]);
1255 }
1256
1257 #[test]
1258 fn reply_context_flattens_groups_and_unfolds() {
1259 let reply = recipients_of(&[
1260 (
1261 "To",
1262 "Team: a@example.com,\r\n b@example.com;, c@example.com",
1263 ),
1264 ("Cc", "undisclosed-recipients:;"),
1265 ]);
1266 assert_eq!(
1267 emails(&reply.to),
1268 ["a@example.com", "b@example.com", "c@example.com"]
1269 );
1270 assert!(reply.cc.is_empty());
1271 assert!(reply.skipped.is_empty(), "{:?}", reply.skipped);
1272 }
1273
1274 #[test]
1275 fn reply_context_skips_an_encoded_line_break_in_a_name() {
1276 let reply = recipients_of(&[(
1278 "Cc",
1279 "=?UTF-8?Q?Eve=0D=0ABcc:_x@example.com?= <eve@example.com>, ok@example.com",
1280 )]);
1281 assert_eq!(emails(&reply.cc), ["ok@example.com"]);
1282 assert_eq!(reply.skipped, [("Cc", "eve@example.com".to_string())]);
1283 }
1284
1285 #[test]
1286 fn reply_context_reads_every_repeated_address_header() {
1287 let reply = recipients_of(&[
1288 ("Cc", "a@example.com"),
1289 ("To", "t@example.com"),
1290 ("cc", "b@example.com, c@example.com"),
1291 ]);
1292 assert_eq!(
1293 emails(&reply.cc),
1294 ["a@example.com", "b@example.com", "c@example.com"]
1295 );
1296 }
1297
1298 #[test]
1299 fn reply_context_unfolds_a_tab_in_a_quoted_name() {
1300 let reply = recipients_of(&[("To", "\"Doe,\r\n\tJane\" <jane@example.com>")]);
1301 assert_eq!(reply.to.len(), 1, "{:?}", reply.skipped);
1302 assert_eq!(reply.to[0].name.as_deref(), Some("Doe, Jane"));
1303 }
1304
1305 #[test]
1306 fn reply_context_keeps_a_raw_line_break_inside_its_header() {
1307 let reply = recipients_of(&[("From", "a@example.com\r\nCc: eve@example.com")]);
1310 assert!(reply.cc.is_empty());
1311 assert!(reply.from.len() <= 1, "{:?}", reply.from);
1312 }
1313
1314 fn context(from: &[&str], reply_to: &[&str], to: &[&str], cc: &[&str]) -> ReplyContext {
1315 let list = |values: &[&str]| values.iter().map(|v| mailbox(v)).collect();
1316 ReplyContext {
1317 from: list(from),
1318 reply_to: list(reply_to),
1319 to: list(to),
1320 cc: list(cc),
1321 ..ReplyContext::default()
1322 }
1323 }
1324
1325 #[test]
1326 fn default_recipients_prefers_reply_to_over_from() {
1327 let reply = context(&["a@x.org"], &["list@x.org", "b@x.org"], &["me@x.org"], &[]);
1328 let (to, cc) = reply.default_recipients(false, &[]);
1329 assert_eq!(emails(&to), ["list@x.org", "b@x.org"]);
1330 assert!(cc.is_empty());
1331 }
1332
1333 #[test]
1334 fn default_recipients_falls_back_to_from() {
1335 let reply = context(&["Alice <a@x.org>"], &[], &["me@x.org"], &["c@x.org"]);
1336 let (to, cc) = reply.default_recipients(false, &[]);
1337 assert_eq!(to, [mailbox("Alice <a@x.org>")]);
1338 assert!(cc.is_empty());
1339 }
1340
1341 #[test]
1342 fn default_recipients_of_a_sent_message_go_to_its_to() {
1343 let reply = ReplyContext {
1344 sent_by_me: true,
1345 ..context(&["me@x.org"], &[], &["b@x.org", "c@x.org"], &["d@x.org"])
1346 };
1347 let (to, cc) = reply.default_recipients(false, &[]);
1348 assert_eq!(emails(&to), ["b@x.org", "c@x.org"]);
1349 assert!(cc.is_empty());
1350 let (to, cc) = reply.default_recipients(true, &["me@x.org".to_string()]);
1351 assert_eq!(emails(&to), ["b@x.org", "c@x.org"]);
1352 assert_eq!(emails(&cc), ["d@x.org"]);
1353 }
1354
1355 #[test]
1356 fn default_recipients_reply_all_keeps_each_recipient_in_its_header() {
1357 let reply = context(&["a@x.org"], &[], &["me@x.org", "b@x.org"], &["c@x.org"]);
1358 let (to, cc) = reply.default_recipients(true, &["me@x.org".to_string()]);
1359 assert_eq!(emails(&to), ["a@x.org", "b@x.org"]);
1360 assert_eq!(emails(&cc), ["c@x.org"]);
1361 }
1362
1363 #[test]
1364 fn default_recipients_excludes_addresses_case_insensitively() {
1365 let reply = context(
1366 &["a@x.org"],
1367 &[],
1368 &["Me@X.org", "b@x.org"],
1369 &["ALIAS@y.org", "c@x.org"],
1370 );
1371 let own = ["me@x.org".to_string(), "alias@Y.org".to_string()];
1372 let (to, cc) = reply.default_recipients(true, &own);
1373 assert_eq!(emails(&to), ["a@x.org", "b@x.org"]);
1374 assert_eq!(emails(&cc), ["c@x.org"]);
1375 }
1376
1377 #[test]
1378 fn default_recipients_dedupes_across_to_and_cc() {
1379 let reply = context(
1380 &["a@x.org"],
1381 &[],
1382 &["A@x.org", "b@x.org", "b@x.org"],
1383 &["B@X.ORG", "c@x.org", "a@x.org"],
1384 );
1385 let (to, cc) = reply.default_recipients(true, &[]);
1386 assert_eq!(emails(&to), ["a@x.org", "b@x.org"]);
1387 assert_eq!(emails(&cc), ["c@x.org"]);
1388 }
1389
1390 #[test]
1391 fn default_recipients_can_be_empty() {
1392 let (to, cc) = ReplyContext::default().default_recipients(true, &[]);
1393 assert!(to.is_empty() && cc.is_empty());
1394 let reply = ReplyContext {
1396 sent_by_me: true,
1397 ..context(&["me@x.org"], &[], &["me@x.org"], &[])
1398 };
1399 assert!(reply
1400 .default_recipients(true, &["me@x.org".to_string()])
1401 .0
1402 .is_empty());
1403 }
1404
1405 #[test]
1406 fn reply_context_requires_a_thread_id() {
1407 let message = Message {
1408 thread_id: None,
1409 ..original(&[])
1410 };
1411 assert!(ReplyContext::from_message(&message).is_err());
1412 }
1413
1414 #[test]
1415 fn reply_references_appends_the_parent_to_its_references() {
1416 let reply = ReplyContext {
1417 message_id: Some("c@x".to_string()),
1418 references: vec!["a@x".to_string(), "b@x".to_string()],
1419 in_reply_to: vec!["b@x".to_string()],
1420 ..ReplyContext::default()
1421 };
1422 assert_eq!(reply.reply_references(), ["a@x", "b@x", "c@x"]);
1423 }
1424
1425 #[test]
1426 fn reply_references_falls_back_to_in_reply_to_and_dedupes() {
1427 let reply = ReplyContext {
1428 message_id: Some("c@x".to_string()),
1429 in_reply_to: vec!["b@x".to_string(), "c@x".to_string()],
1430 ..ReplyContext::default()
1431 };
1432 assert_eq!(reply.reply_references(), ["b@x", "c@x"]);
1433 }
1434
1435 #[test]
1436 fn reply_references_of_a_first_message_is_just_its_id() {
1437 let reply = ReplyContext {
1438 message_id: Some("c@x".to_string()),
1439 ..ReplyContext::default()
1440 };
1441 assert_eq!(reply.reply_references(), ["c@x"]);
1442 }
1443
1444 #[test]
1445 fn reply_subject_prefixes_re_once() {
1446 let subject = |s: &str| {
1447 ReplyContext {
1448 subject: s.to_string(),
1449 ..ReplyContext::default()
1450 }
1451 .reply_subject()
1452 };
1453 assert_eq!(subject("Report"), "Re: Report");
1454 assert_eq!(subject(" Report "), "Re: Report");
1455 assert_eq!(subject("Re: Report"), "Re: Report");
1456 assert_eq!(subject("RE: Report"), "RE: Report");
1457 assert_eq!(subject("re:Report"), "re:Report");
1458 assert_eq!(subject("RE : Report"), "RE : Report");
1459 assert_eq!(subject("Regarding"), "Re: Regarding");
1460 assert_eq!(subject(""), "Re:");
1461 assert_eq!(subject("Ré"), "Re: Ré");
1462 }
1463
1464 #[test]
1465 fn subject_matches_reply_ignores_case_and_whitespace() {
1466 let reply = ReplyContext {
1467 subject: "Report".to_string(),
1468 ..ReplyContext::default()
1469 };
1470 assert!(subject_matches_reply(" re: report ", &reply));
1471 assert!(subject_matches_reply("Report", &reply));
1472 assert!(subject_matches_reply("Re: Re: REPORT", &reply));
1473 assert!(!subject_matches_reply("Re: Other", &reply));
1474 assert!(!subject_matches_reply("Reports", &reply));
1475 }
1476
1477 #[test]
1478 fn strip_reply_prefixes_removes_only_leading_re_colons() {
1479 assert_eq!(strip_reply_prefixes("Re: RE :re:x"), "x");
1480 assert_eq!(strip_reply_prefixes("Regarding"), "Regarding");
1481 assert_eq!(strip_reply_prefixes("Re"), "Re");
1482 assert_eq!(strip_reply_prefixes("Ré: x"), "Ré: x");
1483 assert_eq!(strip_reply_prefixes(" "), "");
1484 }
1485
1486 #[test]
1487 fn build_accepts_a_tab_in_the_subject() {
1488 let message = built(&plain("Re: Quarterly\treport"));
1489 let parsed = MessageParser::default().parse(message.as_bytes()).unwrap();
1490 assert!(parsed.subject().unwrap().starts_with("Re: Quarterly"));
1491 }
1492
1493 #[test]
1494 fn parse_msg_ids_handles_folding_and_missing_brackets() {
1495 assert_eq!(
1496 parse_msg_ids("<a@x> <b@x>\r\n\t<c@x>"),
1497 ["a@x", "b@x", "c@x"]
1498 );
1499 assert_eq!(parse_msg_ids("a@x b@x"), ["a@x", "b@x"]);
1500 assert!(parse_msg_ids("").is_empty());
1501 assert!(parse_msg_ids("<>").is_empty());
1502 assert!(parse_msg_ids("<unclosed").is_empty());
1505 assert_eq!(parse_msg_ids("<a@x> <unclosed"), ["a@x"]);
1506 }
1507
1508 #[test]
1509 fn build_reply_writes_in_reply_to_and_references() {
1510 let composition = Composition {
1511 reply: Some(ReplyContext {
1512 thread_id: "t-orig".to_string(),
1513 message_id: Some("c@example.com".to_string()),
1514 references: vec!["a@example.com".to_string()],
1515 in_reply_to: Vec::new(),
1516 subject: "Report".to_string(),
1517 ..ReplyContext::default()
1518 }),
1519 ..plain("Re: Report")
1520 };
1521 let message = built(&composition);
1522 assert_eq!(
1523 raw_header(&message, "In-Reply-To").as_deref(),
1524 Some("<c@example.com>")
1525 );
1526 assert_eq!(
1527 raw_header(&message, "References").as_deref(),
1528 Some("<a@example.com> <c@example.com>")
1529 );
1530 }
1531
1532 #[test]
1533 fn build_reply_to_a_message_without_an_id_writes_no_threading_headers() {
1534 let composition = Composition {
1535 reply: Some(ReplyContext {
1536 thread_id: "t".to_string(),
1537 ..ReplyContext::default()
1538 }),
1539 ..plain("Re: x")
1540 };
1541 let message = built(&composition);
1542 assert!(raw_header(&message, "In-Reply-To").is_none());
1543 assert!(raw_header(&message, "References").is_none());
1544 }
1545}