restructure source and refactor set_text_body and set_html_body

This commit is contained in:
2025-12-15 18:54:30 +01:00
parent d1944d92d1
commit b5b0f2a14f
7 changed files with 379 additions and 325 deletions
+56
View File
@@ -0,0 +1,56 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
```bash
cargo build # Build the library
cargo build --release # Build with optimizations (LTO enabled)
cargo test # Run all tests
cargo test test_name # Run a specific test
cargo doc --open # Generate and view documentation
```
## Architecture
This is a Rust library for parsing, modifying, and serializing email messages (RFC 5322/MIME). It uses copy-on-write semantics to minimize allocations when working with email data.
### Core Types
- **`CowMail<H>`** (`lib.rs`): Main email message struct. Holds headers, body bytes, and nested subparts for multipart messages. Uses `Cow<[u8]>` for zero-copy parsing with owned modifications.
- **`CowMailReader`** (`lib.rs`): Streaming reader implementing `std::io::Read`. Allows efficient serialization without full memory allocation.
- **`CowHeader`** (`header.rs`): Single email header with copy-on-write storage. Tracks byte indices for key/value positions within raw data.
- **`Header` trait** (`header.rs`): Interface for header implementations including parsing, serialization, and RFC 2045 parameter handling.
- **`HeaderMap` trait** (`headers.rs`): Collection operations for headers - find, iterate, add, replace, remove.
- **`Mail` trait** (`lib.rs`): High-level operations for email bodies - content decoding, content-type detection, multipart handling.
- **`CowMailError`** (`lib.rs`): Error type for mail operations (generic errors, parse errors, missing headers).
- **`BodyType`** (`lib.rs`): Enum for body type selection (HTML, PLAIN).
- **`MessageType`** (`lib.rs`): Enum distinguishing SinglePart from MultiPart messages (with boundary).
- **`ContentType`** (`lib.rs`): Wrapper around `ParsedContentType` with conditional defaults for multipart/digest.
### Key Design Patterns
**Copy-on-Write**: Parsed messages borrow from the original byte slice. Only modified fields become owned. The `serialize()` method returns `Vec<Cow<[u8]>>` to preserve this optimization.
**Multipart Handling**: `CowMail` recursively contains `subparts: Vec<CowMail>`. Methods like `make_mixed()`, `make_alternative()`, and `make_related()` transform single-part messages into multipart containers. The original content becomes the first subpart.
**Body Part Navigation**: `get_body_part(BodyType)` traverses the MIME tree to find text/plain or text/html parts, correctly handling `multipart/alternative`, `multipart/related` with `start` parameter, and attachments. The internal `BodyStatus` enum tracks whether a part is a body, not a body, or needs traversal.
### Content Encoding
Body content is automatically encoded/decoded using the `lettre` and `mailparse` crates. `set_content()` automatically selects transfer encoding (quoted-printable for UTF-8 text) and sets appropriate headers.
### High-Level Body Manipulation
- `set_text_body()` / `set_html_body()` / `set_body()`: Intelligently insert or update body parts, creating `multipart/alternative` structures as needed
- These methods use `get_body_parent_part_mut()` to find the correct insertion point in nested MIME structures
+1 -1
View File
@@ -2,7 +2,7 @@
name = "email-message" name = "email-message"
version = "0.0.1" version = "0.0.1"
authors = ["Thomas Oettli <spacefreak@noop.ch>"] authors = ["Thomas Oettli <spacefreak@noop.ch>"]
edition = "2021" edition = "2024"
[profile.release] [profile.release]
lto = true lto = true
+48
View File
@@ -0,0 +1,48 @@
use std::fmt::Write;
use email_encoding::headers::writer::EmailWriter;
fn allowed_str(s: &str) -> bool {
s.bytes().all(allowed_char)
}
const fn allowed_char(c: u8) -> bool {
c >= 1 && c <= 9 || c == 11 || c == 12 || c >= 14 && c <= 127
}
fn encode(encode_buf: &mut String, writer: &mut EmailWriter) {
if encode_buf.is_empty() {
return;
}
let prefix = encode_buf.trim_end_matches(' ');
email_encoding::headers::rfc2047::encode(prefix, writer).expect("String is always writable");
// TODO: add a better API for doing this in email-encoding
let spaces = encode_buf.len() - prefix.len();
for _ in 0..spaces {
writer.space();
}
encode_buf.clear();
}
pub fn encode_header_value(key: &str, value: &str) -> String {
let mut encoded_value = String::new();
let line_len = key.len() + ": ".len();
let mut writer = EmailWriter::new(&mut encoded_value, line_len, 0, false);
let mut encode_buf = String::new();
for next_word in value.split_inclusive(' ') {
if allowed_str(next_word) {
// This word only contains allowed characters
// the next word is allowed, but we may have accumulated some words to encode
encode(&mut encode_buf, &mut writer);
writer.folding().write_str(next_word).expect("String is always writable");
} else {
// This word contains unallowed characters
encode_buf.push_str(next_word);
}
}
encode(&mut encode_buf, &mut writer);
drop(writer);
encoded_value
}
+2 -2
View File
@@ -1,4 +1,4 @@
use crate::header::{Header, parse_header}; use super::{Header, parse_header};
pub trait HeaderMap<'a, H: Header<'a> + 'a> { pub trait HeaderMap<'a, H: Header<'a> + 'a> {
fn iter_headers(&'a self, key: &str) -> impl Iterator<Item = &'a H>; fn iter_headers(&'a self, key: &str) -> impl Iterator<Item = &'a H>;
@@ -110,7 +110,7 @@ pub fn parse_headers<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(Vec<H>, u
return Err("Headers were followed by an unexpected lone CR character!"); return Err("Headers were followed by an unexpected lone CR character!");
} }
} }
let (header, ix_next) = parse_header(&raw_data[ix..]).unwrap(); let (header, ix_next) = parse_header(&raw_data[ix..])?;
headers.push(header); headers.push(header);
ix += ix_next; ix += ix_next;
} }
+8 -53
View File
@@ -1,60 +1,15 @@
mod encoding;
mod headers;
pub use encoding::encode_header_value;
pub use headers::{HeaderMap, parse_headers};
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{self, Display, Write}; use std::fmt::{self, Display};
use charset::decode_latin1; use charset::decode_latin1;
use email::rfc2045::Rfc2045Parser; use email::{FromHeader, rfc2045::Rfc2045Parser};
use email::FromHeader;
use email_encoding::headers::writer::EmailWriter;
fn allowed_str(s: &str) -> bool {
s.bytes().all(allowed_char)
}
const fn allowed_char(c: u8) -> bool {
c >= 1 && c <= 9 || c == 11 || c == 12 || c >= 14 && c <= 127
}
fn encode(encode_buf: &mut String, writer: &mut EmailWriter) {
if encode_buf.is_empty() {
// nothing to encode
return;
}
let prefix = encode_buf.trim_end_matches(' ');
email_encoding::headers::rfc2047::encode(prefix, writer).expect("String is always writable");
// TODO: add a better API for doing this in email-encoding
let spaces = encode_buf.len() - prefix.len();
for _ in 0..spaces {
writer.space();
}
encode_buf.clear();
}
fn encode_header_value(key: &str, value: &str) -> String {
let mut encoded_value = String::new();
let line_len = key.len() + ": ".len();
let mut writer = EmailWriter::new(&mut encoded_value, line_len, 0, false);
let mut encode_buf = String::new();
for next_word in value.split_inclusive(' ') {
if allowed_str(next_word) {
// This word only contains allowed characters
// the next word is allowed, but we may have accumulated some words to encode
encode(&mut encode_buf, &mut writer);
writer.folding().write_str(next_word).expect("String is always writable");
} else {
// This word contains unallowed characters
encode_buf.push_str(next_word);
}
}
encode(&mut encode_buf, &mut writer);
drop(writer);
encoded_value
}
pub trait Header<'a>: Clone + Display { pub trait Header<'a>: Clone + Display {
fn new<K: ToString, V: ToString>(key: K, value: V) -> Self; fn new<K: ToString, V: ToString>(key: K, value: V) -> Self;
+102 -271
View File
@@ -1,21 +1,22 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod header; pub mod header;
pub mod headers;
use header::CowHeader;
pub use header::Header;
use headers::parse_headers;
pub use headers::HeaderMap;
use lettre::message::header::ContentTransferEncoding; #[cfg(test)]
mod tests;
use header::CowHeader;
pub use header::{Header, HeaderMap, parse_headers};
use lettre::message::MaybeString; use lettre::message::MaybeString;
use lettre::message::header::ContentTransferEncoding;
use mailparse::{ use mailparse::{
body::Body, parse_content_disposition, parse_content_type, DispositionType, MailParseError, DispositionType, MailParseError, ParsedContentDisposition, ParsedContentType, body::Body,
ParsedContentDisposition, ParsedContentType, parse_content_disposition, parse_content_type,
}; };
pub use mediatype::{names, values, MediaType}; pub use mediatype::{MediaType, names, values};
use mediatype::{names::*, values::UTF_8, MediaTypeBuf, Name, ReadParams, WriteParams}; use mediatype::{MediaTypeBuf, Name, ReadParams, WriteParams, names::*, values::UTF_8};
use rand::{distr::Alphanumeric, rng, Rng as _}; use rand::{Rng as _, distr::Alphanumeric, rng};
use std::borrow::Cow; use std::borrow::Cow;
use std::boxed::Box; use std::boxed::Box;
use std::ops::Deref; use std::ops::Deref;
@@ -131,11 +132,11 @@ pub trait Mail<'a, T: Header<'a> + 'a>: HeaderMap<'a, T> {
match ctype.mimetype.parse::<MediaTypeBuf>() { match ctype.mimetype.parse::<MediaTypeBuf>() {
Ok(mtype) => { Ok(mtype) => {
let mut res = MessageType::SinglePart; let mut res = MessageType::SinglePart;
if mtype.ty() == MULTIPART { if mtype.ty() == MULTIPART
if let Some(boundary) = ctype.params.get("boundary") { && let Some(boundary) = ctype.params.get("boundary")
{
res = MessageType::MultiPart(boundary.clone()); res = MessageType::MultiPart(boundary.clone());
} }
}
res res
} }
Err(_) => MessageType::SinglePart, Err(_) => MessageType::SinglePart,
@@ -194,7 +195,7 @@ pub fn generate_boundary() -> String {
} }
/// Result of checking whether a MIME part is a text body (plain/html). /// Result of checking whether a MIME part is a text body (plain/html).
enum BodyStatus { enum BodyCheckStatus {
/// This part is a text/plain or text/html body (not an attachment) /// This part is a text/plain or text/html body (not an attachment)
IsBody, IsBody,
/// This is a leaf node but not a body (attachment, other content type, etc.) /// This is a leaf node but not a body (attachment, other content type, etc.)
@@ -300,14 +301,14 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
} }
/// Check if this MIME part is a text body (plain/html), not a body, or needs traversal. /// Check if this MIME part is a text body (plain/html), not a body, or needs traversal.
fn body_status(&self, bodytype: Option<BodyType>) -> BodyStatus { fn check_body_status(&self, bodytype: Option<BodyType>) -> BodyCheckStatus {
match self.is_multipart() { match self.is_multipart() {
true if self.subparts.is_empty() => BodyStatus::NotBody, true if self.subparts.is_empty() => BodyCheckStatus::NotBody,
true => BodyStatus::NeedsTraversal, true => BodyCheckStatus::NeedsTraversal,
false => { false => {
let mime = self.get_mime_type(); let mime = self.get_mime_type();
if mime.ty() != TEXT || self.is_attachment() { if mime.ty() != TEXT || self.is_attachment() {
return BodyStatus::NotBody; return BodyCheckStatus::NotBody;
} }
let body_types = match bodytype { let body_types = match bodytype {
@@ -316,8 +317,8 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
}; };
match body_types.contains(&mime.subty()) { match body_types.contains(&mime.subty()) {
true => BodyStatus::IsBody, true => BodyCheckStatus::IsBody,
false => BodyStatus::NotBody, false => BodyCheckStatus::NotBody,
} }
} }
} }
@@ -335,30 +336,30 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
} }
pub fn get_body_part(&self, bodytype: BodyType) -> Option<&CowMail<'a, H>> { pub fn get_body_part(&self, bodytype: BodyType) -> Option<&CowMail<'a, H>> {
match self.body_status(Some(bodytype)) { match self.check_body_status(Some(bodytype)) {
BodyStatus::IsBody => Some(self), BodyCheckStatus::IsBody => Some(self),
BodyStatus::NotBody => None, BodyCheckStatus::NotBody => None,
BodyStatus::NeedsTraversal => { BodyCheckStatus::NeedsTraversal => {
if self.get_mime_type().subty() == RELATED { if self.get_mime_type().subty() == RELATED
if let Some(candidate) = self.get_related_candidate() { && let Some(candidate) = self.get_related_candidate()
{
return self.subparts[candidate].get_body_part(bodytype); return self.subparts[candidate].get_body_part(bodytype);
} }
}
self.subparts.iter().find_map(|p| p.get_body_part(bodytype)) self.subparts.iter().find_map(|p| p.get_body_part(bodytype))
} }
} }
} }
pub fn get_body_part_mut(&mut self, bodytype: BodyType) -> Option<&mut CowMail<'a, H>> { pub fn get_body_part_mut(&mut self, bodytype: BodyType) -> Option<&mut CowMail<'a, H>> {
match self.body_status(Some(bodytype)) { match self.check_body_status(Some(bodytype)) {
BodyStatus::IsBody => Some(self), BodyCheckStatus::IsBody => Some(self),
BodyStatus::NotBody => None, BodyCheckStatus::NotBody => None,
BodyStatus::NeedsTraversal => { BodyCheckStatus::NeedsTraversal => {
if self.get_mime_type().subty() == RELATED { if self.get_mime_type().subty() == RELATED
if let Some(candidate) = self.get_related_candidate() { && let Some(candidate) = self.get_related_candidate()
{
return self.subparts[candidate].get_body_part_mut(bodytype); return self.subparts[candidate].get_body_part_mut(bodytype);
} }
}
self.subparts.iter_mut().find_map(|p| p.get_body_part_mut(bodytype)) self.subparts.iter_mut().find_map(|p| p.get_body_part_mut(bodytype))
} }
} }
@@ -467,10 +468,10 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
for i in subpart_indices { for i in subpart_indices {
let subpart = &self.subparts[i]; let subpart = &self.subparts[i];
match subpart.body_status(bodytype) { match subpart.check_body_status(bodytype) {
BodyStatus::IsBody => return Some(path.clone()), BodyCheckStatus::IsBody => return Some(path.clone()),
BodyStatus::NotBody => continue, BodyCheckStatus::NotBody => continue,
BodyStatus::NeedsTraversal => { BodyCheckStatus::NeedsTraversal => {
path.push(i); path.push(i);
match subpart.find_body_parent_path(path, bodytype) { match subpart.find_body_parent_path(path, bodytype) {
None => { None => {
@@ -486,10 +487,10 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
} }
fn get_body_parent_part_mut(&mut self) -> Option<&mut CowMail<'a, H>> { fn get_body_parent_part_mut(&mut self) -> Option<&mut CowMail<'a, H>> {
match self.body_status(None) { match self.check_body_status(None) {
BodyStatus::IsBody => Some(self), BodyCheckStatus::IsBody => Some(self),
BodyStatus::NotBody => None, BodyCheckStatus::NotBody => None,
BodyStatus::NeedsTraversal => match self.find_body_parent_path(&mut Vec::new(), None) { BodyCheckStatus::NeedsTraversal => match self.find_body_parent_path(&mut Vec::new(), None) {
Some(path) => { Some(path) => {
let mut current = self; let mut current = self;
for idx in path { for idx in path {
@@ -502,25 +503,48 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
} }
} }
pub fn set_text_body<T: ToString>(&mut self, content: T) -> Result<(), CowMailError> { fn set_body_part<T: ToString>(&mut self, content: T, body_type: BodyType) -> Result<(), CowMailError> {
if let Some(parent) = self.get_body_parent_part_mut() { let ctype = match body_type {
match parent.get_body_part_mut(BodyType::PLAIN) { BodyType::PLAIN => None,
Some(part) => part.set_content(content, None), BodyType::HTML => Some(MediaType::new(TEXT, HTML)),
None => { };
let mut text_part = Self::new();
text_part.set_content(content, None);
if parent.get_mime_type().subty() == ALTERNATIVE {
parent.subparts.insert(0, text_part);
} else {
let html_part = parent
.get_body_part_mut(BodyType::HTML)
.ok_or(CowMailError::Generic("get_body_parent_part_mut returned a parent without a plain or html body, this should never happen!".into()))?;
html_part.make_alternative(None);
html_part.subparts.insert(0, text_part);
}
}
}
if let Some(parent) = self.get_body_parent_part_mut() {
match parent.get_body_part_mut(body_type) {
Some(part) => part.set_content(content, ctype),
None => {
let mut new_part = Self::new();
new_part.set_content(content, ctype);
let parent_mime = parent.get_mime_type();
let parent_subty = parent_mime.subty();
if parent_subty == ALTERNATIVE {
match body_type {
BodyType::PLAIN => parent.subparts.insert(0, new_part),
BodyType::HTML => parent.subparts.push(new_part),
}
} else if parent_subty == RELATED {
parent.make_alternative(None);
match body_type {
BodyType::PLAIN => parent.subparts.insert(0, new_part),
BodyType::HTML => parent.subparts.push(new_part),
}
} else {
let other_type = match body_type {
BodyType::PLAIN => BodyType::HTML,
BodyType::HTML => BodyType::PLAIN,
};
let other_part = parent.get_body_part_mut(other_type).ok_or(CowMailError::Generic(
"get_body_parent_part_mut returned a parent without a plain or html body".into(),
))?;
other_part.make_alternative(None);
match body_type {
BodyType::PLAIN => other_part.subparts.insert(0, new_part),
BodyType::HTML => other_part.subparts.push(new_part),
}
}
}
}
return Ok(()); return Ok(());
} }
@@ -528,44 +552,19 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
self.make_mixed(None); self.make_mixed(None);
} }
let mut text_part = Self::new(); let mut new_part = Self::new();
text_part.set_content(content, None); new_part.set_content(content, ctype);
self.subparts.insert(0, text_part); self.subparts.insert(0, new_part);
Ok(()) Ok(())
} }
pub fn set_text_body<T: ToString>(&mut self, content: T) -> Result<(), CowMailError> {
self.set_body_part(content, BodyType::PLAIN)
}
pub fn set_html_body<T: ToString>(&mut self, content: T) -> Result<(), CowMailError> { pub fn set_html_body<T: ToString>(&mut self, content: T) -> Result<(), CowMailError> {
if let Some(parent) = self.get_body_parent_part_mut() { self.set_body_part(content, BodyType::HTML)
match parent.get_body_part_mut(BodyType::HTML) {
Some(part) => part.set_content(content, Some(MediaType::new(TEXT, HTML))),
None => {
let mut html_part = Self::new();
html_part.set_content(content, Some(MediaType::new(TEXT, HTML)));
if parent.get_mime_type().subty() == ALTERNATIVE {
parent.subparts.push(html_part);
} else {
let text_part = parent
.get_body_part_mut(BodyType::PLAIN)
.ok_or(CowMailError::Generic("get_body_parent_part_mut returned a parent without a plain or html body, this should never happen!".into()))?;
text_part.make_alternative(None);
text_part.subparts.push(html_part);
}
}
}
return Ok(());
}
if !self.is_multipart() {
self.make_mixed(None);
}
let mut html_part = Self::new();
html_part.set_content(content, Some(MediaType::new(TEXT, HTML)));
self.subparts.insert(0, html_part);
Ok(())
} }
pub fn set_body<T: ToString>(&mut self, text: T, html: T) -> Result<(), CowMailError> { pub fn set_body<T: ToString>(&mut self, text: T, html: T) -> Result<(), CowMailError> {
@@ -924,19 +923,6 @@ fn parse_mail_recursive<'a, H: Header<'a> + 'a>(
Ok(mail) Ok(mail)
} }
// Like find_from_u8, but additionally filters such that `key` is at the start
// of a line (preceded by `\n`) or at the start of the search space.
fn find_from_u8_line_prefix(line: &[u8], ix_start: usize, key: &[u8]) -> Option<usize> {
let mut start = ix_start;
while let Some(ix) = find_from_u8(line, start, key) {
if ix == ix_start || line[ix - 1] == b'\n' {
return Some(ix);
}
start = ix + 1;
}
None
}
fn find_from_u8(line: &[u8], ix_start: usize, key: &[u8]) -> Option<usize> { fn find_from_u8(line: &[u8], ix_start: usize, key: &[u8]) -> Option<usize> {
assert!(!key.is_empty()); assert!(!key.is_empty());
assert!(ix_start < line.len()); assert!(ix_start < line.len());
@@ -963,170 +949,15 @@ fn find_from_u8(line: &[u8], ix_start: usize, key: &[u8]) -> Option<usize> {
None None
} }
#[cfg(test)] /// Like find_from_u8, but additionally filters such that `key` is at the start
mod tests { /// of a line (preceded by `\n`) or at the start of the search space.
//use std::io::{Read, Write}; fn find_from_u8_line_prefix(line: &[u8], ix_start: usize, key: &[u8]) -> Option<usize> {
use crate::{names::HTML, names::TEXT, CowHeader, CowMail, Header, HeaderMap, Mail, MediaType}; let mut start = ix_start;
while let Some(ix) = find_from_u8(line, start, key) {
#[test] if ix == ix_start || line[ix - 1] == b'\n' {
fn test_header_mutations() { return Some(ix);
let mut mail = CowMail::<CowHeader>::new();
assert_eq!(mail.headers.len(), 0);
let test_headers = vec![
("X-Test", "Test"),
("X-Another-Test", "Another"),
("X-Test", "Test2"),
("X-Another-Test", "Another1"),
("X-Test", "Test3"),
];
for (key, value) in test_headers.iter() {
mail.add_header(key, value);
} }
start = ix + 1;
assert_eq!(mail.headers.len(), test_headers.len());
for (idx, header) in mail.headers.iter().enumerate() {
assert_eq!(header.get_key(), test_headers[idx].0);
assert_eq!(header.get_value(), test_headers[idx].1);
}
mail.insert_header("X-Test", "Test1", 1);
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test", "Test1", "Test2", "Test3"]
);
mail.replace_all_headers("X-Test", "Test555");
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test555", "Test555", "Test555", "Test555"]
);
mail.replace_header("X-Test", "Test", 0);
mail.replace_header("X-Test", "Test1", 1);
mail.replace_header("X-Test", "Test2", 2);
mail.replace_header("X-Test", "Test3", 3);
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test", "Test1", "Test2", "Test3"]
);
mail.remove_header("X-Test", 1);
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test", "Test2", "Test3"]
);
mail.remove_all_headers("X-Test");
assert_eq!(mail.headers.len(), 2);
}
#[test]
fn test_body_mutations() {
let mut mail =
CowMail::try_from(concat!("Content-Type: text/plain\n", "\n", "This is a test message").as_bytes())
.unwrap();
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "text/plain");
assert_eq!(ctype.charset, "us-ascii");
assert_eq!(mail.is_multipart(), false);
assert_eq!(mail.get_content().unwrap(), "This is a test message");
mail.clear_content();
assert!(mail.find_header("Content-Type").is_none());
assert_eq!(mail.get_content().unwrap(), "");
mail.set_content("This is a new test message ❤\n".to_string(), None);
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "text/plain");
assert_eq!(ctype.charset, "UTF-8");
mail.clear_content();
assert_eq!(mail.get_content().unwrap(), "");
mail.set_content("This is a new test message\n".to_string(), None);
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "text/plain");
assert_eq!(ctype.charset, "us-ascii");
mail.add_header("From", "\"John Doe\" <john.doe@example.com>");
let mut html = CowMail::new();
html.set_content(
"<html><body>This is a new test message ☺</body></html>".to_string(),
Some(MediaType::new(TEXT, HTML)),
);
mail.add_alternative(html);
let alt_boundary = mail.get_boundary().unwrap();
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "multipart/alternative");
assert_eq!(ctype.params.get("boundary").unwrap(), &alt_boundary);
assert_eq!(
mail.find_header("Content-Type").unwrap().get_value(),
format!("multipart/alternative; boundary=\"{}\"", alt_boundary)
);
let mut attachment = CowMail::new();
attachment.set_content("This is a test text file".to_string(), None);
let mut disp = CowHeader::new("Content-Disposition", "attachment");
disp.set_param("filename", "Test☺File.html");
attachment.headers.push(disp);
mail.add_attachment(attachment);
let mixed_boundary = mail.get_boundary().unwrap();
let encoded = mail
.serialize()
.iter()
.flat_map(|p| p.into_iter())
.cloned()
.collect::<Vec<u8>>();
let expected = format!(
"From: \"John Doe\" <john.doe@example.com>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed;\r\n boundary=\"{mixed_boundary}\"\r\n\
\r\n\
--{mixed_boundary}\r\n\
Content-Type: multipart/alternative;\r\n boundary=\"{alt_boundary}\"\r\n\
\r\n\
--{alt_boundary}\r\n\
Content-Type: text/plain\r\n\
\r\n\
This is a new test message\r\n\
\r\n\
--{alt_boundary}\r\n\
Content-Type: text/html; charset=UTF-8\r\n\
Content-Transfer-Encoding: quoted-printable\r\n\
\r\n\
<html><body>This is a new test message =E2=98=BA</body></html>\r\n\
--{alt_boundary}--\r\n\
--{mixed_boundary}\r\n\
Content-Type: text/plain\r\n\
Content-Disposition: attachment;\r\n filename=\"=?utf-8?b?VGVzdOKYukZpbGUuaHRtbA==?=\"\r\n\
\r\n\
This is a test text file\r\n\
--{mixed_boundary}--\r\n"
);
let expected = expected.into_bytes();
assert_eq!(encoded, expected);
/*
let mut f = std::fs::File::open("/tmp/test.eml").unwrap();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
let mail = CowMail::try_from(&data[..]).unwrap();
let mut serialized = mail
.serialize()
.iter()
.flat_map(|p| p.into_iter())
.cloned()
.collect::<Vec<u8>>();
assert_eq!(data, serialized);
let mut f = std::fs::File::create("/tmp/test1.eml").unwrap();
f.write_all(&mut serialized).unwrap();
*/
} }
None
} }
+164
View File
@@ -0,0 +1,164 @@
//use std::io::{Read, Write};
use crate::{CowHeader, CowMail, Header, HeaderMap, Mail, MediaType, names::HTML, names::TEXT};
#[test]
fn test_header_mutations() {
let mut mail = CowMail::<CowHeader>::new();
assert_eq!(mail.headers.len(), 0);
let test_headers = vec![
("X-Test", "Test"),
("X-Another-Test", "Another"),
("X-Test", "Test2"),
("X-Another-Test", "Another1"),
("X-Test", "Test3"),
];
for (key, value) in test_headers.iter() {
mail.add_header(key, value);
}
assert_eq!(mail.headers.len(), test_headers.len());
for (idx, header) in mail.headers.iter().enumerate() {
assert_eq!(header.get_key(), test_headers[idx].0);
assert_eq!(header.get_value(), test_headers[idx].1);
}
mail.insert_header("X-Test", "Test1", 1);
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test", "Test1", "Test2", "Test3"]
);
mail.replace_all_headers("X-Test", "Test555");
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test555", "Test555", "Test555", "Test555"]
);
mail.replace_header("X-Test", "Test", 0);
mail.replace_header("X-Test", "Test1", 1);
mail.replace_header("X-Test", "Test2", 2);
mail.replace_header("X-Test", "Test3", 3);
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test", "Test1", "Test2", "Test3"]
);
mail.remove_header("X-Test", 1);
assert_eq!(
mail.iter_values("X-Test").collect::<Vec<_>>(),
["Test", "Test2", "Test3"]
);
mail.remove_all_headers("X-Test");
assert_eq!(mail.headers.len(), 2);
}
#[test]
fn test_body_mutations() {
let mut mail =
CowMail::try_from(concat!("Content-Type: text/plain\n", "\n", "This is a test message").as_bytes())
.unwrap();
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "text/plain");
assert_eq!(ctype.charset, "us-ascii");
assert_eq!(mail.is_multipart(), false);
assert_eq!(mail.get_content().unwrap(), "This is a test message");
mail.clear_content();
assert!(mail.find_header("Content-Type").is_none());
assert_eq!(mail.get_content().unwrap(), "");
mail.set_content("This is a new test message ❤\n".to_string(), None);
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "text/plain");
assert_eq!(ctype.charset, "UTF-8");
mail.clear_content();
assert_eq!(mail.get_content().unwrap(), "");
mail.set_content("This is a new test message\n".to_string(), None);
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "text/plain");
assert_eq!(ctype.charset, "us-ascii");
mail.add_header("From", "\"John Doe\" <john.doe@example.com>");
let mut html = CowMail::new();
html.set_content(
"<html><body>This is a new test message ☺</body></html>".to_string(),
Some(MediaType::new(TEXT, HTML)),
);
mail.add_alternative(html);
let alt_boundary = mail.get_boundary().unwrap();
let ctype = mail.content_type();
assert_eq!(ctype.mimetype, "multipart/alternative");
assert_eq!(ctype.params.get("boundary").unwrap(), &alt_boundary);
assert_eq!(
mail.find_header("Content-Type").unwrap().get_value(),
format!("multipart/alternative; boundary=\"{}\"", alt_boundary)
);
let mut attachment = CowMail::new();
attachment.set_content("This is a test text file".to_string(), None);
let mut disp = CowHeader::new("Content-Disposition", "attachment");
disp.set_param("filename", "Test☺File.html");
attachment.headers.push(disp);
mail.add_attachment(attachment);
let mixed_boundary = mail.get_boundary().unwrap();
let encoded = mail
.serialize()
.iter()
.flat_map(|p| p.into_iter())
.cloned()
.collect::<Vec<u8>>();
let expected = format!(
"From: \"John Doe\" <john.doe@example.com>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed;\r\n boundary=\"{mixed_boundary}\"\r\n\
\r\n\
--{mixed_boundary}\r\n\
Content-Type: multipart/alternative;\r\n boundary=\"{alt_boundary}\"\r\n\
\r\n\
--{alt_boundary}\r\n\
Content-Type: text/plain\r\n\
\r\n\
This is a new test message\r\n\
\r\n\
--{alt_boundary}\r\n\
Content-Type: text/html; charset=UTF-8\r\n\
Content-Transfer-Encoding: quoted-printable\r\n\
\r\n\
<html><body>This is a new test message =E2=98=BA</body></html>\r\n\
--{alt_boundary}--\r\n\
--{mixed_boundary}\r\n\
Content-Type: text/plain\r\n\
Content-Disposition: attachment;\r\n filename=\"=?utf-8?b?VGVzdOKYukZpbGUuaHRtbA==?=\"\r\n\
\r\n\
This is a test text file\r\n\
--{mixed_boundary}--\r\n"
);
let expected = expected.into_bytes();
assert_eq!(encoded, expected);
/*
let mut f = std::fs::File::open("/tmp/test.eml").unwrap();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
let mail = CowMail::try_from(&data[..]).unwrap();
let mut serialized = mail
.serialize()
.iter()
.flat_map(|p| p.into_iter())
.cloned()
.collect::<Vec<u8>>();
assert_eq!(data, serialized);
let mut f = std::fs::File::create("/tmp/test1.eml").unwrap();
f.write_all(&mut serialized).unwrap();
*/
}