1use 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#[derive(Debug, Clone, Default)]
50pub struct DraftEdit {
51 pub from: Option<Option<Mailbox>>,
56 pub to: Option<Vec<Mailbox>>,
58 pub cc: Option<Vec<Mailbox>>,
60 pub bcc: Option<Vec<Mailbox>>,
62 pub subject: Option<String>,
64 pub body: Option<String>,
67 pub html_body: Option<String>,
71 pub attach: Vec<Attachment>,
73 pub remove_attachments: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct EditedMessage {
83 pub raw: Vec<u8>,
85 pub warnings: Vec<String>,
87}
88
89impl DraftEdit {
90 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 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 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 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 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 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 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
284struct ExistingPart<'a> {
287 bytes: Cow<'a, [u8]>,
288 stored_name: Option<String>,
290 shown_name: String,
293}
294
295fn 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
317fn 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
350fn 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
377fn 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 Field {
383 name: name.to_string(),
384 bytes: with_eol(bytes, eol),
385 }
386}
387
388fn 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
412struct Split<'a> {
414 headers: &'a [u8],
416 separator: &'a [u8],
418 body: &'a [u8],
420 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#[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 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
486fn 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
514struct TopLevel<'a> {
516 preamble: &'a [u8],
518 body: Option<Cow<'a, [u8]>>,
521 others: Vec<Cow<'a, [u8]>>,
523 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 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 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#[derive(Debug, PartialEq, Eq)]
598struct Multipart<'a> {
599 preamble: &'a [u8],
601 parts: Vec<&'a [u8]>,
603 epilogue: &'a [u8],
605}
606
607fn 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 };
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
660struct PartInfo {
663 mime_type: String,
665 is_attachment: bool,
668 name: Option<String>,
670}
671
672impl PartInfo {
673 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
704fn 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
720fn 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
727fn 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 fn body_of(raw: &[u8]) -> &[u8] {
761 split_message(raw).body
762 }
763
764 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 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 #[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 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 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 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 #[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 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 #[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 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 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 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 #[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 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 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 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 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 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 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 #[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}