use base64::{Engine, engine::general_purpose::STANDARD};
use mail_parser::{MessageParser, MimeHeaders, PartType};
use rand::{RngCore, rngs::OsRng};
use serde::{Deserialize, Serialize};
use crate::{Error, Result, keys::hex};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mailbox {
#[serde(default)]
pub name: Option<String>,
pub email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attachment {
pub name: String,
#[serde(rename = "type")]
pub media_type: String,
pub data: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Draft {
pub from: Option<Mailbox>,
#[serde(default)]
pub to: Vec<Mailbox>,
#[serde(default)]
pub cc: Vec<Mailbox>,
#[serde(default)]
pub reply_to: Vec<Mailbox>,
#[serde(default)]
pub subject: String,
pub message_id: String,
#[serde(default)]
pub in_reply_to: Vec<String>,
#[serde(default)]
pub references: Vec<String>,
pub date: String,
#[serde(default)]
pub text: String,
pub html: Option<String>,
#[serde(default)]
pub attachments: Vec<Attachment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Part {
pub index: usize,
pub name: Option<String>,
#[serde(rename = "type")]
pub media_type: String,
pub size: usize,
pub cid: Option<String>,
pub inline: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Parsed {
pub subject: Option<String>,
pub from: Vec<Mailbox>,
pub to: Vec<Mailbox>,
pub cc: Vec<Mailbox>,
pub reply_to: Vec<Mailbox>,
pub sent_at: Option<String>,
pub message_id: Option<String>,
pub in_reply_to: Vec<String>,
pub references: Vec<String>,
pub text: Option<String>,
pub html: Option<String>,
pub attachments: Vec<Part>,
}
pub fn build(draft: &Draft) -> Result<Vec<u8>> {
let from = draft.from.as_ref().ok_or(Error::NotMail)?;
let mut out = String::new();
header(&mut out, "From", &addresses(std::slice::from_ref(from))?);
if !draft.to.is_empty() {
header(&mut out, "To", &addresses(&draft.to)?);
}
if !draft.cc.is_empty() {
header(&mut out, "Cc", &addresses(&draft.cc)?);
}
if !draft.reply_to.is_empty() {
header(&mut out, "Reply-To", &addresses(&draft.reply_to)?);
}
header(&mut out, "Subject", &words(&draft.subject));
header(&mut out, "Date", &plain(&draft.date)?);
header(&mut out, "Message-ID", &format!("<{}>", plain(&draft.message_id)?));
if !draft.in_reply_to.is_empty() {
header(&mut out, "In-Reply-To", &ids(&draft.in_reply_to)?);
}
if !draft.references.is_empty() {
header(&mut out, "References", &ids(&draft.references)?);
}
out.push_str("MIME-Version: 1.0\r\n");
let text = body("text/plain; charset=utf-8", None, draft.text.as_bytes());
let content = match &draft.html {
Some(html) => multipart("alternative", &[text, body("text/html; charset=utf-8", None, html.as_bytes())]),
None => text,
};
let message = if draft.attachments.is_empty() {
content
} else {
let mut parts = vec![content];
for attachment in &draft.attachments {
let data = STANDARD.decode(&attachment.data).map_err(|_| Error::NotMail)?;
let kind = plain(&attachment.media_type)?;
parts.push(body(&kind, Some(&attachment.name), &data));
}
multipart("mixed", &parts)
};
out.push_str(&message);
Ok(out.into_bytes())
}
pub fn parse(raw: &[u8]) -> Result<Parsed> {
let message = MessageParser::default().parse(raw).ok_or(Error::NotMail)?;
let list = |address: Option<&mail_parser::Address<'_>>| -> Vec<Mailbox> {
address
.map(|a| {
a.iter()
.filter_map(|m| {
m.address().map(|email| Mailbox {
name: m.name().map(str::to_owned).filter(|n| !n.is_empty()),
email: email.to_owned(),
})
})
.collect()
})
.unwrap_or_default()
};
let ids = |value: &mail_parser::HeaderValue<'_>| -> Vec<String> {
value.as_text_list().map(|l| l.iter().map(|s| s.to_string()).collect()).unwrap_or_default()
};
let attachments = message
.attachments()
.map(|part| Part {
index: message.parts.iter().position(|p| std::ptr::eq(p, part)).unwrap_or(0),
name: part.attachment_name().map(str::to_owned),
media_type: part
.content_type()
.map(|t| format!("{}/{}", t.ctype(), t.subtype().unwrap_or("octet-stream")))
.unwrap_or_else(|| "application/octet-stream".to_owned()),
size: part.contents().len(),
cid: part.content_id().map(str::to_owned),
inline: part.content_disposition().is_some_and(|d| d.ctype().eq_ignore_ascii_case("inline")),
})
.collect();
Ok(Parsed {
subject: message.subject().map(str::to_owned),
from: list(message.from()),
to: list(message.to()),
cc: list(message.cc()),
reply_to: list(message.reply_to()),
sent_at: message.date().map(|d| d.to_rfc3339()),
message_id: message.message_id().map(str::to_owned),
in_reply_to: ids(message.in_reply_to()),
references: ids(message.references()),
text: message.body_text(0).map(|t| t.into_owned()),
html: message.body_html(0).filter(|_| !message.html_body.is_empty()).map(|h| h.into_owned()),
attachments,
})
}
pub fn part(raw: &[u8], index: usize) -> Result<Vec<u8>> {
let message = MessageParser::default().parse(raw).ok_or(Error::NotMail)?;
let part = message.parts.get(index).ok_or(Error::NotMail)?;
match &part.body {
PartType::Multipart(_) => Err(Error::NotMail),
_ => Ok(part.contents().to_vec()),
}
}
fn header(out: &mut String, name: &str, value: &str) {
let mut line = format!("{name}:");
let mut length = line.len();
for word in value.split(' ') {
if length + 1 + word.len() > 76 && length > name.len() + 1 {
line.push_str("\r\n");
length = 0;
}
line.push(' ');
line.push_str(word);
length += 1 + word.len();
}
out.push_str(&line);
out.push_str("\r\n");
}
fn plain(text: &str) -> Result<String> {
if text.is_ascii() && !text.contains(['\r', '\n']) && !text.trim().is_empty() {
Ok(text.trim().to_owned())
} else {
Err(Error::NotMail)
}
}
fn words(text: &str) -> String {
let text = text.replace(['\r', '\n'], " ");
if text.bytes().all(|b| (0x20..0x7f).contains(&b)) && !text.contains("=?") {
return text;
}
let mut words = Vec::new();
let mut chunk = String::new();
for c in text.chars() {
if chunk.len() + c.len_utf8() > 45 {
words.push(format!("=?utf-8?b?{}?=", STANDARD.encode(&chunk)));
chunk.clear();
}
chunk.push(c);
}
if !chunk.is_empty() {
words.push(format!("=?utf-8?b?{}?=", STANDARD.encode(&chunk)));
}
words.join(" ")
}
fn addresses(list: &[Mailbox]) -> Result<String> {
let mut out = Vec::new();
for mailbox in list {
let email = plain(&mailbox.email)?;
if !email.contains('@') || email.contains([' ', '<', '>', ',', '"']) {
return Err(Error::NotMail);
}
match mailbox.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
Some(name) if name.bytes().all(|b| b.is_ascii_alphanumeric() || b" !#$%&'*+-/=?^_`{|}~".contains(&b)) => {
out.push(format!("{name} <{email}>"));
}
Some(name) if name.is_ascii() && !name.contains(['\r', '\n']) => {
out.push(format!("\"{}\" <{email}>", name.replace('\\', "\\\\").replace('"', "\\\"")));
}
Some(name) => out.push(format!("{} <{email}>", words(name))),
None => out.push(email),
}
}
Ok(out.join(", "))
}
fn ids(list: &[String]) -> Result<String> {
list.iter().map(|id| plain(id).map(|id| format!("<{id}>"))).collect::<Result<Vec<_>>>().map(|l| l.join(" "))
}
fn body(content_type: &str, file: Option<&str>, data: &[u8]) -> String {
let mut out = format!("Content-Type: {content_type}");
if let Some(name) = file {
if name.is_ascii() {
out.push_str(&format!("; name=\"{}\"", ascii_name(name)));
} else {
out.push_str(&format!("; name=\"{}\"", words(name)));
}
}
out.push_str("\r\nContent-Transfer-Encoding: base64\r\n");
if let Some(name) = file {
if name.is_ascii() {
out.push_str(&format!("Content-Disposition: attachment; filename=\"{}\"\r\n", ascii_name(name)));
} else {
out.push_str(&format!("Content-Disposition: attachment; filename*=utf-8''{}\r\n", percent(name)));
}
}
out.push_str("\r\n");
let encoded = STANDARD.encode(data);
for line in encoded.as_bytes().chunks(76) {
out.push_str(std::str::from_utf8(line).unwrap_or_default());
out.push_str("\r\n");
}
out
}
fn multipart(subtype: &str, parts: &[String]) -> String {
let mut random = [0u8; 12];
OsRng.fill_bytes(&mut random);
let boundary = format!("kernoid-{}", hex(&random));
let mut out = format!("Content-Type: multipart/{subtype}; boundary=\"{boundary}\"\r\n\r\n");
for part in parts {
out.push_str(&format!("--{boundary}\r\n{part}"));
}
out.push_str(&format!("--{boundary}--\r\n"));
out
}
fn ascii_name(name: &str) -> String {
name.chars().map(|c| if c.is_ascii() && !matches!(c, '"' | '\\' | '\r' | '\n') { c } else { '_' }).collect()
}
fn percent(text: &str) -> String {
text.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}