Skip to main content

omni_dev/gmail/
draft_edit.rs

1//! In-place editing of a stored draft for `gmail draft update` (#1924).
2//!
3//! `drafts.update` has no partial form: it replaces the whole message. So a
4//! field-level edit fetches the draft's RFC 5322 bytes, changes only what was
5//! asked for, and uploads the result. Rebuilding the message from parsed
6//! fields ([`crate::gmail::compose::Composition`]) would lose everything that
7//! builder doesn't model: a draft written in the Gmail UI carries an HTML
8//! alternative, headers of its own, and attachments whose exact encoding
9//! nothing here needs to touch. [`DraftEdit::apply`] instead edits the bytes
10//! **in place**, and everything not named survives byte for byte:
11//!
12//! - **Header edits** (`From`, `To`, `Cc`, `Bcc`, `Subject`) replace just those
13//!   header fields, encoded by `mail-builder` exactly as `draft create`
14//!   encodes them. The body is not touched.
15//! - **Structural edits** (the body, adding or removing attachments) work on
16//!   the top-level MIME entity only. For a `multipart/mixed` message the
17//!   first non-attachment part is "the body" and every other part is kept
18//!   as it is; any other message is itself "the body". New parts are written
19//!   by `mail-builder` and the parts are re-joined under a fresh boundary.
20//!   A body edit replaces "the body" whole: with plain text alone, or with a
21//!   `multipart/alternative` of plain text and HTML (#1955). Neither half of
22//!   an existing alternative is ever kept, so the two can't drift apart.
23//!
24//! Every other top-level header that isn't a `Content-*` header (`From`
25//! unless it is edited, `Date`, `Message-ID`, `In-Reply-To`, `References`,
26//! …) is always kept in the upload. Gmail then replaces the stored draft's
27//! `Message-ID` and `Date` on every save (#1966), so those two survive only
28//! as far as the request.
29
30use std::borrow::Cow;
31use std::collections::HashSet;
32
33use anyhow::{bail, ensure, Result};
34use mail_builder::headers::address::Address;
35use mail_builder::headers::text::Text;
36use mail_builder::headers::Header;
37use mail_builder::mime::{BodyPart, MimePart};
38use mail_parser::{MessageParser, MimeHeaders};
39
40use crate::gmail::attachments::dedupe_filename;
41use crate::gmail::compose::{
42    inline_image_warning, subject_matches_reply, Attachment, Mailbox, ReplyContext,
43};
44use crate::utils::multipart::generate_boundary_absent_from;
45use crate::utils::path::attachment_filename;
46
47/// The changes to make to a draft's message. A `None` or empty field leaves
48/// that part of the message exactly as it is.
49#[derive(Debug, Clone, Default)]
50pub struct DraftEdit {
51    /// Replaces the `From` mailbox. `Some(None)` removes the header, so
52    /// Gmail fills in the primary address and the account's name. Checking
53    /// the mailbox against the account's send-as aliases is the caller's
54    /// job.
55    pub from: Option<Option<Mailbox>>,
56    /// Replaces the whole `To` list. An empty list removes the header.
57    pub to: Option<Vec<Mailbox>>,
58    /// Replaces the whole `Cc` list. An empty list removes the header.
59    pub cc: Option<Vec<Mailbox>>,
60    /// Replaces the whole `Bcc` list. An empty list removes the header.
61    pub bcc: Option<Vec<Mailbox>>,
62    /// Replaces the `Subject`. An empty subject removes the header.
63    pub subject: Option<String>,
64    /// Replaces the body with this plain text, or with `html_body` its
65    /// plain-text alternative.
66    pub body: Option<String>,
67    /// With `body`, replaces the body with a `multipart/alternative` of
68    /// `body` and this HTML. Deriving `body` from the HTML is the caller's
69    /// job; an HTML body without `body` is refused.
70    pub html_body: Option<String>,
71    /// Files to attach after the existing attachments.
72    pub attach: Vec<Attachment>,
73    /// Existing attachments to remove, one per name. A name matches the
74    /// name `gmail draft show` lists (sanitised, with `-N` added to repeats
75    /// and `attachment-N` for an unnamed part), or failing that the stored
76    /// filename when exactly one attachment has it.
77    pub remove_attachments: Vec<String>,
78}
79
80/// The edited message, plus anything the caller should warn about.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct EditedMessage {
83    /// The complete new RFC 5322 message.
84    pub raw: Vec<u8>,
85    /// Human-readable warnings, e.g. that an HTML version was dropped.
86    pub warnings: Vec<String>,
87}
88
89impl DraftEdit {
90    /// Whether the edit changes the MIME structure (the body or the
91    /// attachments) rather than only header fields.
92    fn is_structural(&self) -> bool {
93        self.body.is_some()
94            || self.html_body.is_some()
95            || !self.attach.is_empty()
96            || !self.remove_attachments.is_empty()
97    }
98
99    /// Applies the edit to `original`, a complete RFC 5322 message.
100    pub fn apply(&self, original: &[u8]) -> Result<EditedMessage> {
101        ensure!(
102            self.html_body.is_none() || self.body.is_some(),
103            "an HTML body needs a plain-text alternative"
104        );
105        if let Some(subject) = &self.subject {
106            // Same rule as `Composition::build`: a tab is legal, but CR, LF
107            // and NUL would break (or inject) a header.
108            ensure!(
109                !subject.contains(['\r', '\n', '\0']),
110                "the subject contains a line break or NUL character"
111            );
112        }
113        for attachment in &self.attach {
114            ensure!(
115                !attachment.content_type.chars().any(char::is_control),
116                "attachment {:?} has an invalid content type",
117                attachment.filename
118            );
119        }
120
121        let split = split_message(original);
122        let mut fields = parse_fields(split.headers, split.eol);
123        let mut warnings = Vec::new();
124
125        if let Some(subject) = &self.subject {
126            if let Some(warning) = reply_subject_warning(original, &fields, subject) {
127                warnings.push(warning);
128            }
129        }
130        let eol = split.eol;
131        if let Some(from) = &self.from {
132            let field = from
133                .as_ref()
134                .map(|from| encode_field("From", &to_address(from), eol));
135            match field {
136                // A new `From` goes first, where `draft create` puts it.
137                Some(field) if !fields.iter().any(|f| f.is_named("From")) => {
138                    fields.insert(0, field);
139                }
140                field => set_field(&mut fields, "From", field),
141            }
142        }
143        for (name, mailboxes) in [("To", &self.to), ("Cc", &self.cc), ("Bcc", &self.bcc)] {
144            if let Some(mailboxes) = mailboxes {
145                let field = (!mailboxes.is_empty()).then(|| {
146                    let list = Address::new_list(mailboxes.iter().map(to_address).collect());
147                    encode_field(name, &list, eol)
148                });
149                set_field(&mut fields, name, field);
150            }
151        }
152        if let Some(subject) = &self.subject {
153            let field =
154                (!subject.is_empty()).then(|| encode_field("Subject", &Text::new(subject), eol));
155            set_field(&mut fields, "Subject", field);
156        }
157
158        let raw = if self.is_structural() {
159            self.rebuild_body(original, &split, fields, &mut warnings)?
160        } else {
161            let mut raw: Vec<u8> = fields
162                .iter()
163                .flat_map(|f| f.bytes.iter().copied())
164                .collect();
165            raw.extend_from_slice(split.separator);
166            raw.extend_from_slice(split.body);
167            raw
168        };
169        Ok(EditedMessage { raw, warnings })
170    }
171
172    /// Rebuilds the top-level entity with the body and attachment edits
173    /// applied, keeping every non-`Content-*` header in `fields`.
174    fn rebuild_body(
175        &self,
176        original: &[u8],
177        split: &Split<'_>,
178        fields: Vec<Field>,
179        warnings: &mut Vec<String>,
180    ) -> Result<Vec<u8>> {
181        let eol = split.eol;
182        let (content_fields, mut message_fields): (Vec<Field>, Vec<Field>) =
183            fields.into_iter().partition(Field::is_content);
184
185        let TopLevel {
186            preamble,
187            body,
188            others,
189            epilogue,
190        } = top_level_parts(original, split, &content_fields)?;
191        let mut others = name_parts(others);
192
193        let body = match (&self.body, body) {
194            (Some(text), old) => {
195                let text_part = MimePart::new("text/plain", BodyPart::Text(text.as_str().into()));
196                let new_body = match &self.html_body {
197                    None => {
198                        let info = old.as_deref().map(part_info);
199                        if let Some(info) = info.filter(PartInfo::is_rich_body) {
200                            warnings.push(format!(
201                                "the draft's {} body is replaced by plain text: its HTML \
202                                 version (and any inline images) is dropped",
203                                info.mime_type
204                            ));
205                        }
206                        text_part
207                    }
208                    Some(html) => {
209                        if old.as_deref().is_some_and(has_inline_images) {
210                            warnings.push(
211                                "the draft's inline images are dropped along with its old body"
212                                    .to_string(),
213                            );
214                        }
215                        warnings.extend(inline_image_warning(html));
216                        let html_part =
217                            MimePart::new("text/html", BodyPart::Text(html.as_str().into()));
218                        MimePart::new("multipart/alternative", vec![text_part, html_part])
219                    }
220                };
221                Some(Cow::Owned(write_part(new_body, eol)))
222            }
223            (None, old) => old,
224        };
225
226        for name in &self.remove_attachments {
227            let index = find_attachment(&others, name)?;
228            others.remove(index);
229        }
230
231        let mut parts: Vec<Cow<'_, [u8]>> = body.into_iter().collect();
232        parts.extend(others.into_iter().map(|part| part.bytes));
233        for attachment in &self.attach {
234            let part = MimePart::new(
235                attachment.content_type.as_str(),
236                BodyPart::Binary(attachment.data.as_slice().into()),
237            )
238            .attachment(attachment.filename.as_str());
239            parts.push(Cow::Owned(write_part(part, eol)));
240        }
241        if parts.is_empty() {
242            // Only an attachment was left, and it was removed.
243            parts.push(Cow::Owned(write_part(
244                MimePart::new("text/plain", BodyPart::Text("".into())),
245                eol,
246            )));
247        }
248
249        if !message_fields
250            .iter()
251            .any(|field| field.is_named("MIME-Version"))
252        {
253            message_fields.push(Field::new("MIME-Version", b"MIME-Version: 1.0", eol));
254        }
255        let mut raw: Vec<u8> = message_fields
256            .iter()
257            .flat_map(|f| f.bytes.iter().copied())
258            .collect();
259        if let [single] = parts.as_slice() {
260            // One part is the whole entity: its headers join the message's.
261            raw.extend_from_slice(single);
262            return Ok(raw);
263        }
264        let boundary = generate_boundary_absent_from(&parts.concat());
265        raw.extend_from_slice(
266            format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"").as_bytes(),
267        );
268        raw.extend_from_slice(eol);
269        raw.extend_from_slice(eol);
270        raw.extend_from_slice(preamble);
271        for part in &parts {
272            raw.extend_from_slice(format!("--{boundary}").as_bytes());
273            raw.extend_from_slice(eol);
274            raw.extend_from_slice(part);
275            raw.extend_from_slice(eol);
276        }
277        raw.extend_from_slice(format!("--{boundary}--").as_bytes());
278        raw.extend_from_slice(eol);
279        raw.extend_from_slice(epilogue);
280        Ok(raw)
281    }
282}
283
284/// A top-level part other than the body, with the names it can be removed
285/// by.
286struct ExistingPart<'a> {
287    bytes: Cow<'a, [u8]>,
288    /// The decoded stored filename, if any.
289    stored_name: Option<String>,
290    /// The name `gmail draft show` lists it under: sanitised, unique within
291    /// the message, `attachment-N` when unnamed.
292    shown_name: String,
293}
294
295/// Names each part the way `gmail draft show` (via
296/// [`crate::gmail::attachments::extract_attachments`]) lists it.
297fn name_parts(parts: Vec<Cow<'_, [u8]>>) -> Vec<ExistingPart<'_>> {
298    let mut seen = HashSet::new();
299    parts
300        .into_iter()
301        .enumerate()
302        .map(|(index, bytes)| {
303            let stored_name = part_info(&bytes).name;
304            let shown_name = dedupe_filename(
305                &mut seen,
306                attachment_filename(stored_name.as_deref().unwrap_or(""), &index.to_string()),
307            );
308            ExistingPart {
309                bytes,
310                stored_name,
311                shown_name,
312            }
313        })
314        .collect()
315}
316
317/// The index of the one part `name` removes: the part `draft show` lists
318/// under `name`, else the only part stored under that filename.
319fn find_attachment(parts: &[ExistingPart<'_>], name: &str) -> Result<usize> {
320    if let Some(index) = parts.iter().position(|part| part.shown_name == name) {
321        return Ok(index);
322    }
323    let stored: Vec<usize> = (0..parts.len())
324        .filter(|&i| parts[i].stored_name.as_deref() == Some(name))
325        .collect();
326    let quoted = |indexes: &mut dyn Iterator<Item = usize>| -> String {
327        indexes
328            .map(|i| format!("{:?}", parts[i].shown_name))
329            .collect::<Vec<_>>()
330            .join(", ")
331    };
332    match stored.as_slice() {
333        [index] => Ok(*index),
334        [] if parts.is_empty() => {
335            bail!("the draft has no attachment named {name:?}: it has no attachments")
336        }
337        [] => bail!(
338            "the draft has no attachment named {name:?}; its attachments are: {}",
339            quoted(&mut (0..parts.len()))
340        ),
341        _ => bail!(
342            "{} attachments are named {name:?}; remove them by the names `gmail draft show` \
343             lists instead: {}",
344            stored.len(),
345            quoted(&mut stored.iter().copied())
346        ),
347    }
348}
349
350/// Warns when a subject edit may move a reply draft out of its thread:
351/// Gmail files a reply by its subject too, so a draft with `In-Reply-To`
352/// whose new subject no longer matches the old one may start a new thread.
353fn reply_subject_warning(original: &[u8], fields: &[Field], subject: &str) -> Option<String> {
354    if !fields.iter().any(|field| field.is_named("In-Reply-To")) {
355        return None;
356    }
357    let old = MessageParser::default()
358        .parse_headers(original)
359        .and_then(|message| message.subject().map(str::to_string))
360        .unwrap_or_default();
361    let reply = ReplyContext {
362        subject: old.clone(),
363        ..ReplyContext::default()
364    };
365    (!subject_matches_reply(subject, &reply)).then(|| {
366        format!(
367            "the new subject differs from the reply's {old:?}; Gmail may move this draft out of \
368             its thread"
369        )
370    })
371}
372
373fn to_address(mailbox: &Mailbox) -> Address<'_> {
374    Address::new_address(mailbox.name.as_deref(), mailbox.email.as_str())
375}
376
377/// `Name: <encoded value>` plus a line ending, in `eol`'s style.
378fn encode_field(name: &str, value: &impl Header, eol: &[u8]) -> Field {
379    let mut bytes = format!("{name}: ").into_bytes();
380    value.write_header(&mut bytes, name.len() + 2);
381    // The encoder ends the field (and any fold) with CRLF.
382    Field {
383        name: name.to_string(),
384        bytes: with_eol(bytes, eol),
385    }
386}
387
388/// Replaces the first field called `name` with `field` and drops any other
389/// field of that name. With no existing field, `field` is appended. `None`
390/// removes the header entirely.
391fn set_field(fields: &mut Vec<Field>, name: &str, field: Option<Field>) {
392    let mut replacement = field;
393    let mut found = false;
394    fields.retain_mut(|existing| {
395        if !existing.is_named(name) {
396            return true;
397        }
398        found = true;
399        match replacement.take() {
400            Some(new) => {
401                *existing = new;
402                true
403            }
404            None => false,
405        }
406    });
407    if !found {
408        fields.extend(replacement);
409    }
410}
411
412/// A message split at the blank line ending its header section.
413struct Split<'a> {
414    /// The header section, each field ending in a line ending.
415    headers: &'a [u8],
416    /// The blank line between headers and body; empty when there is none.
417    separator: &'a [u8],
418    /// Everything after the separator.
419    body: &'a [u8],
420    /// The message's line ending: CRLF unless its first line ends in a bare
421    /// LF.
422    eol: &'static [u8],
423}
424
425fn split_message(raw: &[u8]) -> Split<'_> {
426    let eol: &'static [u8] = match raw.iter().position(|&b| b == b'\n') {
427        Some(nl) if nl == 0 || raw[nl - 1] != b'\r' => b"\n",
428        _ => b"\r\n",
429    };
430    let mut pos = 0;
431    while pos < raw.len() {
432        let line_end = raw[pos..]
433            .iter()
434            .position(|&b| b == b'\n')
435            .map_or(raw.len(), |nl| pos + nl + 1);
436        let line = &raw[pos..line_end];
437        if line == b"\n" || line == b"\r\n" {
438            return Split {
439                headers: &raw[..pos],
440                separator: line,
441                body: &raw[line_end..],
442                eol,
443            };
444        }
445        pos = line_end;
446    }
447    Split {
448        headers: raw,
449        separator: b"",
450        body: b"",
451        eol,
452    }
453}
454
455/// One header field: its name and its complete bytes, folded continuation
456/// lines and final line ending included.
457#[derive(Debug, Clone)]
458struct Field {
459    name: String,
460    bytes: Vec<u8>,
461}
462
463impl Field {
464    fn new(name: &str, line: &[u8], eol: &[u8]) -> Self {
465        let mut bytes = line.to_vec();
466        bytes.extend_from_slice(eol);
467        Self {
468            name: name.to_string(),
469            bytes,
470        }
471    }
472
473    fn is_named(&self, name: &str) -> bool {
474        self.name.eq_ignore_ascii_case(name)
475    }
476
477    /// Whether this header describes the top-level MIME entity rather than
478    /// the message (RFC 2045 §9: every `Content-*` field).
479    fn is_content(&self) -> bool {
480        self.name
481            .get(..8)
482            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("content-"))
483    }
484}
485
486/// Splits a header section into fields. A line starting with a space or tab
487/// continues the field before it. A final field without a line ending gets
488/// one, so another field can follow it.
489fn parse_fields(headers: &[u8], eol: &[u8]) -> Vec<Field> {
490    let mut fields: Vec<Field> = Vec::new();
491    for line in headers.split_inclusive(|&b| b == b'\n') {
492        let continues = matches!(line.first(), Some(b' ' | b'\t'));
493        match fields.last_mut() {
494            Some(field) if continues => field.bytes.extend_from_slice(line),
495            _ => {
496                let name = line
497                    .iter()
498                    .position(|&b| b == b':')
499                    .map(|colon| String::from_utf8_lossy(&line[..colon]).trim().to_string())
500                    .unwrap_or_default();
501                fields.push(Field {
502                    name,
503                    bytes: line.to_vec(),
504                });
505            }
506        }
507    }
508    if let Some(last) = fields.last_mut().filter(|f| !f.bytes.ends_with(b"\n")) {
509        last.bytes.extend_from_slice(eol);
510    }
511    fields
512}
513
514/// The top-level entity, taken apart for a structural edit.
515struct TopLevel<'a> {
516    /// A `multipart/mixed` message's preamble, kept as it was.
517    preamble: &'a [u8],
518    /// "The body": the first non-attachment part, or a non-`mixed` message
519    /// whole. Each part is its own headers, a blank line and its body.
520    body: Option<Cow<'a, [u8]>>,
521    /// Every other part, byte for byte.
522    others: Vec<Cow<'a, [u8]>>,
523    /// Anything after a `multipart/mixed` message's close delimiter line,
524    /// kept as it was.
525    epilogue: &'a [u8],
526}
527
528fn top_level_parts<'a>(
529    original: &'a [u8],
530    split: &Split<'a>,
531    content_fields: &[Field],
532) -> Result<TopLevel<'a>> {
533    let root = MessageParser::default().parse_headers(original);
534    let boundary = root.as_ref().and_then(|message| {
535        let content_type = message.content_type()?;
536        let is_mixed = content_type.ctype().eq_ignore_ascii_case("multipart")
537            && content_type
538                .subtype()
539                .is_some_and(|subtype| subtype.eq_ignore_ascii_case("mixed"));
540        is_mixed
541            .then(|| content_type.attribute("boundary").map(str::to_string))
542            .flatten()
543    });
544
545    let Some(boundary) = boundary else {
546        // Not `multipart/mixed`: the whole entity is the body.
547        let mut entity: Vec<u8> = content_fields
548            .iter()
549            .flat_map(|f| f.bytes.iter().copied())
550            .collect();
551        entity.extend_from_slice(split.eol);
552        entity.extend_from_slice(split.body);
553        let entity = Cow::Owned(entity);
554        // A draft that is nothing but an attachment has no body.
555        let (body, others) = if part_info(&entity).is_attachment {
556            (None, vec![entity])
557        } else {
558            (Some(entity), Vec::new())
559        };
560        return Ok(TopLevel {
561            preamble: b"",
562            body,
563            others,
564            epilogue: b"",
565        });
566    };
567
568    let Some(Multipart {
569        preamble,
570        parts,
571        epilogue,
572    }) = split_multipart(split.body, &boundary)
573    else {
574        bail!(
575            "the draft is multipart/mixed but its boundary {boundary:?} never occurs in it, so \
576             its parts can't be edited; use `--raw` instead"
577        );
578    };
579    let mut parts = parts.into_iter().map(Cow::Borrowed);
580    let mut body = None;
581    let mut others = Vec::new();
582    match parts.next() {
583        Some(first) if part_info(&first).is_attachment => others.push(first),
584        Some(first) => body = Some(first),
585        None => {}
586    }
587    others.extend(parts);
588    Ok(TopLevel {
589        preamble,
590        body,
591        others,
592        epilogue,
593    })
594}
595
596/// A multipart body split on its delimiter lines.
597#[derive(Debug, PartialEq, Eq)]
598struct Multipart<'a> {
599    /// Everything before the first delimiter line.
600    preamble: &'a [u8],
601    /// Each part, without the line ending that precedes the next delimiter.
602    parts: Vec<&'a [u8]>,
603    /// Everything after the close delimiter line.
604    epilogue: &'a [u8],
605}
606
607/// Splits a multipart body on `--boundary` delimiter lines (RFC 2046
608/// §5.1.1). A part excludes the line ending before the next delimiter, which
609/// belongs to the delimiter. A missing close delimiter is tolerated: the
610/// last part runs to the end. `None` when the boundary never occurs.
611fn split_multipart<'a>(body: &'a [u8], boundary: &str) -> Option<Multipart<'a>> {
612    let delimiter = format!("--{boundary}");
613    let delimiter = delimiter.as_bytes();
614    let mut preamble = None;
615    let mut parts = Vec::new();
616    let mut part_start = 0;
617    let mut pos = 0;
618    while pos < body.len() {
619        let line_end = body[pos..]
620            .iter()
621            .position(|&b| b == b'\n')
622            .map_or(body.len(), |nl| pos + nl + 1);
623        let line = body[pos..line_end].trim_ascii_end();
624        let is_close = line.strip_prefix(delimiter) == Some(b"--");
625        if line == delimiter || is_close {
626            if preamble.is_none() {
627                preamble = Some(&body[..pos]);
628            } else {
629                let end = if body[..pos].ends_with(b"\r\n") {
630                    pos - 2
631                } else if body[..pos].ends_with(b"\n") {
632                    pos - 1
633                } else {
634                    pos // omni-dev: coverage ignore-line reason="unreachable: a delimiter line's start is always the position right after some previous line's '\n' (either the outer while loop's line_end, or 0 for the very first line), so it always ends with '\n'; this arm exists solely for exhaustiveness over the byte-slice check"
635                };
636                parts.push(&body[part_start..end.max(part_start)]);
637            }
638            if is_close {
639                return Some(Multipart {
640                    preamble: preamble.unwrap_or_default(),
641                    parts,
642                    epilogue: &body[line_end..],
643                });
644            }
645            part_start = line_end;
646        }
647        pos = line_end;
648    }
649    let preamble = preamble?;
650    if part_start < body.len() {
651        parts.push(&body[part_start..]);
652    }
653    Some(Multipart {
654        preamble,
655        parts,
656        epilogue: b"",
657    })
658}
659
660/// What [`top_level_parts`] and the attachment edits need to know about a
661/// part.
662struct PartInfo {
663    /// `type/subtype`, lower-cased; `text/plain` when unstated.
664    mime_type: String,
665    /// `Content-Disposition: attachment`, a filename, or a part that is
666    /// neither text nor multipart.
667    is_attachment: bool,
668    /// The decoded filename (RFC 2231 / RFC 2047), if any.
669    name: Option<String>,
670}
671
672impl PartInfo {
673    /// A body that isn't plain text: HTML, `multipart/alternative`, or
674    /// `multipart/related` with inline images.
675    fn is_rich_body(&self) -> bool {
676        self.mime_type != "text/plain"
677    }
678}
679
680fn part_info(part: &[u8]) -> PartInfo {
681    let parsed = MessageParser::default().parse_headers(part);
682    let content_type = parsed.as_ref().and_then(|message| message.content_type());
683    let ctype = content_type.map_or_else(|| "text".to_string(), |ct| ct.ctype().to_lowercase());
684    let subtype = content_type
685        .and_then(|ct| ct.subtype())
686        .map_or_else(|| "plain".to_string(), str::to_lowercase);
687    let disposition_attachment = parsed
688        .as_ref()
689        .and_then(|message| message.content_disposition())
690        .is_some_and(|disposition| disposition.ctype().eq_ignore_ascii_case("attachment"));
691    let name = parsed
692        .as_ref()
693        .and_then(|message| message.attachment_name())
694        .map(str::to_string);
695    let is_attachment =
696        disposition_attachment || name.is_some() || !matches!(ctype.as_str(), "text" | "multipart");
697    PartInfo {
698        mime_type: format!("{ctype}/{subtype}"),
699        is_attachment,
700        name,
701    }
702}
703
704/// Whether a body part holds inline images: a `multipart/related` part, or
705/// any part with a `Content-ID` for the HTML to refer to by `cid:`.
706fn has_inline_images(part: &[u8]) -> bool {
707    MessageParser::default().parse(part).is_some_and(|message| {
708        message.parts.iter().any(|part| {
709            part.content_id().is_some()
710                || part.content_type().is_some_and(|ct| {
711                    ct.ctype().eq_ignore_ascii_case("multipart")
712                        && ct
713                            .subtype()
714                            .is_some_and(|subtype| subtype.eq_ignore_ascii_case("related"))
715                })
716        })
717    })
718}
719
720/// Serialises a `mail-builder` part, converting its CRLFs to `eol`.
721fn write_part(part: MimePart<'_>, eol: &[u8]) -> Vec<u8> {
722    let mut bytes = Vec::new();
723    part.write_part(&mut bytes);
724    with_eol(bytes, eol)
725}
726
727/// `mail-builder` output (CRLF line endings) converted to `eol`'s style.
728fn with_eol(bytes: Vec<u8>, eol: &[u8]) -> Vec<u8> {
729    if eol == b"\n" {
730        let mut lf = Vec::with_capacity(bytes.len());
731        let mut iter = bytes.iter().peekable();
732        while let Some(&b) = iter.next() {
733            if b == b'\r' && iter.peek() == Some(&&b'\n') {
734                continue;
735            }
736            lf.push(b);
737        }
738        lf
739    } else {
740        bytes
741    }
742}
743
744#[cfg(test)]
745#[allow(clippy::unwrap_used, clippy::expect_used)]
746mod tests {
747    use super::*;
748    use crate::gmail::compose::Composition;
749    use mail_parser::Message;
750
751    fn mailbox(input: &str) -> Mailbox {
752        Mailbox::parse(input).unwrap()
753    }
754
755    fn parse(raw: &[u8]) -> Message<'_> {
756        MessageParser::default().parse(raw).unwrap()
757    }
758
759    /// The body section: everything after the header section's blank line.
760    fn body_of(raw: &[u8]) -> &[u8] {
761        split_message(raw).body
762    }
763
764    /// A reply draft as the Gmail web UI stores one: `multipart/mixed`
765    /// around a `multipart/alternative` body, a PDF and a file with a
766    /// non-ASCII (RFC 2231) name, plus headers no builder here models.
767    const GMAIL_UI_DRAFT: &str = "MIME-Version: 1.0\r\n\
768Date: Mon, 21 Sep 2026 10:00:00 +1000\r\n\
769References: <a@example.com> <b@example.com>\r\n\
770In-Reply-To: <b@example.com>\r\n\
771Message-ID: <draft-1@mail.gmail.com>\r\n\
772Subject: Re: Quarterly report\r\n\
773From: Me <me@example.com>\r\n\
774To: Alice <alice@example.com>\r\n\
775Cc: carol@example.com\r\n\
776X-Custom-Header: keep me\r\n\
777Content-Type: multipart/mixed; boundary=\"000000000000mixed\"\r\n\
778\r\n\
779--000000000000mixed\r\n\
780Content-Type: multipart/alternative; boundary=\"000000000000alt\"\r\n\
781\r\n\
782--000000000000alt\r\n\
783Content-Type: text/plain; charset=\"UTF-8\"\r\n\
784\r\n\
785Figures attached.\r\n\
786\r\n\
787--000000000000alt\r\n\
788Content-Type: text/html; charset=\"UTF-8\"\r\n\
789\r\n\
790<div dir=\"ltr\">Figures <b>attached</b>.</div>\r\n\
791\r\n\
792--000000000000alt--\r\n\
793--000000000000mixed\r\n\
794Content-Type: application/pdf; name=\"q3.pdf\"\r\n\
795Content-Disposition: attachment; filename=\"q3.pdf\"\r\n\
796Content-Transfer-Encoding: base64\r\n\
797X-Attachment-Id: f_abc\r\n\
798\r\n\
799JVBERi0xLjQK\r\n\
800--000000000000mixed\r\n\
801Content-Type: text/plain; charset=\"UTF-8\"; name=\"=?UTF-8?Q?Jahresabschlu=C3=9F=2Etxt?=\"\r\n\
802Content-Disposition: attachment; filename*=UTF-8''Jahresabschlu%C3%9F.txt\r\n\
803Content-Transfer-Encoding: base64\r\n\
804\r\n\
805SGVsbG8=\r\n\
806--000000000000mixed--\r\n";
807
808    const PDF_PART: &str = "Content-Type: application/pdf; name=\"q3.pdf\"\r\n\
809Content-Disposition: attachment; filename=\"q3.pdf\"\r\n\
810Content-Transfer-Encoding: base64\r\n\
811X-Attachment-Id: f_abc\r\n\
812\r\n\
813JVBERi0xLjQK";
814
815    const ALT_PART: &str = "Content-Type: multipart/alternative; boundary=\"000000000000alt\"\r\n\
816\r\n\
817--000000000000alt\r\n\
818Content-Type: text/plain; charset=\"UTF-8\"\r\n\
819\r\n\
820Figures attached.\r\n\
821\r\n\
822--000000000000alt\r\n\
823Content-Type: text/html; charset=\"UTF-8\"\r\n\
824\r\n\
825<div dir=\"ltr\">Figures <b>attached</b>.</div>\r\n\
826\r\n\
827--000000000000alt--";
828
829    /// Every header line other than the named ones, in order.
830    fn other_fields(raw: &[u8], except: &[&str]) -> Vec<String> {
831        let split = split_message(raw);
832        parse_fields(split.headers, split.eol)
833            .into_iter()
834            .filter(|f| !except.iter().any(|name| f.is_named(name)))
835            .map(|f| String::from_utf8(f.bytes).unwrap())
836            .collect()
837    }
838
839    fn contains(haystack: &[u8], needle: &str) -> bool {
840        haystack
841            .windows(needle.len())
842            .any(|window| window == needle.as_bytes())
843    }
844
845    fn attachment(name: &str, content_type: &str, data: &[u8]) -> Attachment {
846        Attachment {
847            filename: name.to_string(),
848            content_type: content_type.to_string(),
849            data: data.to_vec(),
850        }
851    }
852
853    // ── header edits ─────────────────────────────────────────────────
854
855    #[test]
856    fn a_subject_edit_changes_only_the_subject_field() {
857        let original = GMAIL_UI_DRAFT.as_bytes();
858        let edited = DraftEdit {
859            subject: Some("RE: quarterly report".to_string()),
860            ..DraftEdit::default()
861        }
862        .apply(original)
863        .unwrap();
864
865        assert_eq!(body_of(&edited.raw), body_of(original));
866        assert_eq!(
867            other_fields(&edited.raw, &["Subject"]),
868            other_fields(original, &["Subject"])
869        );
870        let parsed = parse(&edited.raw);
871        assert_eq!(parsed.subject(), Some("RE: quarterly report"));
872        // The reply headers survive, and a subject differing only in case
873        // still matches the thread's.
874        assert_eq!(parsed.in_reply_to().as_text(), Some("b@example.com"));
875        assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
876    }
877
878    #[test]
879    fn a_from_edit_replaces_only_the_from_field_in_place() {
880        let original = GMAIL_UI_DRAFT.as_bytes();
881        let edited = DraftEdit {
882            from: Some(Some(mailbox("Zoë <sales@example.org>"))),
883            ..DraftEdit::default()
884        }
885        .apply(original)
886        .unwrap();
887
888        assert_eq!(body_of(&edited.raw), body_of(original));
889        assert_eq!(
890            other_fields(&edited.raw, &["From"]),
891            other_fields(original, &["From"])
892        );
893        let fields = other_fields(&edited.raw, &[]);
894        let from_index = other_fields(original, &[])
895            .iter()
896            .position(|f| f.starts_with("From:"))
897            .unwrap();
898        assert!(fields[from_index].starts_with("From: "));
899        assert!(fields[from_index].is_ascii());
900        let parsed = parse(&edited.raw);
901        let from = parsed.from().unwrap().first().unwrap();
902        assert_eq!(from.name(), Some("Zoë"));
903        assert_eq!(from.address(), Some("sales@example.org"));
904        assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
905    }
906
907    #[test]
908    fn a_from_edit_adds_the_header_to_a_draft_without_one() {
909        let original = b"To: a@example.com\r\nSubject: Hi\r\n\r\nbody\r\n";
910        let edited = DraftEdit {
911            from: Some(Some(mailbox("sales@example.org"))),
912            ..DraftEdit::default()
913        }
914        .apply(original)
915        .unwrap();
916        assert_eq!(
917            edited.raw,
918            b"From: <sales@example.org>\r\nTo: a@example.com\r\nSubject: Hi\r\n\r\nbody\r\n"
919        );
920    }
921
922    #[test]
923    fn a_from_edit_of_none_removes_the_header() {
924        let original = b"From: Alias <a@example.org>\r\nTo: b@example.com\r\n\r\nbody\r\n";
925        let edited = DraftEdit {
926            from: Some(None),
927            ..DraftEdit::default()
928        }
929        .apply(original)
930        .unwrap();
931        assert_eq!(edited.raw, b"To: b@example.com\r\n\r\nbody\r\n");
932    }
933
934    #[test]
935    fn a_recipient_edit_replaces_the_whole_list_in_place() {
936        let original = GMAIL_UI_DRAFT.as_bytes();
937        let edited = DraftEdit {
938            cc: Some(vec![
939                mailbox("Zoë <zoe@example.com>"),
940                mailbox("dan@example.com"),
941            ]),
942            ..DraftEdit::default()
943        }
944        .apply(original)
945        .unwrap();
946
947        assert_eq!(body_of(&edited.raw), body_of(original));
948        let fields = other_fields(&edited.raw, &[]);
949        let original_fields = other_fields(original, &[]);
950        // Same position, same neighbours.
951        assert_eq!(fields.len(), original_fields.len());
952        let cc_index = original_fields
953            .iter()
954            .position(|f| f.starts_with("Cc:"))
955            .unwrap();
956        assert!(fields[cc_index].starts_with("Cc: "));
957        assert!(fields[cc_index].is_ascii());
958
959        let parsed = parse(&edited.raw);
960        let cc: Vec<(Option<&str>, Option<&str>)> = parsed
961            .cc()
962            .unwrap()
963            .iter()
964            .map(|a| (a.name(), a.address()))
965            .collect();
966        assert_eq!(
967            cc,
968            [
969                (Some("Zoë"), Some("zoe@example.com")),
970                (None, Some("dan@example.com"))
971            ]
972        );
973    }
974
975    #[test]
976    fn a_missing_header_is_added_and_an_empty_list_removes_one() {
977        let original = GMAIL_UI_DRAFT.as_bytes();
978        let edited = DraftEdit {
979            bcc: Some(vec![mailbox("audit@example.com")]),
980            cc: Some(Vec::new()),
981            ..DraftEdit::default()
982        }
983        .apply(original)
984        .unwrap();
985        let parsed = parse(&edited.raw);
986        assert_eq!(
987            parsed.bcc().unwrap().first().unwrap().address(),
988            Some("audit@example.com")
989        );
990        assert!(parsed.cc().is_none());
991        assert_eq!(body_of(&edited.raw), body_of(original));
992    }
993
994    #[test]
995    fn duplicate_fields_collapse_into_the_replacement() {
996        let original = b"To: a@example.com\r\nSubject: One\r\nSubject: Two\r\n\r\nbody\r\n";
997        let edited = DraftEdit {
998            subject: Some("Three".to_string()),
999            ..DraftEdit::default()
1000        }
1001        .apply(original)
1002        .unwrap();
1003        assert_eq!(
1004            edited.raw,
1005            b"To: a@example.com\r\nSubject: Three\r\n\r\nbody\r\n"
1006        );
1007    }
1008
1009    #[test]
1010    fn folded_fields_are_replaced_whole_and_others_kept_folded() {
1011        let original = b"To: a@example.com,\r\n b@example.com\r\nX-Long: one\r\n\ttwo\r\n\r\nbody";
1012        let edited = DraftEdit {
1013            to: Some(vec![mailbox("c@example.com")]),
1014            ..DraftEdit::default()
1015        }
1016        .apply(original)
1017        .unwrap();
1018        assert_eq!(
1019            edited.raw,
1020            b"To: <c@example.com>\r\nX-Long: one\r\n\ttwo\r\n\r\nbody"
1021        );
1022    }
1023
1024    #[test]
1025    fn non_ascii_subjects_are_encoded_words() {
1026        let edited = DraftEdit {
1027            subject: Some("Grüße — 你好".to_string()),
1028            ..DraftEdit::default()
1029        }
1030        .apply(b"Subject: x\r\n\r\nbody")
1031        .unwrap();
1032        assert!(edited.raw.is_ascii());
1033        assert_eq!(parse(&edited.raw).subject(), Some("Grüße — 你好"));
1034    }
1035
1036    #[test]
1037    fn a_subject_with_a_line_break_is_rejected() {
1038        let err = DraftEdit {
1039            subject: Some("Hi\r\nBcc: eve@example.com".to_string()),
1040            ..DraftEdit::default()
1041        }
1042        .apply(b"Subject: x\r\n\r\nbody")
1043        .unwrap_err();
1044        assert!(err.to_string().contains("subject"), "{err}");
1045    }
1046
1047    #[test]
1048    fn changing_a_reply_subject_warns_about_the_thread() {
1049        let edited = DraftEdit {
1050            subject: Some("Something else".to_string()),
1051            ..DraftEdit::default()
1052        }
1053        .apply(GMAIL_UI_DRAFT.as_bytes())
1054        .unwrap();
1055        assert_eq!(edited.warnings.len(), 1);
1056        assert!(
1057            edited.warnings[0].contains("out of its thread"),
1058            "{:?}",
1059            edited.warnings
1060        );
1061
1062        // A draft that isn't a reply has no thread to fall out of.
1063        let edited = DraftEdit {
1064            subject: Some("Something else".to_string()),
1065            ..DraftEdit::default()
1066        }
1067        .apply(b"Subject: x\r\n\r\nbody")
1068        .unwrap();
1069        assert!(edited.warnings.is_empty());
1070    }
1071
1072    #[test]
1073    fn lf_only_messages_keep_lf_line_endings() {
1074        let original = b"To: a@example.com\nSubject: Old\n\nbody\n";
1075        let edited = DraftEdit {
1076            subject: Some("New".to_string()),
1077            ..DraftEdit::default()
1078        }
1079        .apply(original)
1080        .unwrap();
1081        assert_eq!(edited.raw, b"To: a@example.com\nSubject: New\n\nbody\n");
1082    }
1083
1084    #[test]
1085    fn a_message_without_a_body_gains_the_field() {
1086        let edited = DraftEdit {
1087            subject: Some("New".to_string()),
1088            ..DraftEdit::default()
1089        }
1090        .apply(b"To: a@example.com")
1091        .unwrap();
1092        assert_eq!(edited.raw, b"To: a@example.com\r\nSubject: New\r\n");
1093    }
1094
1095    // ── attachment edits ─────────────────────────────────────────────
1096
1097    #[test]
1098    fn removing_an_attachment_keeps_every_other_part_byte_for_byte() {
1099        let original = GMAIL_UI_DRAFT.as_bytes();
1100        let edited = DraftEdit {
1101            remove_attachments: vec!["Jahresabschluß.txt".to_string()],
1102            ..DraftEdit::default()
1103        }
1104        .apply(original)
1105        .unwrap();
1106
1107        assert!(contains(&edited.raw, ALT_PART));
1108        assert!(contains(&edited.raw, PDF_PART));
1109        assert!(!contains(&edited.raw, "Jahresabschlu"));
1110        assert_eq!(
1111            other_fields(&edited.raw, &["Content-Type"]),
1112            other_fields(original, &["Content-Type"])
1113        );
1114        let parsed = parse(&edited.raw);
1115        assert_eq!(parsed.attachment_count(), 1);
1116        assert_eq!(
1117            parsed.attachment(0).unwrap().attachment_name(),
1118            Some("q3.pdf")
1119        );
1120        assert_eq!(
1121            parsed.body_text(0).as_deref(),
1122            Some("Figures attached.\r\n")
1123        );
1124        assert!(parsed.body_html(0).unwrap().contains("<b>attached</b>"));
1125        assert!(edited.warnings.is_empty());
1126    }
1127
1128    #[test]
1129    fn adding_an_attachment_appends_a_part_and_keeps_the_rest() {
1130        let original = GMAIL_UI_DRAFT.as_bytes();
1131        let binary: Vec<u8> = (0..=255u8).collect();
1132        let edited = DraftEdit {
1133            attach: vec![attachment("data.bin", "application/octet-stream", &binary)],
1134            ..DraftEdit::default()
1135        }
1136        .apply(original)
1137        .unwrap();
1138
1139        assert!(contains(&edited.raw, ALT_PART));
1140        assert!(contains(&edited.raw, PDF_PART));
1141        let parsed = parse(&edited.raw);
1142        assert_eq!(parsed.attachment_count(), 3);
1143        let added = parsed.attachment(2).unwrap();
1144        assert_eq!(added.attachment_name(), Some("data.bin"));
1145        assert_eq!(added.contents(), binary.as_slice());
1146        assert_eq!(
1147            parsed.attachment(1).unwrap().attachment_name(),
1148            Some("Jahresabschluß.txt")
1149        );
1150    }
1151
1152    #[test]
1153    fn attaching_to_a_single_part_draft_wraps_it_in_multipart_mixed() {
1154        let original = b"To: a@example.com\r\nSubject: Hi\r\nMIME-Version: 1.0\r\n\
1155Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello.\r\n";
1156        let edited = DraftEdit {
1157            attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
1158            ..DraftEdit::default()
1159        }
1160        .apply(original)
1161        .unwrap();
1162
1163        // The old entity headers moved into the first part, unchanged.
1164        assert!(contains(
1165            &edited.raw,
1166            "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello.\r\n"
1167        ));
1168        let parsed = parse(&edited.raw);
1169        assert_eq!(parsed.body_text(0).as_deref(), Some("Hello.\r\n"));
1170        assert_eq!(parsed.attachment_count(), 1);
1171        assert_eq!(parsed.attachment(0).unwrap().contents(), b"%PDF");
1172        assert_eq!(
1173            other_fields(&edited.raw, &["Content-Type"]),
1174            [
1175                "To: a@example.com\r\n",
1176                "Subject: Hi\r\n",
1177                "MIME-Version: 1.0\r\n"
1178            ]
1179        );
1180    }
1181
1182    #[test]
1183    fn removing_the_last_attachment_unwraps_the_body() {
1184        let original = Composition {
1185            to: vec![mailbox("a@example.com")],
1186            subject: "Hi".to_string(),
1187            body: "Hello.\n".to_string(),
1188            attachments: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
1189            ..Composition::default()
1190        }
1191        .build()
1192        .unwrap();
1193        let edited = DraftEdit {
1194            remove_attachments: vec!["a.pdf".to_string()],
1195            ..DraftEdit::default()
1196        }
1197        .apply(&original)
1198        .unwrap();
1199
1200        assert!(!contains(&edited.raw, "multipart"));
1201        let parsed = parse(&edited.raw);
1202        assert_eq!(parsed.attachment_count(), 0);
1203        assert_eq!(parsed.body_text(0).as_deref(), Some("Hello.\r\n"));
1204        assert_eq!(parsed.subject(), Some("Hi"));
1205    }
1206
1207    #[test]
1208    fn removing_an_unknown_attachment_lists_the_real_ones() {
1209        let err = DraftEdit {
1210            remove_attachments: vec!["nope.pdf".to_string()],
1211            ..DraftEdit::default()
1212        }
1213        .apply(GMAIL_UI_DRAFT.as_bytes())
1214        .unwrap_err()
1215        .to_string();
1216        assert!(err.contains("\"nope.pdf\""), "{err}");
1217        assert!(err.contains("\"q3.pdf\", \"Jahresabschluß.txt\""), "{err}");
1218
1219        let err = DraftEdit {
1220            remove_attachments: vec!["nope.pdf".to_string()],
1221            ..DraftEdit::default()
1222        }
1223        .apply(b"Subject: x\r\n\r\nbody")
1224        .unwrap_err()
1225        .to_string();
1226        assert!(err.contains("it has no attachments"), "{err}");
1227    }
1228
1229    #[test]
1230    fn an_attachment_content_type_with_a_line_break_is_rejected() {
1231        let err = DraftEdit {
1232            attach: vec![attachment("a.txt", "text/plain\r\nX-Evil: 1", b"")],
1233            ..DraftEdit::default()
1234        }
1235        .apply(b"Subject: x\r\n\r\nbody")
1236        .unwrap_err();
1237        assert!(err.to_string().contains("content type"), "{err}");
1238    }
1239
1240    // ── body edits ───────────────────────────────────────────────────
1241
1242    #[test]
1243    fn a_body_edit_keeps_the_attachments_and_warns_that_html_is_dropped() {
1244        let original = GMAIL_UI_DRAFT.as_bytes();
1245        let edited = DraftEdit {
1246            body: Some("Revised figures attached.\n".to_string()),
1247            ..DraftEdit::default()
1248        }
1249        .apply(original)
1250        .unwrap();
1251
1252        assert!(contains(&edited.raw, PDF_PART));
1253        assert!(!contains(&edited.raw, "text/html"));
1254        let parsed = parse(&edited.raw);
1255        assert_eq!(
1256            parsed.body_text(0).as_deref(),
1257            Some("Revised figures attached.\r\n")
1258        );
1259        assert_eq!(parsed.attachment_count(), 2);
1260        assert_eq!(parsed.in_reply_to().as_text(), Some("b@example.com"));
1261        assert_eq!(
1262            other_fields(&edited.raw, &["Content-Type"]),
1263            other_fields(original, &["Content-Type"])
1264        );
1265        assert_eq!(edited.warnings.len(), 1);
1266        assert!(
1267            edited.warnings[0].contains("multipart/alternative"),
1268            "{:?}",
1269            edited.warnings
1270        );
1271    }
1272
1273    #[test]
1274    fn a_body_edit_on_a_plain_draft_replaces_it_without_a_warning() {
1275        let original = Composition {
1276            to: vec![mailbox("a@example.com")],
1277            subject: "Hi".to_string(),
1278            body: "Old.\n".to_string(),
1279            ..Composition::default()
1280        }
1281        .build()
1282        .unwrap();
1283        let edited = DraftEdit {
1284            body: Some("Ünïcödé new body.\n".to_string()),
1285            ..DraftEdit::default()
1286        }
1287        .apply(&original)
1288        .unwrap();
1289
1290        assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
1291        let parsed = parse(&edited.raw);
1292        assert_eq!(parsed.body_text(0).as_deref(), Some("Ünïcödé new body.\n"));
1293        assert_eq!(
1294            other_fields(&edited.raw, &["Content-Type", "Content-Transfer-Encoding"]),
1295            other_fields(&original, &["Content-Type", "Content-Transfer-Encoding"])
1296        );
1297    }
1298
1299    fn html_edit(text: &str, html: &str) -> DraftEdit {
1300        DraftEdit {
1301            body: Some(text.to_string()),
1302            html_body: Some(html.to_string()),
1303            ..DraftEdit::default()
1304        }
1305    }
1306
1307    #[test]
1308    fn an_html_edit_on_a_plain_draft_makes_it_multipart_alternative() {
1309        let original = Composition {
1310            to: vec![mailbox("a@example.com")],
1311            subject: "Hi".to_string(),
1312            body: "Old.\n".to_string(),
1313            ..Composition::default()
1314        }
1315        .build()
1316        .unwrap();
1317        let edited = html_edit("New.", "<p>New.</p>").apply(&original).unwrap();
1318
1319        assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
1320        let raw = String::from_utf8(edited.raw.clone()).unwrap();
1321        let top = split_message(&edited.raw).headers;
1322        assert!(
1323            contains(top, "Content-Type: multipart/alternative"),
1324            "{raw}"
1325        );
1326        assert!(raw.find("text/plain").unwrap() < raw.find("text/html").unwrap());
1327        let parsed = parse(&edited.raw);
1328        assert_eq!(parsed.body_text(0).as_deref(), Some("New."));
1329        assert_eq!(parsed.body_html(0).as_deref(), Some("<p>New.</p>"));
1330        assert_eq!(
1331            other_fields(&edited.raw, &["Content-Type", "Content-Transfer-Encoding"]),
1332            other_fields(&original, &["Content-Type", "Content-Transfer-Encoding"])
1333        );
1334    }
1335
1336    #[test]
1337    fn an_html_edit_replaces_a_gmail_alternative_and_keeps_the_attachments() {
1338        let original = GMAIL_UI_DRAFT.as_bytes();
1339        let edited = html_edit("Revised.", "<p>Revised <b>figures</b>.</p>")
1340            .apply(original)
1341            .unwrap();
1342
1343        assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
1344        assert!(!contains(&edited.raw, ALT_PART));
1345        assert!(!contains(&edited.raw, "Figures attached."));
1346        assert!(contains(&edited.raw, PDF_PART));
1347        assert!(contains(
1348            &edited.raw,
1349            "Content-Disposition: attachment; filename*=UTF-8''Jahresabschlu%C3%9F.txt\r\n\
1350             Content-Transfer-Encoding: base64\r\n\r\nSGVsbG8="
1351        ));
1352        let parsed = parse(&edited.raw);
1353        assert_eq!(parsed.body_text(0).as_deref(), Some("Revised."));
1354        assert_eq!(
1355            parsed.body_html(0).as_deref(),
1356            Some("<p>Revised <b>figures</b>.</p>")
1357        );
1358        assert_eq!(parsed.attachment_count(), 2);
1359        assert_eq!(
1360            other_fields(&edited.raw, &["Content-Type"]),
1361            other_fields(original, &["Content-Type"])
1362        );
1363    }
1364
1365    /// A Gmail-style body with an inline image: `multipart/related` around
1366    /// the alternative and a `Content-ID` part.
1367    const INLINE_IMAGE_DRAFT: &str = "MIME-Version: 1.0\r\n\
1368Subject: Logo\r\n\
1369Content-Type: multipart/related; boundary=\"rel\"\r\n\
1370\r\n\
1371--rel\r\n\
1372Content-Type: multipart/alternative; boundary=\"alt\"\r\n\
1373\r\n\
1374--alt\r\n\
1375Content-Type: text/plain\r\n\
1376\r\n\
1377[image: logo]\r\n\
1378--alt\r\n\
1379Content-Type: text/html\r\n\
1380\r\n\
1381<img src=\"cid:logo@x\">\r\n\
1382--alt--\r\n\
1383--rel\r\n\
1384Content-Type: image/png; name=\"logo.png\"\r\n\
1385Content-ID: <logo@x>\r\n\
1386Content-Transfer-Encoding: base64\r\n\
1387\r\n\
1388iVBORw0KGgo=\r\n\
1389--rel--\r\n";
1390
1391    #[test]
1392    fn an_html_edit_warns_when_it_drops_inline_images() {
1393        let edited = html_edit("New.", "<p>New.</p>")
1394            .apply(INLINE_IMAGE_DRAFT.as_bytes())
1395            .unwrap();
1396        assert_eq!(edited.warnings.len(), 1, "{:?}", edited.warnings);
1397        assert!(
1398            edited.warnings[0].contains("inline images are dropped"),
1399            "{:?}",
1400            edited.warnings
1401        );
1402        assert!(!contains(&edited.raw, "iVBORw0KGgo="));
1403    }
1404
1405    #[test]
1406    fn an_html_edit_warns_about_cid_references_in_the_new_html() {
1407        let edited = html_edit("New.", "<img src=\"cid:logo@x\">")
1408            .apply(b"Subject: x\r\n\r\nOld.\r\n")
1409            .unwrap();
1410        assert_eq!(edited.warnings.len(), 1, "{:?}", edited.warnings);
1411        assert!(
1412            edited.warnings[0].contains("`cid:`"),
1413            "{:?}",
1414            edited.warnings
1415        );
1416    }
1417
1418    #[test]
1419    fn has_inline_images_looks_for_related_parts_and_content_ids() {
1420        assert!(has_inline_images(INLINE_IMAGE_DRAFT.as_bytes()));
1421        assert!(!has_inline_images(ALT_PART.as_bytes()));
1422        assert!(has_inline_images(
1423            b"Content-Type: multipart/related; boundary=\"r\"\r\n\r\n--r\r\n\
1424Content-Type: text/html\r\n\r\n<p>x</p>\r\n--r--\r\n"
1425        ));
1426    }
1427
1428    #[test]
1429    fn an_html_edit_needs_a_plain_text_body() {
1430        let err = DraftEdit {
1431            html_body: Some("<p>x</p>".to_string()),
1432            ..DraftEdit::default()
1433        }
1434        .apply(b"Subject: x\r\n\r\nOld.\r\n")
1435        .unwrap_err();
1436        assert!(err.to_string().contains("plain-text alternative"), "{err}");
1437    }
1438
1439    #[test]
1440    fn an_html_edit_on_an_lf_message_writes_lf_parts() {
1441        let original = b"Subject: x\nContent-Type: text/plain\n\nbody\n";
1442        let edited = html_edit("New.\n", "<p>New.</p>\n")
1443            .apply(original)
1444            .unwrap();
1445        assert!(!edited.raw.contains(&b'\r'));
1446        let parsed = parse(&edited.raw);
1447        assert_eq!(parsed.body_text(0).as_deref(), Some("New.\n"));
1448        assert_eq!(parsed.body_html(0).as_deref(), Some("<p>New.</p>\n"));
1449    }
1450
1451    #[test]
1452    fn an_html_edit_combines_with_attachment_and_subject_edits() {
1453        let edited = DraftEdit {
1454            subject: Some("Re: Quarterly report (v2)".to_string()),
1455            attach: vec![attachment("notes.txt", "text/plain", b"notes")],
1456            remove_attachments: vec!["q3.pdf".to_string()],
1457            ..html_edit("Revised.", "<p>Revised.</p>")
1458        }
1459        .apply(GMAIL_UI_DRAFT.as_bytes())
1460        .unwrap();
1461
1462        let parsed = parse(&edited.raw);
1463        assert_eq!(parsed.subject(), Some("Re: Quarterly report (v2)"));
1464        assert_eq!(parsed.body_html(0).as_deref(), Some("<p>Revised.</p>"));
1465        let names: Vec<_> = parsed
1466            .attachments()
1467            .map(|a| a.attachment_name().unwrap().to_string())
1468            .collect();
1469        assert_eq!(names, ["Jahresabschluß.txt", "notes.txt"]);
1470    }
1471
1472    #[test]
1473    fn a_draft_that_is_only_an_attachment_gains_a_body_before_it() {
1474        let original = b"Subject: x\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b\r\n\r\n\
1475--b\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=a.pdf\r\n\r\n%PDF\r\n--b--\r\n";
1476        let edited = DraftEdit {
1477            body: Some("Cover note.".to_string()),
1478            ..DraftEdit::default()
1479        }
1480        .apply(original)
1481        .unwrap();
1482        let parsed = parse(&edited.raw);
1483        assert_eq!(parsed.body_text(0).as_deref(), Some("Cover note."));
1484        assert_eq!(
1485            parsed.attachment(0).unwrap().attachment_name(),
1486            Some("a.pdf")
1487        );
1488        assert!(edited.warnings.is_empty());
1489    }
1490
1491    #[test]
1492    fn edits_combine_in_one_pass() {
1493        let original = GMAIL_UI_DRAFT.as_bytes();
1494        let edited = DraftEdit {
1495            from: Some(Some(mailbox("sales@example.org"))),
1496            to: Some(vec![mailbox("bob@example.com")]),
1497            body: Some("New.".to_string()),
1498            attach: vec![attachment("n.txt", "text/plain", b"note")],
1499            remove_attachments: vec!["q3.pdf".to_string()],
1500            ..DraftEdit::default()
1501        }
1502        .apply(original)
1503        .unwrap();
1504        let parsed = parse(&edited.raw);
1505        assert_eq!(
1506            parsed.from().unwrap().first().unwrap().address(),
1507            Some("sales@example.org")
1508        );
1509        assert_eq!(
1510            parsed.to().unwrap().first().unwrap().address(),
1511            Some("bob@example.com")
1512        );
1513        assert_eq!(parsed.body_text(0).as_deref(), Some("New."));
1514        let names: Vec<_> = parsed
1515            .attachments()
1516            .map(|a| a.attachment_name().unwrap().to_string())
1517            .collect();
1518        assert_eq!(names, ["Jahresabschluß.txt", "n.txt"]);
1519        assert_eq!(
1520            parsed.header_raw("X-Custom-Header").map(str::trim),
1521            Some("keep me")
1522        );
1523    }
1524
1525    #[test]
1526    fn edits_keep_the_drafts_message_id_exactly() {
1527        // `draft update` never writes a Message-ID of its own: the upload
1528        // carries the stored draft's id byte for byte (#1953). Gmail then
1529        // assigns a new one on save (#1966), which no test here can see.
1530        let composed = Composition {
1531            to: vec![mailbox("a@example.com")],
1532            subject: "Hi".to_string(),
1533            body: "Hello.\n".to_string(),
1534            message_id_domain: Some("example.org".to_string()),
1535            ..Composition::default()
1536        }
1537        .build()
1538        .unwrap();
1539        let message_id_field = |raw: &[u8]| {
1540            let split = split_message(raw);
1541            parse_fields(split.headers, split.eol)
1542                .into_iter()
1543                .find(|f| f.is_named("Message-ID"))
1544                .map(|f| f.bytes)
1545        };
1546        let edits = || {
1547            [
1548                DraftEdit {
1549                    subject: Some("New".to_string()),
1550                    ..DraftEdit::default()
1551                },
1552                DraftEdit {
1553                    body: Some("New.".to_string()),
1554                    attach: vec![attachment("n.txt", "text/plain", b"note")],
1555                    ..DraftEdit::default()
1556                },
1557            ]
1558        };
1559        for original in [composed.as_slice(), GMAIL_UI_DRAFT.as_bytes()] {
1560            let id = message_id_field(original).expect("the draft has a Message-ID");
1561            for edit in edits() {
1562                let edited = edit.apply(original).unwrap();
1563                assert_eq!(message_id_field(&edited.raw).as_ref(), Some(&id));
1564            }
1565        }
1566    }
1567
1568    #[test]
1569    fn a_missing_mime_version_is_added_on_a_structural_edit() {
1570        let edited = DraftEdit {
1571            attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
1572            ..DraftEdit::default()
1573        }
1574        .apply(b"Subject: x\r\n\r\nbody\r\n")
1575        .unwrap();
1576        assert_eq!(
1577            other_fields(&edited.raw, &["Content-Type"]),
1578            ["Subject: x\r\n", "MIME-Version: 1.0\r\n"]
1579        );
1580        let parsed = parse(&edited.raw);
1581        assert_eq!(parsed.body_text(0).as_deref(), Some("body\r\n"));
1582        assert_eq!(parsed.attachment_count(), 1);
1583    }
1584
1585    #[test]
1586    fn a_mixed_draft_whose_boundary_is_missing_is_refused() {
1587        let original =
1588            b"Content-Type: multipart/mixed; boundary=absent\r\n\r\n--other\r\nx\r\n--other--\r\n";
1589        let err = DraftEdit {
1590            body: Some("x".to_string()),
1591            ..DraftEdit::default()
1592        }
1593        .apply(original)
1594        .unwrap_err();
1595        assert!(err.to_string().contains("--raw"), "{err}");
1596    }
1597
1598    #[test]
1599    fn an_attachment_added_to_an_empty_mixed_draft_becomes_the_whole_entity() {
1600        // The open and close delimiter coincide: no parts, no body, no
1601        // attachments to carry over.
1602        let original = b"Content-Type: multipart/mixed; boundary=b\r\n\r\n--b--\r\n";
1603        let edited = DraftEdit {
1604            attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
1605            ..DraftEdit::default()
1606        }
1607        .apply(original)
1608        .unwrap();
1609        assert!(!contains(&edited.raw, "multipart"));
1610        let parsed = parse(&edited.raw);
1611        assert_eq!(
1612            parsed.attachment(0).unwrap().attachment_name(),
1613            Some("a.pdf")
1614        );
1615    }
1616
1617    #[test]
1618    fn a_structural_edit_on_an_lf_message_writes_lf_parts() {
1619        let original = b"Subject: x\nContent-Type: text/plain\n\nbody\n";
1620        let edited = DraftEdit {
1621            attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
1622            ..DraftEdit::default()
1623        }
1624        .apply(original)
1625        .unwrap();
1626        assert!(!edited.raw.contains(&b'\r'));
1627        let parsed = parse(&edited.raw);
1628        assert_eq!(parsed.body_text(0).as_deref(), Some("body\n"));
1629        assert_eq!(parsed.attachment(0).unwrap().contents(), b"%PDF");
1630    }
1631
1632    // ── round trips on builder output ────────────────────────────────
1633
1634    #[test]
1635    fn round_trip_of_a_composed_multipart_draft_preserves_every_attachment() {
1636        let binary: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
1637        let original = Composition {
1638            to: vec![mailbox("Zoë Ångström <zoe@example.com>")],
1639            cc: vec![mailbox("carol@example.com")],
1640            subject: "Grüße".to_string(),
1641            body: "Hello.\n".to_string(),
1642            attachments: vec![
1643                attachment("data.bin", "application/octet-stream", &binary),
1644                attachment("Jahresabschluß.pdf", "application/pdf", b"%PDF-1.4"),
1645            ],
1646            ..Composition::default()
1647        }
1648        .build()
1649        .unwrap();
1650
1651        let edited = DraftEdit {
1652            subject: Some("Grüße (v2)".to_string()),
1653            body: Some("Hello again.\n".to_string()),
1654            ..DraftEdit::default()
1655        }
1656        .apply(&original)
1657        .unwrap();
1658
1659        let before = parse(&original);
1660        let after = parse(&edited.raw);
1661        assert_eq!(after.subject(), Some("Grüße (v2)"));
1662        assert_eq!(after.body_text(0).as_deref(), Some("Hello again.\r\n"));
1663        assert_eq!(after.attachment_count(), before.attachment_count());
1664        for (a, b) in after.attachments().zip(before.attachments()) {
1665            assert_eq!(a.attachment_name(), b.attachment_name());
1666            assert_eq!(a.contents(), b.contents());
1667            assert_eq!(
1668                a.raw_body_offset() - a.raw_header_offset(),
1669                b.raw_body_offset() - b.raw_header_offset()
1670            );
1671        }
1672        assert_eq!(
1673            after.to().unwrap().first().unwrap().name(),
1674            Some("Zoë Ångström")
1675        );
1676        assert_eq!(after.date(), before.date());
1677        assert_eq!(after.message_id(), before.message_id());
1678    }
1679
1680    // ── attachment naming (review fixes) ─────────────────────────────
1681
1682    /// A `multipart/mixed` message of `parts`, each already a complete part.
1683    fn mixed(parts: &[&str], epilogue: &str) -> Vec<u8> {
1684        let mut raw = String::from(
1685            "Subject: x\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b\r\n\r\n",
1686        );
1687        for part in parts {
1688            raw.push_str("--b\r\n");
1689            raw.push_str(part);
1690            raw.push_str("\r\n");
1691        }
1692        raw.push_str("--b--\r\n");
1693        raw.push_str(epilogue);
1694        raw.into_bytes()
1695    }
1696
1697    const TEXT_BODY: &str = "Content-Type: text/plain\r\n\r\nHello.";
1698
1699    fn named(name: &str, data: &str) -> String {
1700        format!(
1701            "Content-Type: application/octet-stream\r\nContent-Disposition: attachment; \
1702             filename=\"{name}\"\r\n\r\n{data}"
1703        )
1704    }
1705
1706    fn remove(raw: &[u8], name: &str) -> Result<EditedMessage> {
1707        DraftEdit {
1708            remove_attachments: vec![name.to_string()],
1709            ..DraftEdit::default()
1710        }
1711        .apply(raw)
1712    }
1713
1714    #[test]
1715    fn duplicate_names_are_removed_one_at_a_time_by_their_shown_names() {
1716        let raw = mixed(
1717            &[
1718                TEXT_BODY,
1719                &named("image.png", "first"),
1720                &named("image.png", "second"),
1721            ],
1722            "",
1723        );
1724        // `draft show` lists these as image.png and image-1.png.
1725        let edited = remove(&raw, "image-1.png").unwrap();
1726        assert!(contains(&edited.raw, "first"));
1727        assert!(!contains(&edited.raw, "second"));
1728        let edited = remove(&raw, "image.png").unwrap();
1729        assert!(!contains(&edited.raw, "first"));
1730        assert!(contains(&edited.raw, "second"));
1731    }
1732
1733    #[test]
1734    fn a_stored_name_works_only_when_it_is_unambiguous() {
1735        let raw = mixed(&[TEXT_BODY, &named("a/b.pdf", "only")], "");
1736        // Shown as `b.pdf`; the stored `a/b.pdf` names just one part.
1737        assert!(!contains(&remove(&raw, "b.pdf").unwrap().raw, "only"));
1738        assert!(!contains(&remove(&raw, "a/b.pdf").unwrap().raw, "only"));
1739
1740        let raw = mixed(
1741            &[
1742                TEXT_BODY,
1743                &named("x/r.pdf", "one"),
1744                &named("y/r.pdf", "two"),
1745            ],
1746            "",
1747        );
1748        // Only one part is stored as y/r.pdf (shown as r-1.pdf).
1749        let edited = remove(&raw, "y/r.pdf").unwrap();
1750        assert!(contains(&edited.raw, "one"));
1751        assert!(!contains(&edited.raw, "two"));
1752        let err = remove(&raw, "zzz").unwrap_err().to_string();
1753        assert!(err.contains("\"r.pdf\", \"r-1.pdf\""), "{err}");
1754    }
1755
1756    #[test]
1757    fn two_parts_with_the_same_stored_name_need_their_shown_names() {
1758        let a = "Content-Type: application/pdf; name=\"r.pdf\"\r\n\r\none";
1759        let b = "Content-Type: application/pdf; name=\"x/r.pdf\"\r\n\r\ntwo";
1760        // Both show as r.pdf / r-1.pdf, and "x/r.pdf" matches only b.
1761        let raw = mixed(&[TEXT_BODY, a, b, &named("x/r.pdf", "three")], "");
1762        let err = remove(&raw, "x/r.pdf").unwrap_err().to_string();
1763        assert!(
1764            err.starts_with("2 attachments are named \"x/r.pdf\""),
1765            "{err}"
1766        );
1767        assert!(err.contains("\"r-1.pdf\", \"r-2.pdf\""), "{err}");
1768    }
1769
1770    #[test]
1771    fn an_unnamed_attachment_is_removed_by_its_placeholder_name() {
1772        let unnamed = "Content-Type: application/pdf\r\n\r\n%PDF";
1773        let raw = mixed(&[TEXT_BODY, unnamed], "");
1774        let edited = remove(&raw, "attachment-0").unwrap();
1775        assert!(!contains(&edited.raw, "%PDF"));
1776    }
1777
1778    #[test]
1779    fn a_named_text_part_first_is_an_attachment_not_the_body() {
1780        let notes = "Content-Type: text/plain; name=\"notes.txt\"\r\n\r\nmy notes";
1781        let raw = mixed(&[notes], "");
1782        let edited = DraftEdit {
1783            body: Some("Cover note.".to_string()),
1784            ..DraftEdit::default()
1785        }
1786        .apply(&raw)
1787        .unwrap();
1788        assert!(contains(&edited.raw, "my notes"));
1789        let parsed = parse(&edited.raw);
1790        assert_eq!(parsed.body_text(0).as_deref(), Some("Cover note."));
1791        assert!(!contains(
1792            &remove(&raw, "notes.txt").unwrap().raw,
1793            "my notes"
1794        ));
1795    }
1796
1797    #[test]
1798    fn a_draft_that_is_only_an_attachment_keeps_it_when_a_body_is_added() {
1799        let raw = b"Subject: x\r\nContent-Type: application/pdf\r\n\
1800Content-Disposition: attachment; filename=a.pdf\r\n\r\n%PDF";
1801        let edited = DraftEdit {
1802            body: Some("Cover note.".to_string()),
1803            ..DraftEdit::default()
1804        }
1805        .apply(raw)
1806        .unwrap();
1807        assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
1808        let parsed = parse(&edited.raw);
1809        assert_eq!(parsed.body_text(0).as_deref(), Some("Cover note."));
1810        assert_eq!(
1811            parsed.attachment(0).unwrap().attachment_name(),
1812            Some("a.pdf")
1813        );
1814        assert!(!contains(&remove(raw, "a.pdf").unwrap().raw, "%PDF"));
1815    }
1816
1817    #[test]
1818    fn the_epilogue_survives_a_structural_edit() {
1819        let raw = mixed(&[TEXT_BODY, &named("a.pdf", "x")], "trailer text\r\n");
1820        let edited = remove(&raw, "a.pdf").unwrap();
1821        // Unwrapped to a single part: there is no multipart left to carry it.
1822        assert!(!contains(&edited.raw, "multipart"));
1823        let edited = DraftEdit {
1824            attach: vec![attachment("b.pdf", "application/pdf", b"%PDF")],
1825            ..DraftEdit::default()
1826        }
1827        .apply(&raw)
1828        .unwrap();
1829        assert!(edited.raw.ends_with(b"--\r\ntrailer text\r\n"));
1830    }
1831
1832    // ── split_multipart ──────────────────────────────────────────────
1833
1834    #[test]
1835    fn split_multipart_keeps_the_preamble_and_excludes_delimiter_line_endings() {
1836        let body = b"preamble\r\n--b\r\nA\r\n--b  \r\n\r\nB\r\n--b--\r\nepilogue";
1837        assert_eq!(
1838            split_multipart(body, "b").unwrap(),
1839            Multipart {
1840                preamble: b"preamble\r\n",
1841                parts: vec![&b"A"[..], &b"\r\nB"[..]],
1842                epilogue: b"epilogue",
1843            }
1844        );
1845    }
1846
1847    #[test]
1848    fn split_multipart_ignores_lines_that_only_start_with_the_delimiter() {
1849        let body = b"--b\r\n--bx is not a delimiter\r\n--b--";
1850        let parts = split_multipart(body, "b").unwrap().parts;
1851        assert_eq!(parts, [&b"--bx is not a delimiter"[..]]);
1852    }
1853
1854    #[test]
1855    fn split_multipart_tolerates_a_missing_close_delimiter() {
1856        let split = split_multipart(b"--b\nA\n--b\nB\n", "b").unwrap();
1857        assert_eq!(split.parts, [&b"A"[..], &b"B\n"[..]]);
1858        assert!(split.epilogue.is_empty());
1859        assert!(split_multipart(b"no delimiter", "b").is_none());
1860    }
1861}