Skip to main content

omni_dev/gmail/
compose.rs

1//! RFC 5322 message composition for `gmail draft create` (#1923).
2//!
3//! The encoding itself (RFC 2047 encoded words for non-ASCII subjects and
4//! display names, RFC 2231 `filename*=` parameters, `multipart/mixed`
5//! assembly and base64 transfer encoding) is `mail-builder`'s. This module
6//! owns what that crate leaves to its caller:
7//!
8//! - **Input validation.** A CR or LF in a subject, display name or address
9//!   would let a caller inject a header, so [`Mailbox::parse`] and
10//!   [`Composition::build`] reject them outright rather than relying on the
11//!   encoder to neutralise them.
12//! - **Reply threading.** Gmail only files a draft into an existing thread
13//!   when three things agree: the draft's `threadId`, its `In-Reply-To` /
14//!   `References` headers, and its `Subject`. [`ReplyContext`] derives the
15//!   last two from the original message's headers.
16//! - **Reply recipients.** [`ReplyContext::default_recipients`] works out who
17//!   a reply goes to when the caller names nobody, as a mail client's Reply
18//!   and Reply All do (#1954). The original's address headers are decoded
19//!   with `mail-parser`, and every address is re-validated by
20//!   [`Mailbox::from_parts`] before it can reach a header.
21//!
22//! - **The `Message-ID`.** `mail-builder` always writes one, and with its
23//!   `gethostname` feature off its host part is `localhost`. A
24//!   [`Composition::message_id_domain`] replaces that with the account's own
25//!   domain (#1953). This is the uploaded id only: Gmail gives the stored
26//!   draft an id of its own on every save (#1966).
27//!
28//! - **HTML bodies.** An HTML body always goes out as `multipart/alternative`
29//!   beside a plain-text part (#1955). [`plain_text_from_html`] derives that
30//!   part when the caller has none, and [`inline_image_warning`] flags HTML
31//!   that expects inline images nothing here attaches.
32//!
33//! `From` is set only when the caller names one ([`Composition::from`],
34//! checked against the account's send-as aliases by the caller, #1956);
35//! otherwise Gmail fills in the authenticated account's address.
36
37use 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
50/// The headers [`ReplyContext::from_message`] reads, for the caller's
51/// `messages.get(format=metadata)` request.
52pub 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
63/// Gmail's label on every message the account sent.
64const SENT_LABEL: &str = "SENT";
65
66/// One recipient: an address with an optional display name.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Mailbox {
69    /// Display name, if one was given.
70    pub name: Option<String>,
71    /// The `local@domain` address.
72    pub email: String,
73}
74
75impl Mailbox {
76    /// Parses `addr@example.com` or `Display Name <addr@example.com>`.
77    ///
78    /// A display name may be double-quoted (`"Doe, Jane" <j@example.com>`);
79    /// the quotes are removed and the builder re-quotes or encodes it as
80    /// needed. The input is one mailbox, never a comma-separated list, so a
81    /// comma inside a name is safe. Rejects control characters (CR/LF would
82    /// inject a header), a missing `@`, and whitespace or delimiters inside
83    /// the address.
84    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    /// Builds a mailbox from an already-split name and address, such as one
103    /// decoded from another message's header.
104    ///
105    /// Applies the same checks as [`Mailbox::parse`]. The name is checked
106    /// *after* decoding, since an RFC 2047 encoded word can carry a CR, LF
107    /// or ESC that the raw header never showed. An empty name is `None`.
108    pub fn from_parts(name: Option<&str>, email: &str) -> Result<Self> {
109        // Trim spaces only, so a stray CR/LF at either end is still seen
110        // (and refused) by `checked` rather than quietly trimmed away.
111        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    /// The checks [`Mailbox::parse`] and [`Mailbox::from_parts`] share: no
120    /// control character in the name or address (CR/LF would inject a
121    /// header), then [`validate_email`].
122    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    /// `Name <addr>`, or the bare address. For messages to a person, not
144    /// for a header: the name is not quoted or encoded.
145    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
153/// Strips one pair of surrounding double quotes and undoes RFC 5322
154/// quoted-pairs (`\x` → `x`) in a single left-to-right pass.
155fn 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
173/// A deliberately loose check: enough to catch typos and anything that would
174/// corrupt the header, not a full RFC 5321 validator. Gmail rejects or
175/// bounces the rest.
176fn 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/// A file attached to a composed message.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Attachment {
195    /// The filename recipients see. Non-ASCII is RFC 2231-encoded.
196    pub filename: String,
197    /// The MIME type, e.g. `application/pdf`.
198    pub content_type: String,
199    /// The file's bytes, base64-encoded on the wire.
200    pub data: Vec<u8>,
201}
202
203/// What a reply needs from the message it replies to.
204#[derive(Debug, Clone, PartialEq, Eq, Default)]
205pub struct ReplyContext {
206    /// Gmail thread id of the original. Goes on the draft's `message`, not
207    /// in a header.
208    pub thread_id: String,
209    /// The original's `Message-ID`, without angle brackets.
210    pub message_id: Option<String>,
211    /// The original's `References`, without angle brackets, in order.
212    pub references: Vec<String>,
213    /// The original's `In-Reply-To`, without angle brackets.
214    pub in_reply_to: Vec<String>,
215    /// The original's `Subject`, or empty.
216    pub subject: String,
217    /// The original's `From`.
218    pub from: Vec<Mailbox>,
219    /// The original's `Reply-To`.
220    pub reply_to: Vec<Mailbox>,
221    /// The original's `To`.
222    pub to: Vec<Mailbox>,
223    /// The original's `Cc`.
224    pub cc: Vec<Mailbox>,
225    /// Whether the account sent the original (Gmail's `SENT` label, which
226    /// also covers send-as aliases).
227    pub sent_by_me: bool,
228    /// Addresses in the original's address headers that could not be used,
229    /// as `(header, address)`, for the caller to warn about.
230    pub skipped: Vec<(&'static str, String)>,
231}
232
233impl ReplyContext {
234    /// Reads the reply context from a message fetched with
235    /// `format=metadata` and [`REPLY_HEADERS`].
236    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    /// The reply's `To` and `Cc` when the caller names nobody (#1954).
267    ///
268    /// A plain reply goes to the original's `Reply-To`, else its `From`; a
269    /// reply to a message the account sent goes to that message's `To`
270    /// instead, as in Gmail's UI. `reply_all` adds the original's `To` to the
271    /// reply's `To` and its `Cc` to the reply's `Cc`, keeping each recipient
272    /// in the header they were in. The addresses in `exclude` (the account's
273    /// own, and any the caller has already placed) are left out, as are
274    /// repeats, both compared case-insensitively and keeping the first
275    /// occurrence (`To` before `Cc`).
276    ///
277    /// Either list can come back empty, `To` included: deciding what that
278    /// means is the caller's job.
279    #[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    /// The reply's `References`: the original's `References` (or, lacking
314    /// those, its `In-Reply-To`) followed by the original's `Message-ID`,
315    /// per RFC 5322 §3.6.4. Duplicates are dropped, keeping the first.
316    #[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    /// `Re: <original subject>`, unless the original already starts with a
333    /// `Re:` (any case, `Re :` too), so replies don't stack `Re: Re:`.
334    #[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
347/// `subject` with every leading `Re:` removed (any case, optional
348/// whitespace before the colon), then trimmed.
349fn 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/// Whether `subject` keeps a reply in the original's thread.
367///
368/// Compares the two subjects without their leading `Re:` prefixes,
369/// ignoring case, as Gmail does: both `Re: Report` and `Report` match an
370/// original of `Report`.
371#[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
376/// Extracts the ids from a `Message-ID` / `References` / `In-Reply-To`
377/// value, without their angle brackets. Falls back to whitespace-separated
378/// tokens when a malformed value has no brackets at all.
379fn 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
398/// Every value of the header `name`, joined with `, `. A long recipient
399/// list is sometimes split over several `To`/`Cc` headers, and
400/// [`header_value`] returns only the first.
401fn 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
418/// Decodes the address header `name` (one of `From`, `Reply-To`, `To` or
419/// `Cc`) with `mail-parser`, which handles RFC 2047 encoded words, quoted
420/// names, comments and groups (`Team: a@x, b@y;`, whose members are
421/// flattened into the list).
422///
423/// Returns the usable mailboxes, then the addresses [`Mailbox::from_parts`]
424/// refused (as the address, or the name when there is none), for the caller
425/// to warn about. An empty group such as `undisclosed-recipients:;` yields
426/// neither.
427fn parse_address_header(name: &str, value: &str) -> (Vec<Mailbox>, Vec<String>) {
428    // Unfold as RFC 5322 does (drop the CRLF, keep the whitespace after it),
429    // then turn any stray CR, LF or tab into a space: a folded quoted name
430    // would otherwise keep the fold's tab (a control character), and a lone
431    // line break must not end the synthetic header early.
432    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/// A message to build: `text/plain`, or `multipart/alternative` when it has
471/// an HTML body.
472#[derive(Debug, Clone, Default)]
473pub struct Composition {
474    /// The `From` mailbox. `None` leaves the header out, and Gmail fills in
475    /// the account's primary address. Gmail only sends as a verified
476    /// send-as alias, so the caller checks it first (see
477    /// [`crate::gmail::send_as_api::resolve_from`]).
478    pub from: Option<Mailbox>,
479    /// `To` recipients.
480    pub to: Vec<Mailbox>,
481    /// `Cc` recipients.
482    pub cc: Vec<Mailbox>,
483    /// `Bcc` recipients. Kept as a header: Gmail reads it from the draft
484    /// when the draft is sent, and strips it from what recipients receive.
485    pub bcc: Vec<Mailbox>,
486    /// The `Subject`. May be empty.
487    pub subject: String,
488    /// The plain-text body, or with `html_body` its plain-text alternative.
489    pub body: String,
490    /// The HTML body. Sent as `multipart/alternative` after `body`, so
491    /// clients that can render HTML show this one.
492    pub html_body: Option<String>,
493    /// Files attached after the body. Any attachment makes the message
494    /// `multipart/mixed`.
495    pub attachments: Vec<Attachment>,
496    /// When replying: the source of `In-Reply-To` and `References`.
497    pub reply: Option<ReplyContext>,
498    /// The domain for the generated `Message-ID`, normally the account
499    /// address's (see [`message_id_domain`]). `None` leaves the id to
500    /// `mail-builder`, which ends it in `@localhost`.
501    pub message_id_domain: Option<String>,
502}
503
504/// The domain part of `email`, when it can serve as a `Message-ID`'s right
505/// half: a dot-separated run of letters, digits and hyphens.
506///
507/// Anything else (no `@`, an address literal, a stray character) is `None`,
508/// so a malformed address can never inject into the header.
509#[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
515/// Whether `domain` is an RFC 1035 host name: at most 253 characters of
516/// dot-separated labels, each 1 to 63 letters, digits and hyphens that
517/// neither starts nor ends with a hyphen. The last label may not be all
518/// digits (RFC 1123 §2.1), which also rules out a bare IPv4 address.
519fn 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
533/// Refuses a subject that would break the header.
534///
535/// [`Composition::build`] runs it too; it is public so a caller can refuse
536/// a bad subject before it makes any request.
537pub fn check_subject(subject: &str) -> Result<()> {
538    // A tab is legal (and survives unfolding a long original subject);
539    // CR, LF and NUL would break the header.
540    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    /// Serialises the message to RFC 5322 bytes with CRLF line endings.
549    ///
550    /// The `Message-ID` is `<{random}@{message_id_domain}>`. Gmail replaces
551    /// it on the stored draft (#1966), but whether the id is also replaced
552    /// when the draft is sent is unverified (#1953), so it is made valid
553    /// either way rather than left as `@localhost`.
554    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
615/// The plain-text alternative for an HTML body, as Markdown.
616///
617/// `htmd` converts it, the converter `gmail render` uses for an HTML-only
618/// message. Markdown keeps links as `[text](url)`, which stripping the tags
619/// would lose. A full HTML document's `<head>`, `<style>` and `<script>`
620/// are left out, so an email template's title and CSS don't open the plain
621/// text. A failed conversion is an error, never raw HTML in the plain part.
622pub 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/// A warning when `html` refers to an inline image (`cid:`), which no
631/// command here attaches, so the image would show as broken.
632///
633/// A plain case-insensitive search: it can fire on `cid:` in the text
634/// itself, which only costs a spurious warning.
635#[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        // An empty header name leaves mail-parser unable to find any header
653        // at all, taking the `parse_headers` None branch that a real call
654        // site (always a fixed, non-empty header name) never hits.
655        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        // `parse_address_header`'s only call site always passes one of
664        // From/Reply-To/To/Cc; a header name outside that set falls through
665        // the match's catch-all arm.
666        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    /// The raw (still-encoded) value of the first header named `name`,
690    /// with folding undone.
691    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    // ── Mailbox::parse ───────────────────────────────────────────────
702
703    #[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    // ── Composition::build ───────────────────────────────────────────
764
765    #[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        // With no domain, the id is the builder's own placeholder (#1953).
777        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        // A 7-bit body goes out with RFC 5322's CRLF line endings.
787        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        // RFC 5322 `id-left` is a `dot-atom-text`.
806        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        // The wire form is pure ASCII encoded words…
917        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        // …which decode back to the originals.
923        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    /// Byte offset of the first `Content-Type: {mime_type}` line.
990    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        // Clients show the last alternative they can render, so HTML goes last.
1003        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    // ── Reply threading ──────────────────────────────────────────────
1132
1133    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    // ── Mailbox::from_parts ──────────────────────────────────────────
1169
1170    #[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    // ── Reply recipients ─────────────────────────────────────────────
1211
1212    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        // `=0D=0A` decodes to CR LF: kept, it would inject a header.
1277        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        // However `mail-parser` reads the garbage, it stays a `From` value:
1308        // it can't start a `Cc` of its own.
1309        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        // Replying to your own message sent only to yourself.
1395        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        // An unclosed `<` stops the scan without panicking or looping;
1503        // ids found before it are kept.
1504        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}