use base64::Engine;
use chrono::Utc;
use uuid::Uuid;
pub struct MimeInputs<'a> {
pub subject: &'a str,
pub text: Option<&'a str>,
pub html: Option<&'a str>,
}
pub fn build_message(input: &MimeInputs<'_>) -> String {
let mut headers = String::new();
headers.push_str(&format!("Date: {}\r\n", rfc2822_now()));
headers.push_str(&format!(
"Message-ID: <{}@fakecloud.local>\r\n",
Uuid::new_v4().simple()
));
headers.push_str(&format!("Subject: {}\r\n", encode_header(input.subject)));
headers.push_str("MIME-Version: 1.0\r\n");
match (input.text, input.html) {
(Some(text), Some(html)) => {
let boundary = format!("=_fakecloud_{}", Uuid::new_v4().simple());
headers.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n",
boundary
));
let mut body = String::new();
push_part(&mut body, &boundary, "text/plain; charset=UTF-8", text);
push_part(&mut body, &boundary, "text/html; charset=UTF-8", html);
body.push_str(&format!("--{}--\r\n", boundary));
headers + &body
}
(None, Some(html)) => single_part(headers, "text/html; charset=UTF-8", html),
(Some(text), None) => single_part(headers, "text/plain; charset=UTF-8", text),
(None, None) => single_part(headers, "text/plain; charset=UTF-8", ""),
}
}
fn single_part(mut headers: String, content_type: &str, body: &str) -> String {
let (encoded_body, encoding) = encode_body(body);
headers.push_str(&format!("Content-Type: {}\r\n", content_type));
headers.push_str(&format!("Content-Transfer-Encoding: {}\r\n\r\n", encoding));
headers.push_str(&encoded_body);
headers
}
fn push_part(out: &mut String, boundary: &str, content_type: &str, body: &str) {
let (encoded_body, encoding) = encode_body(body);
out.push_str(&format!("--{}\r\n", boundary));
out.push_str(&format!("Content-Type: {}\r\n", content_type));
out.push_str(&format!("Content-Transfer-Encoding: {}\r\n\r\n", encoding));
out.push_str(&encoded_body);
if !encoded_body.ends_with("\r\n") {
out.push_str("\r\n");
}
}
fn encode_body(body: &str) -> (String, &'static str) {
if body.is_ascii() {
(normalize_crlf(body), "7bit")
} else {
(quoted_printable_encode(body), "quoted-printable")
}
}
fn normalize_crlf(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\r' => {
out.push_str("\r\n");
if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
i += 2;
} else {
i += 1;
}
}
b'\n' => {
out.push_str("\r\n");
i += 1;
}
b => {
out.push(b as char);
i += 1;
}
}
}
out
}
fn quoted_printable_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut line_len = 0;
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
let byte = bytes[i];
if byte == b'\r' || byte == b'\n' {
out.push_str("\r\n");
line_len = 0;
if byte == b'\r' && i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
i += 2;
} else {
i += 1;
}
continue;
}
let needs_encoding = matches!(byte, 0..=31 | 61 | 127..=255) && byte != b'\t';
let chunk: String = if needs_encoding {
format!("={:02X}", byte)
} else {
(byte as char).to_string()
};
if line_len + chunk.len() > 75 {
out.push_str("=\r\n");
line_len = 0;
}
out.push_str(&chunk);
line_len += chunk.len();
i += 1;
}
out
}
fn encode_header(value: &str) -> String {
if value.is_ascii() {
value.to_string()
} else {
let b64 = base64::engine::general_purpose::STANDARD.encode(value.as_bytes());
format!("=?UTF-8?B?{}?=", b64)
}
}
fn rfc2822_now() -> String {
Utc::now().format("%a, %d %b %Y %H:%M:%S +0000").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_text_only_uses_7bit() {
let mime = build_message(&MimeInputs {
subject: "hello",
text: Some("plain body"),
html: None,
});
assert!(mime.contains("Subject: hello\r\n"));
assert!(mime.contains("Content-Type: text/plain; charset=UTF-8\r\n"));
assert!(mime.contains("Content-Transfer-Encoding: 7bit\r\n"));
assert!(mime.contains("plain body"));
assert!(mime.contains("Date: "));
assert!(mime.contains("Message-ID: <"));
}
#[test]
fn ascii_html_only_uses_html_part() {
let mime = build_message(&MimeInputs {
subject: "hi",
text: None,
html: Some("<p>x</p>"),
});
assert!(mime.contains("Content-Type: text/html; charset=UTF-8\r\n"));
assert!(mime.contains("<p>x</p>"));
}
#[test]
fn both_parts_use_multipart_alternative() {
let mime = build_message(&MimeInputs {
subject: "hi",
text: Some("plain"),
html: Some("<p>x</p>"),
});
assert!(mime.contains("multipart/alternative; boundary=\"=_fakecloud_"));
assert!(mime.contains("Content-Type: text/plain; charset=UTF-8\r\n"));
assert!(mime.contains("Content-Type: text/html; charset=UTF-8\r\n"));
assert!(mime.contains("plain"));
assert!(mime.contains("<p>x</p>"));
}
#[test]
fn non_ascii_subject_uses_encoded_word() {
let mime = build_message(&MimeInputs {
subject: "héllo",
text: Some("body"),
html: None,
});
assert!(mime.contains("Subject: =?UTF-8?B?"));
}
#[test]
fn non_ascii_body_uses_quoted_printable() {
let mime = build_message(&MimeInputs {
subject: "x",
text: Some("café"),
html: None,
});
assert!(mime.contains("Content-Transfer-Encoding: quoted-printable\r\n"));
assert!(mime.contains("=C3=A9"));
}
#[test]
fn quoted_printable_emits_crlf_for_newlines_not_0a() {
let qp = quoted_printable_encode("café\nlatte");
assert!(qp.contains("=C3=A9\r\nlatte"));
assert!(!qp.contains("=0A"));
}
#[test]
fn quoted_printable_collapses_crlf_lf_cr_to_canonical_crlf() {
let qp = quoted_printable_encode("a\r\nb\nc\rdé");
assert_eq!(qp, "a\r\nb\r\nc\r\nd=C3=A9");
}
#[test]
fn normalize_crlf_does_not_double_existing_crlf() {
assert_eq!(normalize_crlf("a\r\nb"), "a\r\nb");
assert_eq!(normalize_crlf("a\nb"), "a\r\nb");
assert_eq!(normalize_crlf("a\rb"), "a\r\nb");
assert_eq!(normalize_crlf("a\r\n\r\nb"), "a\r\n\r\nb");
assert_eq!(normalize_crlf("a\n\nb"), "a\r\n\r\nb");
}
}