use std::borrow::Cow;
use std::collections::HashSet;
use anyhow::{bail, ensure, Result};
use mail_builder::headers::address::Address;
use mail_builder::headers::text::Text;
use mail_builder::headers::Header;
use mail_builder::mime::{BodyPart, MimePart};
use mail_parser::{MessageParser, MimeHeaders};
use crate::gmail::attachments::dedupe_filename;
use crate::gmail::compose::{
inline_image_warning, subject_matches_reply, Attachment, Mailbox, ReplyContext,
};
use crate::utils::multipart::generate_boundary_absent_from;
use crate::utils::path::attachment_filename;
#[derive(Debug, Clone, Default)]
pub struct DraftEdit {
pub from: Option<Option<Mailbox>>,
pub to: Option<Vec<Mailbox>>,
pub cc: Option<Vec<Mailbox>>,
pub bcc: Option<Vec<Mailbox>>,
pub subject: Option<String>,
pub body: Option<String>,
pub html_body: Option<String>,
pub attach: Vec<Attachment>,
pub remove_attachments: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditedMessage {
pub raw: Vec<u8>,
pub warnings: Vec<String>,
}
impl DraftEdit {
fn is_structural(&self) -> bool {
self.body.is_some()
|| self.html_body.is_some()
|| !self.attach.is_empty()
|| !self.remove_attachments.is_empty()
}
pub fn apply(&self, original: &[u8]) -> Result<EditedMessage> {
ensure!(
self.html_body.is_none() || self.body.is_some(),
"an HTML body needs a plain-text alternative"
);
if let Some(subject) = &self.subject {
ensure!(
!subject.contains(['\r', '\n', '\0']),
"the subject contains a line break or NUL character"
);
}
for attachment in &self.attach {
ensure!(
!attachment.content_type.chars().any(char::is_control),
"attachment {:?} has an invalid content type",
attachment.filename
);
}
let split = split_message(original);
let mut fields = parse_fields(split.headers, split.eol);
let mut warnings = Vec::new();
if let Some(subject) = &self.subject {
if let Some(warning) = reply_subject_warning(original, &fields, subject) {
warnings.push(warning);
}
}
let eol = split.eol;
if let Some(from) = &self.from {
let field = from
.as_ref()
.map(|from| encode_field("From", &to_address(from), eol));
match field {
Some(field) if !fields.iter().any(|f| f.is_named("From")) => {
fields.insert(0, field);
}
field => set_field(&mut fields, "From", field),
}
}
for (name, mailboxes) in [("To", &self.to), ("Cc", &self.cc), ("Bcc", &self.bcc)] {
if let Some(mailboxes) = mailboxes {
let field = (!mailboxes.is_empty()).then(|| {
let list = Address::new_list(mailboxes.iter().map(to_address).collect());
encode_field(name, &list, eol)
});
set_field(&mut fields, name, field);
}
}
if let Some(subject) = &self.subject {
let field =
(!subject.is_empty()).then(|| encode_field("Subject", &Text::new(subject), eol));
set_field(&mut fields, "Subject", field);
}
let raw = if self.is_structural() {
self.rebuild_body(original, &split, fields, &mut warnings)?
} else {
let mut raw: Vec<u8> = fields
.iter()
.flat_map(|f| f.bytes.iter().copied())
.collect();
raw.extend_from_slice(split.separator);
raw.extend_from_slice(split.body);
raw
};
Ok(EditedMessage { raw, warnings })
}
fn rebuild_body(
&self,
original: &[u8],
split: &Split<'_>,
fields: Vec<Field>,
warnings: &mut Vec<String>,
) -> Result<Vec<u8>> {
let eol = split.eol;
let (content_fields, mut message_fields): (Vec<Field>, Vec<Field>) =
fields.into_iter().partition(Field::is_content);
let TopLevel {
preamble,
body,
others,
epilogue,
} = top_level_parts(original, split, &content_fields)?;
let mut others = name_parts(others);
let body = match (&self.body, body) {
(Some(text), old) => {
let text_part = MimePart::new("text/plain", BodyPart::Text(text.as_str().into()));
let new_body = match &self.html_body {
None => {
let info = old.as_deref().map(part_info);
if let Some(info) = info.filter(PartInfo::is_rich_body) {
warnings.push(format!(
"the draft's {} body is replaced by plain text: its HTML \
version (and any inline images) is dropped",
info.mime_type
));
}
text_part
}
Some(html) => {
if old.as_deref().is_some_and(has_inline_images) {
warnings.push(
"the draft's inline images are dropped along with its old body"
.to_string(),
);
}
warnings.extend(inline_image_warning(html));
let html_part =
MimePart::new("text/html", BodyPart::Text(html.as_str().into()));
MimePart::new("multipart/alternative", vec![text_part, html_part])
}
};
Some(Cow::Owned(write_part(new_body, eol)))
}
(None, old) => old,
};
for name in &self.remove_attachments {
let index = find_attachment(&others, name)?;
others.remove(index);
}
let mut parts: Vec<Cow<'_, [u8]>> = body.into_iter().collect();
parts.extend(others.into_iter().map(|part| part.bytes));
for attachment in &self.attach {
let part = MimePart::new(
attachment.content_type.as_str(),
BodyPart::Binary(attachment.data.as_slice().into()),
)
.attachment(attachment.filename.as_str());
parts.push(Cow::Owned(write_part(part, eol)));
}
if parts.is_empty() {
parts.push(Cow::Owned(write_part(
MimePart::new("text/plain", BodyPart::Text("".into())),
eol,
)));
}
if !message_fields
.iter()
.any(|field| field.is_named("MIME-Version"))
{
message_fields.push(Field::new("MIME-Version", b"MIME-Version: 1.0", eol));
}
let mut raw: Vec<u8> = message_fields
.iter()
.flat_map(|f| f.bytes.iter().copied())
.collect();
if let [single] = parts.as_slice() {
raw.extend_from_slice(single);
return Ok(raw);
}
let boundary = generate_boundary_absent_from(&parts.concat());
raw.extend_from_slice(
format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"").as_bytes(),
);
raw.extend_from_slice(eol);
raw.extend_from_slice(eol);
raw.extend_from_slice(preamble);
for part in &parts {
raw.extend_from_slice(format!("--{boundary}").as_bytes());
raw.extend_from_slice(eol);
raw.extend_from_slice(part);
raw.extend_from_slice(eol);
}
raw.extend_from_slice(format!("--{boundary}--").as_bytes());
raw.extend_from_slice(eol);
raw.extend_from_slice(epilogue);
Ok(raw)
}
}
struct ExistingPart<'a> {
bytes: Cow<'a, [u8]>,
stored_name: Option<String>,
shown_name: String,
}
fn name_parts(parts: Vec<Cow<'_, [u8]>>) -> Vec<ExistingPart<'_>> {
let mut seen = HashSet::new();
parts
.into_iter()
.enumerate()
.map(|(index, bytes)| {
let stored_name = part_info(&bytes).name;
let shown_name = dedupe_filename(
&mut seen,
attachment_filename(stored_name.as_deref().unwrap_or(""), &index.to_string()),
);
ExistingPart {
bytes,
stored_name,
shown_name,
}
})
.collect()
}
fn find_attachment(parts: &[ExistingPart<'_>], name: &str) -> Result<usize> {
if let Some(index) = parts.iter().position(|part| part.shown_name == name) {
return Ok(index);
}
let stored: Vec<usize> = (0..parts.len())
.filter(|&i| parts[i].stored_name.as_deref() == Some(name))
.collect();
let quoted = |indexes: &mut dyn Iterator<Item = usize>| -> String {
indexes
.map(|i| format!("{:?}", parts[i].shown_name))
.collect::<Vec<_>>()
.join(", ")
};
match stored.as_slice() {
[index] => Ok(*index),
[] if parts.is_empty() => {
bail!("the draft has no attachment named {name:?}: it has no attachments")
}
[] => bail!(
"the draft has no attachment named {name:?}; its attachments are: {}",
quoted(&mut (0..parts.len()))
),
_ => bail!(
"{} attachments are named {name:?}; remove them by the names `gmail draft show` \
lists instead: {}",
stored.len(),
quoted(&mut stored.iter().copied())
),
}
}
fn reply_subject_warning(original: &[u8], fields: &[Field], subject: &str) -> Option<String> {
if !fields.iter().any(|field| field.is_named("In-Reply-To")) {
return None;
}
let old = MessageParser::default()
.parse_headers(original)
.and_then(|message| message.subject().map(str::to_string))
.unwrap_or_default();
let reply = ReplyContext {
subject: old.clone(),
..ReplyContext::default()
};
(!subject_matches_reply(subject, &reply)).then(|| {
format!(
"the new subject differs from the reply's {old:?}; Gmail may move this draft out of \
its thread"
)
})
}
fn to_address(mailbox: &Mailbox) -> Address<'_> {
Address::new_address(mailbox.name.as_deref(), mailbox.email.as_str())
}
fn encode_field(name: &str, value: &impl Header, eol: &[u8]) -> Field {
let mut bytes = format!("{name}: ").into_bytes();
value.write_header(&mut bytes, name.len() + 2);
Field {
name: name.to_string(),
bytes: with_eol(bytes, eol),
}
}
fn set_field(fields: &mut Vec<Field>, name: &str, field: Option<Field>) {
let mut replacement = field;
let mut found = false;
fields.retain_mut(|existing| {
if !existing.is_named(name) {
return true;
}
found = true;
match replacement.take() {
Some(new) => {
*existing = new;
true
}
None => false,
}
});
if !found {
fields.extend(replacement);
}
}
struct Split<'a> {
headers: &'a [u8],
separator: &'a [u8],
body: &'a [u8],
eol: &'static [u8],
}
fn split_message(raw: &[u8]) -> Split<'_> {
let eol: &'static [u8] = match raw.iter().position(|&b| b == b'\n') {
Some(nl) if nl == 0 || raw[nl - 1] != b'\r' => b"\n",
_ => b"\r\n",
};
let mut pos = 0;
while pos < raw.len() {
let line_end = raw[pos..]
.iter()
.position(|&b| b == b'\n')
.map_or(raw.len(), |nl| pos + nl + 1);
let line = &raw[pos..line_end];
if line == b"\n" || line == b"\r\n" {
return Split {
headers: &raw[..pos],
separator: line,
body: &raw[line_end..],
eol,
};
}
pos = line_end;
}
Split {
headers: raw,
separator: b"",
body: b"",
eol,
}
}
#[derive(Debug, Clone)]
struct Field {
name: String,
bytes: Vec<u8>,
}
impl Field {
fn new(name: &str, line: &[u8], eol: &[u8]) -> Self {
let mut bytes = line.to_vec();
bytes.extend_from_slice(eol);
Self {
name: name.to_string(),
bytes,
}
}
fn is_named(&self, name: &str) -> bool {
self.name.eq_ignore_ascii_case(name)
}
fn is_content(&self) -> bool {
self.name
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("content-"))
}
}
fn parse_fields(headers: &[u8], eol: &[u8]) -> Vec<Field> {
let mut fields: Vec<Field> = Vec::new();
for line in headers.split_inclusive(|&b| b == b'\n') {
let continues = matches!(line.first(), Some(b' ' | b'\t'));
match fields.last_mut() {
Some(field) if continues => field.bytes.extend_from_slice(line),
_ => {
let name = line
.iter()
.position(|&b| b == b':')
.map(|colon| String::from_utf8_lossy(&line[..colon]).trim().to_string())
.unwrap_or_default();
fields.push(Field {
name,
bytes: line.to_vec(),
});
}
}
}
if let Some(last) = fields.last_mut().filter(|f| !f.bytes.ends_with(b"\n")) {
last.bytes.extend_from_slice(eol);
}
fields
}
struct TopLevel<'a> {
preamble: &'a [u8],
body: Option<Cow<'a, [u8]>>,
others: Vec<Cow<'a, [u8]>>,
epilogue: &'a [u8],
}
fn top_level_parts<'a>(
original: &'a [u8],
split: &Split<'a>,
content_fields: &[Field],
) -> Result<TopLevel<'a>> {
let root = MessageParser::default().parse_headers(original);
let boundary = root.as_ref().and_then(|message| {
let content_type = message.content_type()?;
let is_mixed = content_type.ctype().eq_ignore_ascii_case("multipart")
&& content_type
.subtype()
.is_some_and(|subtype| subtype.eq_ignore_ascii_case("mixed"));
is_mixed
.then(|| content_type.attribute("boundary").map(str::to_string))
.flatten()
});
let Some(boundary) = boundary else {
let mut entity: Vec<u8> = content_fields
.iter()
.flat_map(|f| f.bytes.iter().copied())
.collect();
entity.extend_from_slice(split.eol);
entity.extend_from_slice(split.body);
let entity = Cow::Owned(entity);
let (body, others) = if part_info(&entity).is_attachment {
(None, vec![entity])
} else {
(Some(entity), Vec::new())
};
return Ok(TopLevel {
preamble: b"",
body,
others,
epilogue: b"",
});
};
let Some(Multipart {
preamble,
parts,
epilogue,
}) = split_multipart(split.body, &boundary)
else {
bail!(
"the draft is multipart/mixed but its boundary {boundary:?} never occurs in it, so \
its parts can't be edited; use `--raw` instead"
);
};
let mut parts = parts.into_iter().map(Cow::Borrowed);
let mut body = None;
let mut others = Vec::new();
match parts.next() {
Some(first) if part_info(&first).is_attachment => others.push(first),
Some(first) => body = Some(first),
None => {}
}
others.extend(parts);
Ok(TopLevel {
preamble,
body,
others,
epilogue,
})
}
#[derive(Debug, PartialEq, Eq)]
struct Multipart<'a> {
preamble: &'a [u8],
parts: Vec<&'a [u8]>,
epilogue: &'a [u8],
}
fn split_multipart<'a>(body: &'a [u8], boundary: &str) -> Option<Multipart<'a>> {
let delimiter = format!("--{boundary}");
let delimiter = delimiter.as_bytes();
let mut preamble = None;
let mut parts = Vec::new();
let mut part_start = 0;
let mut pos = 0;
while pos < body.len() {
let line_end = body[pos..]
.iter()
.position(|&b| b == b'\n')
.map_or(body.len(), |nl| pos + nl + 1);
let line = body[pos..line_end].trim_ascii_end();
let is_close = line.strip_prefix(delimiter) == Some(b"--");
if line == delimiter || is_close {
if preamble.is_none() {
preamble = Some(&body[..pos]);
} else {
let end = if body[..pos].ends_with(b"\r\n") {
pos - 2
} else if body[..pos].ends_with(b"\n") {
pos - 1
} else {
pos };
parts.push(&body[part_start..end.max(part_start)]);
}
if is_close {
return Some(Multipart {
preamble: preamble.unwrap_or_default(),
parts,
epilogue: &body[line_end..],
});
}
part_start = line_end;
}
pos = line_end;
}
let preamble = preamble?;
if part_start < body.len() {
parts.push(&body[part_start..]);
}
Some(Multipart {
preamble,
parts,
epilogue: b"",
})
}
struct PartInfo {
mime_type: String,
is_attachment: bool,
name: Option<String>,
}
impl PartInfo {
fn is_rich_body(&self) -> bool {
self.mime_type != "text/plain"
}
}
fn part_info(part: &[u8]) -> PartInfo {
let parsed = MessageParser::default().parse_headers(part);
let content_type = parsed.as_ref().and_then(|message| message.content_type());
let ctype = content_type.map_or_else(|| "text".to_string(), |ct| ct.ctype().to_lowercase());
let subtype = content_type
.and_then(|ct| ct.subtype())
.map_or_else(|| "plain".to_string(), str::to_lowercase);
let disposition_attachment = parsed
.as_ref()
.and_then(|message| message.content_disposition())
.is_some_and(|disposition| disposition.ctype().eq_ignore_ascii_case("attachment"));
let name = parsed
.as_ref()
.and_then(|message| message.attachment_name())
.map(str::to_string);
let is_attachment =
disposition_attachment || name.is_some() || !matches!(ctype.as_str(), "text" | "multipart");
PartInfo {
mime_type: format!("{ctype}/{subtype}"),
is_attachment,
name,
}
}
fn has_inline_images(part: &[u8]) -> bool {
MessageParser::default().parse(part).is_some_and(|message| {
message.parts.iter().any(|part| {
part.content_id().is_some()
|| part.content_type().is_some_and(|ct| {
ct.ctype().eq_ignore_ascii_case("multipart")
&& ct
.subtype()
.is_some_and(|subtype| subtype.eq_ignore_ascii_case("related"))
})
})
})
}
fn write_part(part: MimePart<'_>, eol: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
part.write_part(&mut bytes);
with_eol(bytes, eol)
}
fn with_eol(bytes: Vec<u8>, eol: &[u8]) -> Vec<u8> {
if eol == b"\n" {
let mut lf = Vec::with_capacity(bytes.len());
let mut iter = bytes.iter().peekable();
while let Some(&b) = iter.next() {
if b == b'\r' && iter.peek() == Some(&&b'\n') {
continue;
}
lf.push(b);
}
lf
} else {
bytes
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::gmail::compose::Composition;
use mail_parser::Message;
fn mailbox(input: &str) -> Mailbox {
Mailbox::parse(input).unwrap()
}
fn parse(raw: &[u8]) -> Message<'_> {
MessageParser::default().parse(raw).unwrap()
}
fn body_of(raw: &[u8]) -> &[u8] {
split_message(raw).body
}
const GMAIL_UI_DRAFT: &str = "MIME-Version: 1.0\r\n\
Date: Mon, 21 Sep 2026 10:00:00 +1000\r\n\
References: <a@example.com> <b@example.com>\r\n\
In-Reply-To: <b@example.com>\r\n\
Message-ID: <draft-1@mail.gmail.com>\r\n\
Subject: Re: Quarterly report\r\n\
From: Me <me@example.com>\r\n\
To: Alice <alice@example.com>\r\n\
Cc: carol@example.com\r\n\
X-Custom-Header: keep me\r\n\
Content-Type: multipart/mixed; boundary=\"000000000000mixed\"\r\n\
\r\n\
--000000000000mixed\r\n\
Content-Type: multipart/alternative; boundary=\"000000000000alt\"\r\n\
\r\n\
--000000000000alt\r\n\
Content-Type: text/plain; charset=\"UTF-8\"\r\n\
\r\n\
Figures attached.\r\n\
\r\n\
--000000000000alt\r\n\
Content-Type: text/html; charset=\"UTF-8\"\r\n\
\r\n\
<div dir=\"ltr\">Figures <b>attached</b>.</div>\r\n\
\r\n\
--000000000000alt--\r\n\
--000000000000mixed\r\n\
Content-Type: application/pdf; name=\"q3.pdf\"\r\n\
Content-Disposition: attachment; filename=\"q3.pdf\"\r\n\
Content-Transfer-Encoding: base64\r\n\
X-Attachment-Id: f_abc\r\n\
\r\n\
JVBERi0xLjQK\r\n\
--000000000000mixed\r\n\
Content-Type: text/plain; charset=\"UTF-8\"; name=\"=?UTF-8?Q?Jahresabschlu=C3=9F=2Etxt?=\"\r\n\
Content-Disposition: attachment; filename*=UTF-8''Jahresabschlu%C3%9F.txt\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVsbG8=\r\n\
--000000000000mixed--\r\n";
const PDF_PART: &str = "Content-Type: application/pdf; name=\"q3.pdf\"\r\n\
Content-Disposition: attachment; filename=\"q3.pdf\"\r\n\
Content-Transfer-Encoding: base64\r\n\
X-Attachment-Id: f_abc\r\n\
\r\n\
JVBERi0xLjQK";
const ALT_PART: &str = "Content-Type: multipart/alternative; boundary=\"000000000000alt\"\r\n\
\r\n\
--000000000000alt\r\n\
Content-Type: text/plain; charset=\"UTF-8\"\r\n\
\r\n\
Figures attached.\r\n\
\r\n\
--000000000000alt\r\n\
Content-Type: text/html; charset=\"UTF-8\"\r\n\
\r\n\
<div dir=\"ltr\">Figures <b>attached</b>.</div>\r\n\
\r\n\
--000000000000alt--";
fn other_fields(raw: &[u8], except: &[&str]) -> Vec<String> {
let split = split_message(raw);
parse_fields(split.headers, split.eol)
.into_iter()
.filter(|f| !except.iter().any(|name| f.is_named(name)))
.map(|f| String::from_utf8(f.bytes).unwrap())
.collect()
}
fn contains(haystack: &[u8], needle: &str) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle.as_bytes())
}
fn attachment(name: &str, content_type: &str, data: &[u8]) -> Attachment {
Attachment {
filename: name.to_string(),
content_type: content_type.to_string(),
data: data.to_vec(),
}
}
#[test]
fn a_subject_edit_changes_only_the_subject_field() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
subject: Some("RE: quarterly report".to_string()),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(body_of(&edited.raw), body_of(original));
assert_eq!(
other_fields(&edited.raw, &["Subject"]),
other_fields(original, &["Subject"])
);
let parsed = parse(&edited.raw);
assert_eq!(parsed.subject(), Some("RE: quarterly report"));
assert_eq!(parsed.in_reply_to().as_text(), Some("b@example.com"));
assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
}
#[test]
fn a_from_edit_replaces_only_the_from_field_in_place() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
from: Some(Some(mailbox("Zoë <sales@example.org>"))),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(body_of(&edited.raw), body_of(original));
assert_eq!(
other_fields(&edited.raw, &["From"]),
other_fields(original, &["From"])
);
let fields = other_fields(&edited.raw, &[]);
let from_index = other_fields(original, &[])
.iter()
.position(|f| f.starts_with("From:"))
.unwrap();
assert!(fields[from_index].starts_with("From: "));
assert!(fields[from_index].is_ascii());
let parsed = parse(&edited.raw);
let from = parsed.from().unwrap().first().unwrap();
assert_eq!(from.name(), Some("Zoë"));
assert_eq!(from.address(), Some("sales@example.org"));
assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
}
#[test]
fn a_from_edit_adds_the_header_to_a_draft_without_one() {
let original = b"To: a@example.com\r\nSubject: Hi\r\n\r\nbody\r\n";
let edited = DraftEdit {
from: Some(Some(mailbox("sales@example.org"))),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(
edited.raw,
b"From: <sales@example.org>\r\nTo: a@example.com\r\nSubject: Hi\r\n\r\nbody\r\n"
);
}
#[test]
fn a_from_edit_of_none_removes_the_header() {
let original = b"From: Alias <a@example.org>\r\nTo: b@example.com\r\n\r\nbody\r\n";
let edited = DraftEdit {
from: Some(None),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(edited.raw, b"To: b@example.com\r\n\r\nbody\r\n");
}
#[test]
fn a_recipient_edit_replaces_the_whole_list_in_place() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
cc: Some(vec![
mailbox("Zoë <zoe@example.com>"),
mailbox("dan@example.com"),
]),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(body_of(&edited.raw), body_of(original));
let fields = other_fields(&edited.raw, &[]);
let original_fields = other_fields(original, &[]);
assert_eq!(fields.len(), original_fields.len());
let cc_index = original_fields
.iter()
.position(|f| f.starts_with("Cc:"))
.unwrap();
assert!(fields[cc_index].starts_with("Cc: "));
assert!(fields[cc_index].is_ascii());
let parsed = parse(&edited.raw);
let cc: Vec<(Option<&str>, Option<&str>)> = parsed
.cc()
.unwrap()
.iter()
.map(|a| (a.name(), a.address()))
.collect();
assert_eq!(
cc,
[
(Some("Zoë"), Some("zoe@example.com")),
(None, Some("dan@example.com"))
]
);
}
#[test]
fn a_missing_header_is_added_and_an_empty_list_removes_one() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
bcc: Some(vec![mailbox("audit@example.com")]),
cc: Some(Vec::new()),
..DraftEdit::default()
}
.apply(original)
.unwrap();
let parsed = parse(&edited.raw);
assert_eq!(
parsed.bcc().unwrap().first().unwrap().address(),
Some("audit@example.com")
);
assert!(parsed.cc().is_none());
assert_eq!(body_of(&edited.raw), body_of(original));
}
#[test]
fn duplicate_fields_collapse_into_the_replacement() {
let original = b"To: a@example.com\r\nSubject: One\r\nSubject: Two\r\n\r\nbody\r\n";
let edited = DraftEdit {
subject: Some("Three".to_string()),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(
edited.raw,
b"To: a@example.com\r\nSubject: Three\r\n\r\nbody\r\n"
);
}
#[test]
fn folded_fields_are_replaced_whole_and_others_kept_folded() {
let original = b"To: a@example.com,\r\n b@example.com\r\nX-Long: one\r\n\ttwo\r\n\r\nbody";
let edited = DraftEdit {
to: Some(vec![mailbox("c@example.com")]),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(
edited.raw,
b"To: <c@example.com>\r\nX-Long: one\r\n\ttwo\r\n\r\nbody"
);
}
#[test]
fn non_ascii_subjects_are_encoded_words() {
let edited = DraftEdit {
subject: Some("Grüße — 你好".to_string()),
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nbody")
.unwrap();
assert!(edited.raw.is_ascii());
assert_eq!(parse(&edited.raw).subject(), Some("Grüße — 你好"));
}
#[test]
fn a_subject_with_a_line_break_is_rejected() {
let err = DraftEdit {
subject: Some("Hi\r\nBcc: eve@example.com".to_string()),
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nbody")
.unwrap_err();
assert!(err.to_string().contains("subject"), "{err}");
}
#[test]
fn changing_a_reply_subject_warns_about_the_thread() {
let edited = DraftEdit {
subject: Some("Something else".to_string()),
..DraftEdit::default()
}
.apply(GMAIL_UI_DRAFT.as_bytes())
.unwrap();
assert_eq!(edited.warnings.len(), 1);
assert!(
edited.warnings[0].contains("out of its thread"),
"{:?}",
edited.warnings
);
let edited = DraftEdit {
subject: Some("Something else".to_string()),
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nbody")
.unwrap();
assert!(edited.warnings.is_empty());
}
#[test]
fn lf_only_messages_keep_lf_line_endings() {
let original = b"To: a@example.com\nSubject: Old\n\nbody\n";
let edited = DraftEdit {
subject: Some("New".to_string()),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert_eq!(edited.raw, b"To: a@example.com\nSubject: New\n\nbody\n");
}
#[test]
fn a_message_without_a_body_gains_the_field() {
let edited = DraftEdit {
subject: Some("New".to_string()),
..DraftEdit::default()
}
.apply(b"To: a@example.com")
.unwrap();
assert_eq!(edited.raw, b"To: a@example.com\r\nSubject: New\r\n");
}
#[test]
fn removing_an_attachment_keeps_every_other_part_byte_for_byte() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
remove_attachments: vec!["Jahresabschluß.txt".to_string()],
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert!(contains(&edited.raw, ALT_PART));
assert!(contains(&edited.raw, PDF_PART));
assert!(!contains(&edited.raw, "Jahresabschlu"));
assert_eq!(
other_fields(&edited.raw, &["Content-Type"]),
other_fields(original, &["Content-Type"])
);
let parsed = parse(&edited.raw);
assert_eq!(parsed.attachment_count(), 1);
assert_eq!(
parsed.attachment(0).unwrap().attachment_name(),
Some("q3.pdf")
);
assert_eq!(
parsed.body_text(0).as_deref(),
Some("Figures attached.\r\n")
);
assert!(parsed.body_html(0).unwrap().contains("<b>attached</b>"));
assert!(edited.warnings.is_empty());
}
#[test]
fn adding_an_attachment_appends_a_part_and_keeps_the_rest() {
let original = GMAIL_UI_DRAFT.as_bytes();
let binary: Vec<u8> = (0..=255u8).collect();
let edited = DraftEdit {
attach: vec![attachment("data.bin", "application/octet-stream", &binary)],
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert!(contains(&edited.raw, ALT_PART));
assert!(contains(&edited.raw, PDF_PART));
let parsed = parse(&edited.raw);
assert_eq!(parsed.attachment_count(), 3);
let added = parsed.attachment(2).unwrap();
assert_eq!(added.attachment_name(), Some("data.bin"));
assert_eq!(added.contents(), binary.as_slice());
assert_eq!(
parsed.attachment(1).unwrap().attachment_name(),
Some("Jahresabschluß.txt")
);
}
#[test]
fn attaching_to_a_single_part_draft_wraps_it_in_multipart_mixed() {
let original = b"To: a@example.com\r\nSubject: Hi\r\nMIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello.\r\n";
let edited = DraftEdit {
attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert!(contains(
&edited.raw,
"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello.\r\n"
));
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("Hello.\r\n"));
assert_eq!(parsed.attachment_count(), 1);
assert_eq!(parsed.attachment(0).unwrap().contents(), b"%PDF");
assert_eq!(
other_fields(&edited.raw, &["Content-Type"]),
[
"To: a@example.com\r\n",
"Subject: Hi\r\n",
"MIME-Version: 1.0\r\n"
]
);
}
#[test]
fn removing_the_last_attachment_unwraps_the_body() {
let original = Composition {
to: vec![mailbox("a@example.com")],
subject: "Hi".to_string(),
body: "Hello.\n".to_string(),
attachments: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
..Composition::default()
}
.build()
.unwrap();
let edited = DraftEdit {
remove_attachments: vec!["a.pdf".to_string()],
..DraftEdit::default()
}
.apply(&original)
.unwrap();
assert!(!contains(&edited.raw, "multipart"));
let parsed = parse(&edited.raw);
assert_eq!(parsed.attachment_count(), 0);
assert_eq!(parsed.body_text(0).as_deref(), Some("Hello.\r\n"));
assert_eq!(parsed.subject(), Some("Hi"));
}
#[test]
fn removing_an_unknown_attachment_lists_the_real_ones() {
let err = DraftEdit {
remove_attachments: vec!["nope.pdf".to_string()],
..DraftEdit::default()
}
.apply(GMAIL_UI_DRAFT.as_bytes())
.unwrap_err()
.to_string();
assert!(err.contains("\"nope.pdf\""), "{err}");
assert!(err.contains("\"q3.pdf\", \"Jahresabschluß.txt\""), "{err}");
let err = DraftEdit {
remove_attachments: vec!["nope.pdf".to_string()],
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nbody")
.unwrap_err()
.to_string();
assert!(err.contains("it has no attachments"), "{err}");
}
#[test]
fn an_attachment_content_type_with_a_line_break_is_rejected() {
let err = DraftEdit {
attach: vec![attachment("a.txt", "text/plain\r\nX-Evil: 1", b"")],
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nbody")
.unwrap_err();
assert!(err.to_string().contains("content type"), "{err}");
}
#[test]
fn a_body_edit_keeps_the_attachments_and_warns_that_html_is_dropped() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
body: Some("Revised figures attached.\n".to_string()),
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert!(contains(&edited.raw, PDF_PART));
assert!(!contains(&edited.raw, "text/html"));
let parsed = parse(&edited.raw);
assert_eq!(
parsed.body_text(0).as_deref(),
Some("Revised figures attached.\r\n")
);
assert_eq!(parsed.attachment_count(), 2);
assert_eq!(parsed.in_reply_to().as_text(), Some("b@example.com"));
assert_eq!(
other_fields(&edited.raw, &["Content-Type"]),
other_fields(original, &["Content-Type"])
);
assert_eq!(edited.warnings.len(), 1);
assert!(
edited.warnings[0].contains("multipart/alternative"),
"{:?}",
edited.warnings
);
}
#[test]
fn a_body_edit_on_a_plain_draft_replaces_it_without_a_warning() {
let original = Composition {
to: vec![mailbox("a@example.com")],
subject: "Hi".to_string(),
body: "Old.\n".to_string(),
..Composition::default()
}
.build()
.unwrap();
let edited = DraftEdit {
body: Some("Ünïcödé new body.\n".to_string()),
..DraftEdit::default()
}
.apply(&original)
.unwrap();
assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("Ünïcödé new body.\n"));
assert_eq!(
other_fields(&edited.raw, &["Content-Type", "Content-Transfer-Encoding"]),
other_fields(&original, &["Content-Type", "Content-Transfer-Encoding"])
);
}
fn html_edit(text: &str, html: &str) -> DraftEdit {
DraftEdit {
body: Some(text.to_string()),
html_body: Some(html.to_string()),
..DraftEdit::default()
}
}
#[test]
fn an_html_edit_on_a_plain_draft_makes_it_multipart_alternative() {
let original = Composition {
to: vec![mailbox("a@example.com")],
subject: "Hi".to_string(),
body: "Old.\n".to_string(),
..Composition::default()
}
.build()
.unwrap();
let edited = html_edit("New.", "<p>New.</p>").apply(&original).unwrap();
assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
let raw = String::from_utf8(edited.raw.clone()).unwrap();
let top = split_message(&edited.raw).headers;
assert!(
contains(top, "Content-Type: multipart/alternative"),
"{raw}"
);
assert!(raw.find("text/plain").unwrap() < raw.find("text/html").unwrap());
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("New."));
assert_eq!(parsed.body_html(0).as_deref(), Some("<p>New.</p>"));
assert_eq!(
other_fields(&edited.raw, &["Content-Type", "Content-Transfer-Encoding"]),
other_fields(&original, &["Content-Type", "Content-Transfer-Encoding"])
);
}
#[test]
fn an_html_edit_replaces_a_gmail_alternative_and_keeps_the_attachments() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = html_edit("Revised.", "<p>Revised <b>figures</b>.</p>")
.apply(original)
.unwrap();
assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
assert!(!contains(&edited.raw, ALT_PART));
assert!(!contains(&edited.raw, "Figures attached."));
assert!(contains(&edited.raw, PDF_PART));
assert!(contains(
&edited.raw,
"Content-Disposition: attachment; filename*=UTF-8''Jahresabschlu%C3%9F.txt\r\n\
Content-Transfer-Encoding: base64\r\n\r\nSGVsbG8="
));
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("Revised."));
assert_eq!(
parsed.body_html(0).as_deref(),
Some("<p>Revised <b>figures</b>.</p>")
);
assert_eq!(parsed.attachment_count(), 2);
assert_eq!(
other_fields(&edited.raw, &["Content-Type"]),
other_fields(original, &["Content-Type"])
);
}
const INLINE_IMAGE_DRAFT: &str = "MIME-Version: 1.0\r\n\
Subject: Logo\r\n\
Content-Type: multipart/related; boundary=\"rel\"\r\n\
\r\n\
--rel\r\n\
Content-Type: multipart/alternative; boundary=\"alt\"\r\n\
\r\n\
--alt\r\n\
Content-Type: text/plain\r\n\
\r\n\
[image: logo]\r\n\
--alt\r\n\
Content-Type: text/html\r\n\
\r\n\
<img src=\"cid:logo@x\">\r\n\
--alt--\r\n\
--rel\r\n\
Content-Type: image/png; name=\"logo.png\"\r\n\
Content-ID: <logo@x>\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
iVBORw0KGgo=\r\n\
--rel--\r\n";
#[test]
fn an_html_edit_warns_when_it_drops_inline_images() {
let edited = html_edit("New.", "<p>New.</p>")
.apply(INLINE_IMAGE_DRAFT.as_bytes())
.unwrap();
assert_eq!(edited.warnings.len(), 1, "{:?}", edited.warnings);
assert!(
edited.warnings[0].contains("inline images are dropped"),
"{:?}",
edited.warnings
);
assert!(!contains(&edited.raw, "iVBORw0KGgo="));
}
#[test]
fn an_html_edit_warns_about_cid_references_in_the_new_html() {
let edited = html_edit("New.", "<img src=\"cid:logo@x\">")
.apply(b"Subject: x\r\n\r\nOld.\r\n")
.unwrap();
assert_eq!(edited.warnings.len(), 1, "{:?}", edited.warnings);
assert!(
edited.warnings[0].contains("`cid:`"),
"{:?}",
edited.warnings
);
}
#[test]
fn has_inline_images_looks_for_related_parts_and_content_ids() {
assert!(has_inline_images(INLINE_IMAGE_DRAFT.as_bytes()));
assert!(!has_inline_images(ALT_PART.as_bytes()));
assert!(has_inline_images(
b"Content-Type: multipart/related; boundary=\"r\"\r\n\r\n--r\r\n\
Content-Type: text/html\r\n\r\n<p>x</p>\r\n--r--\r\n"
));
}
#[test]
fn an_html_edit_needs_a_plain_text_body() {
let err = DraftEdit {
html_body: Some("<p>x</p>".to_string()),
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nOld.\r\n")
.unwrap_err();
assert!(err.to_string().contains("plain-text alternative"), "{err}");
}
#[test]
fn an_html_edit_on_an_lf_message_writes_lf_parts() {
let original = b"Subject: x\nContent-Type: text/plain\n\nbody\n";
let edited = html_edit("New.\n", "<p>New.</p>\n")
.apply(original)
.unwrap();
assert!(!edited.raw.contains(&b'\r'));
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("New.\n"));
assert_eq!(parsed.body_html(0).as_deref(), Some("<p>New.</p>\n"));
}
#[test]
fn an_html_edit_combines_with_attachment_and_subject_edits() {
let edited = DraftEdit {
subject: Some("Re: Quarterly report (v2)".to_string()),
attach: vec![attachment("notes.txt", "text/plain", b"notes")],
remove_attachments: vec!["q3.pdf".to_string()],
..html_edit("Revised.", "<p>Revised.</p>")
}
.apply(GMAIL_UI_DRAFT.as_bytes())
.unwrap();
let parsed = parse(&edited.raw);
assert_eq!(parsed.subject(), Some("Re: Quarterly report (v2)"));
assert_eq!(parsed.body_html(0).as_deref(), Some("<p>Revised.</p>"));
let names: Vec<_> = parsed
.attachments()
.map(|a| a.attachment_name().unwrap().to_string())
.collect();
assert_eq!(names, ["Jahresabschluß.txt", "notes.txt"]);
}
#[test]
fn a_draft_that_is_only_an_attachment_gains_a_body_before_it() {
let original = b"Subject: x\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b\r\n\r\n\
--b\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=a.pdf\r\n\r\n%PDF\r\n--b--\r\n";
let edited = DraftEdit {
body: Some("Cover note.".to_string()),
..DraftEdit::default()
}
.apply(original)
.unwrap();
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("Cover note."));
assert_eq!(
parsed.attachment(0).unwrap().attachment_name(),
Some("a.pdf")
);
assert!(edited.warnings.is_empty());
}
#[test]
fn edits_combine_in_one_pass() {
let original = GMAIL_UI_DRAFT.as_bytes();
let edited = DraftEdit {
from: Some(Some(mailbox("sales@example.org"))),
to: Some(vec![mailbox("bob@example.com")]),
body: Some("New.".to_string()),
attach: vec![attachment("n.txt", "text/plain", b"note")],
remove_attachments: vec!["q3.pdf".to_string()],
..DraftEdit::default()
}
.apply(original)
.unwrap();
let parsed = parse(&edited.raw);
assert_eq!(
parsed.from().unwrap().first().unwrap().address(),
Some("sales@example.org")
);
assert_eq!(
parsed.to().unwrap().first().unwrap().address(),
Some("bob@example.com")
);
assert_eq!(parsed.body_text(0).as_deref(), Some("New."));
let names: Vec<_> = parsed
.attachments()
.map(|a| a.attachment_name().unwrap().to_string())
.collect();
assert_eq!(names, ["Jahresabschluß.txt", "n.txt"]);
assert_eq!(
parsed.header_raw("X-Custom-Header").map(str::trim),
Some("keep me")
);
}
#[test]
fn edits_keep_the_drafts_message_id_exactly() {
let composed = Composition {
to: vec![mailbox("a@example.com")],
subject: "Hi".to_string(),
body: "Hello.\n".to_string(),
message_id_domain: Some("example.org".to_string()),
..Composition::default()
}
.build()
.unwrap();
let message_id_field = |raw: &[u8]| {
let split = split_message(raw);
parse_fields(split.headers, split.eol)
.into_iter()
.find(|f| f.is_named("Message-ID"))
.map(|f| f.bytes)
};
let edits = || {
[
DraftEdit {
subject: Some("New".to_string()),
..DraftEdit::default()
},
DraftEdit {
body: Some("New.".to_string()),
attach: vec![attachment("n.txt", "text/plain", b"note")],
..DraftEdit::default()
},
]
};
for original in [composed.as_slice(), GMAIL_UI_DRAFT.as_bytes()] {
let id = message_id_field(original).expect("the draft has a Message-ID");
for edit in edits() {
let edited = edit.apply(original).unwrap();
assert_eq!(message_id_field(&edited.raw).as_ref(), Some(&id));
}
}
}
#[test]
fn a_missing_mime_version_is_added_on_a_structural_edit() {
let edited = DraftEdit {
attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
..DraftEdit::default()
}
.apply(b"Subject: x\r\n\r\nbody\r\n")
.unwrap();
assert_eq!(
other_fields(&edited.raw, &["Content-Type"]),
["Subject: x\r\n", "MIME-Version: 1.0\r\n"]
);
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("body\r\n"));
assert_eq!(parsed.attachment_count(), 1);
}
#[test]
fn a_mixed_draft_whose_boundary_is_missing_is_refused() {
let original =
b"Content-Type: multipart/mixed; boundary=absent\r\n\r\n--other\r\nx\r\n--other--\r\n";
let err = DraftEdit {
body: Some("x".to_string()),
..DraftEdit::default()
}
.apply(original)
.unwrap_err();
assert!(err.to_string().contains("--raw"), "{err}");
}
#[test]
fn an_attachment_added_to_an_empty_mixed_draft_becomes_the_whole_entity() {
let original = b"Content-Type: multipart/mixed; boundary=b\r\n\r\n--b--\r\n";
let edited = DraftEdit {
attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert!(!contains(&edited.raw, "multipart"));
let parsed = parse(&edited.raw);
assert_eq!(
parsed.attachment(0).unwrap().attachment_name(),
Some("a.pdf")
);
}
#[test]
fn a_structural_edit_on_an_lf_message_writes_lf_parts() {
let original = b"Subject: x\nContent-Type: text/plain\n\nbody\n";
let edited = DraftEdit {
attach: vec![attachment("a.pdf", "application/pdf", b"%PDF")],
..DraftEdit::default()
}
.apply(original)
.unwrap();
assert!(!edited.raw.contains(&b'\r'));
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("body\n"));
assert_eq!(parsed.attachment(0).unwrap().contents(), b"%PDF");
}
#[test]
fn round_trip_of_a_composed_multipart_draft_preserves_every_attachment() {
let binary: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
let original = Composition {
to: vec![mailbox("Zoë Ångström <zoe@example.com>")],
cc: vec![mailbox("carol@example.com")],
subject: "Grüße".to_string(),
body: "Hello.\n".to_string(),
attachments: vec![
attachment("data.bin", "application/octet-stream", &binary),
attachment("Jahresabschluß.pdf", "application/pdf", b"%PDF-1.4"),
],
..Composition::default()
}
.build()
.unwrap();
let edited = DraftEdit {
subject: Some("Grüße (v2)".to_string()),
body: Some("Hello again.\n".to_string()),
..DraftEdit::default()
}
.apply(&original)
.unwrap();
let before = parse(&original);
let after = parse(&edited.raw);
assert_eq!(after.subject(), Some("Grüße (v2)"));
assert_eq!(after.body_text(0).as_deref(), Some("Hello again.\r\n"));
assert_eq!(after.attachment_count(), before.attachment_count());
for (a, b) in after.attachments().zip(before.attachments()) {
assert_eq!(a.attachment_name(), b.attachment_name());
assert_eq!(a.contents(), b.contents());
assert_eq!(
a.raw_body_offset() - a.raw_header_offset(),
b.raw_body_offset() - b.raw_header_offset()
);
}
assert_eq!(
after.to().unwrap().first().unwrap().name(),
Some("Zoë Ångström")
);
assert_eq!(after.date(), before.date());
assert_eq!(after.message_id(), before.message_id());
}
fn mixed(parts: &[&str], epilogue: &str) -> Vec<u8> {
let mut raw = String::from(
"Subject: x\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b\r\n\r\n",
);
for part in parts {
raw.push_str("--b\r\n");
raw.push_str(part);
raw.push_str("\r\n");
}
raw.push_str("--b--\r\n");
raw.push_str(epilogue);
raw.into_bytes()
}
const TEXT_BODY: &str = "Content-Type: text/plain\r\n\r\nHello.";
fn named(name: &str, data: &str) -> String {
format!(
"Content-Type: application/octet-stream\r\nContent-Disposition: attachment; \
filename=\"{name}\"\r\n\r\n{data}"
)
}
fn remove(raw: &[u8], name: &str) -> Result<EditedMessage> {
DraftEdit {
remove_attachments: vec![name.to_string()],
..DraftEdit::default()
}
.apply(raw)
}
#[test]
fn duplicate_names_are_removed_one_at_a_time_by_their_shown_names() {
let raw = mixed(
&[
TEXT_BODY,
&named("image.png", "first"),
&named("image.png", "second"),
],
"",
);
let edited = remove(&raw, "image-1.png").unwrap();
assert!(contains(&edited.raw, "first"));
assert!(!contains(&edited.raw, "second"));
let edited = remove(&raw, "image.png").unwrap();
assert!(!contains(&edited.raw, "first"));
assert!(contains(&edited.raw, "second"));
}
#[test]
fn a_stored_name_works_only_when_it_is_unambiguous() {
let raw = mixed(&[TEXT_BODY, &named("a/b.pdf", "only")], "");
assert!(!contains(&remove(&raw, "b.pdf").unwrap().raw, "only"));
assert!(!contains(&remove(&raw, "a/b.pdf").unwrap().raw, "only"));
let raw = mixed(
&[
TEXT_BODY,
&named("x/r.pdf", "one"),
&named("y/r.pdf", "two"),
],
"",
);
let edited = remove(&raw, "y/r.pdf").unwrap();
assert!(contains(&edited.raw, "one"));
assert!(!contains(&edited.raw, "two"));
let err = remove(&raw, "zzz").unwrap_err().to_string();
assert!(err.contains("\"r.pdf\", \"r-1.pdf\""), "{err}");
}
#[test]
fn two_parts_with_the_same_stored_name_need_their_shown_names() {
let a = "Content-Type: application/pdf; name=\"r.pdf\"\r\n\r\none";
let b = "Content-Type: application/pdf; name=\"x/r.pdf\"\r\n\r\ntwo";
let raw = mixed(&[TEXT_BODY, a, b, &named("x/r.pdf", "three")], "");
let err = remove(&raw, "x/r.pdf").unwrap_err().to_string();
assert!(
err.starts_with("2 attachments are named \"x/r.pdf\""),
"{err}"
);
assert!(err.contains("\"r-1.pdf\", \"r-2.pdf\""), "{err}");
}
#[test]
fn an_unnamed_attachment_is_removed_by_its_placeholder_name() {
let unnamed = "Content-Type: application/pdf\r\n\r\n%PDF";
let raw = mixed(&[TEXT_BODY, unnamed], "");
let edited = remove(&raw, "attachment-0").unwrap();
assert!(!contains(&edited.raw, "%PDF"));
}
#[test]
fn a_named_text_part_first_is_an_attachment_not_the_body() {
let notes = "Content-Type: text/plain; name=\"notes.txt\"\r\n\r\nmy notes";
let raw = mixed(&[notes], "");
let edited = DraftEdit {
body: Some("Cover note.".to_string()),
..DraftEdit::default()
}
.apply(&raw)
.unwrap();
assert!(contains(&edited.raw, "my notes"));
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("Cover note."));
assert!(!contains(
&remove(&raw, "notes.txt").unwrap().raw,
"my notes"
));
}
#[test]
fn a_draft_that_is_only_an_attachment_keeps_it_when_a_body_is_added() {
let raw = b"Subject: x\r\nContent-Type: application/pdf\r\n\
Content-Disposition: attachment; filename=a.pdf\r\n\r\n%PDF";
let edited = DraftEdit {
body: Some("Cover note.".to_string()),
..DraftEdit::default()
}
.apply(raw)
.unwrap();
assert!(edited.warnings.is_empty(), "{:?}", edited.warnings);
let parsed = parse(&edited.raw);
assert_eq!(parsed.body_text(0).as_deref(), Some("Cover note."));
assert_eq!(
parsed.attachment(0).unwrap().attachment_name(),
Some("a.pdf")
);
assert!(!contains(&remove(raw, "a.pdf").unwrap().raw, "%PDF"));
}
#[test]
fn the_epilogue_survives_a_structural_edit() {
let raw = mixed(&[TEXT_BODY, &named("a.pdf", "x")], "trailer text\r\n");
let edited = remove(&raw, "a.pdf").unwrap();
assert!(!contains(&edited.raw, "multipart"));
let edited = DraftEdit {
attach: vec![attachment("b.pdf", "application/pdf", b"%PDF")],
..DraftEdit::default()
}
.apply(&raw)
.unwrap();
assert!(edited.raw.ends_with(b"--\r\ntrailer text\r\n"));
}
#[test]
fn split_multipart_keeps_the_preamble_and_excludes_delimiter_line_endings() {
let body = b"preamble\r\n--b\r\nA\r\n--b \r\n\r\nB\r\n--b--\r\nepilogue";
assert_eq!(
split_multipart(body, "b").unwrap(),
Multipart {
preamble: b"preamble\r\n",
parts: vec![&b"A"[..], &b"\r\nB"[..]],
epilogue: b"epilogue",
}
);
}
#[test]
fn split_multipart_ignores_lines_that_only_start_with_the_delimiter() {
let body = b"--b\r\n--bx is not a delimiter\r\n--b--";
let parts = split_multipart(body, "b").unwrap().parts;
assert_eq!(parts, [&b"--bx is not a delimiter"[..]]);
}
#[test]
fn split_multipart_tolerates_a_missing_close_delimiter() {
let split = split_multipart(b"--b\nA\n--b\nB\n", "b").unwrap();
assert_eq!(split.parts, [&b"A"[..], &b"B\n"[..]]);
assert!(split.epilogue.is_empty());
assert!(split_multipart(b"no delimiter", "b").is_none());
}
}