1use base64::{Engine, engine::general_purpose::STANDARD};
9use mail_parser::{MessageParser, MimeHeaders, PartType};
10use rand::{RngCore, rngs::OsRng};
11use serde::{Deserialize, Serialize};
12
13use crate::{Error, Result, keys::hex};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Mailbox {
18 #[serde(default)]
19 pub name: Option<String>,
20 pub email: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Attachment {
27 pub name: String,
28 #[serde(rename = "type")]
29 pub media_type: String,
30 pub data: String,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct Draft {
37 pub from: Option<Mailbox>,
38 #[serde(default)]
39 pub to: Vec<Mailbox>,
40 #[serde(default)]
41 pub cc: Vec<Mailbox>,
42 #[serde(default)]
43 pub reply_to: Vec<Mailbox>,
44 #[serde(default)]
45 pub subject: String,
46 pub message_id: String,
48 #[serde(default)]
49 pub in_reply_to: Vec<String>,
50 #[serde(default)]
51 pub references: Vec<String>,
52 pub date: String,
54 #[serde(default)]
55 pub text: String,
56 pub html: Option<String>,
57 #[serde(default)]
58 pub attachments: Vec<Attachment>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct Part {
65 pub index: usize,
67 pub name: Option<String>,
68 #[serde(rename = "type")]
69 pub media_type: String,
70 pub size: usize,
71 pub cid: Option<String>,
72 pub inline: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct Parsed {
79 pub subject: Option<String>,
80 pub from: Vec<Mailbox>,
81 pub to: Vec<Mailbox>,
82 pub cc: Vec<Mailbox>,
83 pub reply_to: Vec<Mailbox>,
84 pub sent_at: Option<String>,
86 pub message_id: Option<String>,
87 pub in_reply_to: Vec<String>,
88 pub references: Vec<String>,
89 pub text: Option<String>,
90 pub html: Option<String>,
91 pub attachments: Vec<Part>,
92}
93
94pub fn build(draft: &Draft) -> Result<Vec<u8>> {
96 let from = draft.from.as_ref().ok_or(Error::NotMail)?;
97 let mut out = String::new();
98 header(&mut out, "From", &addresses(std::slice::from_ref(from))?);
99 if !draft.to.is_empty() {
100 header(&mut out, "To", &addresses(&draft.to)?);
101 }
102 if !draft.cc.is_empty() {
103 header(&mut out, "Cc", &addresses(&draft.cc)?);
104 }
105 if !draft.reply_to.is_empty() {
106 header(&mut out, "Reply-To", &addresses(&draft.reply_to)?);
107 }
108 header(&mut out, "Subject", &words(&draft.subject));
109 header(&mut out, "Date", &plain(&draft.date)?);
110 header(&mut out, "Message-ID", &format!("<{}>", plain(&draft.message_id)?));
111 if !draft.in_reply_to.is_empty() {
112 header(&mut out, "In-Reply-To", &ids(&draft.in_reply_to)?);
113 }
114 if !draft.references.is_empty() {
115 header(&mut out, "References", &ids(&draft.references)?);
116 }
117 out.push_str("MIME-Version: 1.0\r\n");
118
119 let text = body("text/plain; charset=utf-8", None, draft.text.as_bytes());
120 let content = match &draft.html {
121 Some(html) => multipart("alternative", &[text, body("text/html; charset=utf-8", None, html.as_bytes())]),
122 None => text,
123 };
124 let message = if draft.attachments.is_empty() {
125 content
126 } else {
127 let mut parts = vec![content];
128 for attachment in &draft.attachments {
129 let data = STANDARD.decode(&attachment.data).map_err(|_| Error::NotMail)?;
130 let kind = plain(&attachment.media_type)?;
131 parts.push(body(&kind, Some(&attachment.name), &data));
132 }
133 multipart("mixed", &parts)
134 };
135 out.push_str(&message);
136 Ok(out.into_bytes())
137}
138
139pub fn parse(raw: &[u8]) -> Result<Parsed> {
141 let message = MessageParser::default().parse(raw).ok_or(Error::NotMail)?;
142 let list = |address: Option<&mail_parser::Address<'_>>| -> Vec<Mailbox> {
143 address
144 .map(|a| {
145 a.iter()
146 .filter_map(|m| {
147 m.address().map(|email| Mailbox {
148 name: m.name().map(str::to_owned).filter(|n| !n.is_empty()),
149 email: email.to_owned(),
150 })
151 })
152 .collect()
153 })
154 .unwrap_or_default()
155 };
156 let ids = |value: &mail_parser::HeaderValue<'_>| -> Vec<String> {
157 value.as_text_list().map(|l| l.iter().map(|s| s.to_string()).collect()).unwrap_or_default()
158 };
159 let attachments = message
160 .attachments()
161 .map(|part| Part {
162 index: message.parts.iter().position(|p| std::ptr::eq(p, part)).unwrap_or(0),
163 name: part.attachment_name().map(str::to_owned),
164 media_type: part
165 .content_type()
166 .map(|t| format!("{}/{}", t.ctype(), t.subtype().unwrap_or("octet-stream")))
167 .unwrap_or_else(|| "application/octet-stream".to_owned()),
168 size: part.contents().len(),
169 cid: part.content_id().map(str::to_owned),
170 inline: part.content_disposition().is_some_and(|d| d.ctype().eq_ignore_ascii_case("inline")),
171 })
172 .collect();
173 Ok(Parsed {
174 subject: message.subject().map(str::to_owned),
175 from: list(message.from()),
176 to: list(message.to()),
177 cc: list(message.cc()),
178 reply_to: list(message.reply_to()),
179 sent_at: message.date().map(|d| d.to_rfc3339()),
180 message_id: message.message_id().map(str::to_owned),
181 in_reply_to: ids(message.in_reply_to()),
182 references: ids(message.references()),
183 text: message.body_text(0).map(|t| t.into_owned()),
184 html: message.body_html(0).filter(|_| !message.html_body.is_empty()).map(|h| h.into_owned()),
185 attachments,
186 })
187}
188
189pub fn part(raw: &[u8], index: usize) -> Result<Vec<u8>> {
191 let message = MessageParser::default().parse(raw).ok_or(Error::NotMail)?;
192 let part = message.parts.get(index).ok_or(Error::NotMail)?;
193 match &part.body {
194 PartType::Multipart(_) => Err(Error::NotMail),
195 _ => Ok(part.contents().to_vec()),
196 }
197}
198
199fn header(out: &mut String, name: &str, value: &str) {
201 let mut line = format!("{name}:");
202 let mut length = line.len();
203 for word in value.split(' ') {
204 if length + 1 + word.len() > 76 && length > name.len() + 1 {
205 line.push_str("\r\n");
206 length = 0;
207 }
208 line.push(' ');
209 line.push_str(word);
210 length += 1 + word.len();
211 }
212 out.push_str(&line);
213 out.push_str("\r\n");
214}
215
216fn plain(text: &str) -> Result<String> {
218 if text.is_ascii() && !text.contains(['\r', '\n']) && !text.trim().is_empty() {
219 Ok(text.trim().to_owned())
220 } else {
221 Err(Error::NotMail)
222 }
223}
224
225fn words(text: &str) -> String {
228 let text = text.replace(['\r', '\n'], " ");
229 if text.bytes().all(|b| (0x20..0x7f).contains(&b)) && !text.contains("=?") {
230 return text;
231 }
232 let mut words = Vec::new();
234 let mut chunk = String::new();
235 for c in text.chars() {
236 if chunk.len() + c.len_utf8() > 45 {
237 words.push(format!("=?utf-8?b?{}?=", STANDARD.encode(&chunk)));
238 chunk.clear();
239 }
240 chunk.push(c);
241 }
242 if !chunk.is_empty() {
243 words.push(format!("=?utf-8?b?{}?=", STANDARD.encode(&chunk)));
244 }
245 words.join(" ")
246}
247
248fn addresses(list: &[Mailbox]) -> Result<String> {
249 let mut out = Vec::new();
250 for mailbox in list {
251 let email = plain(&mailbox.email)?;
252 if !email.contains('@') || email.contains([' ', '<', '>', ',', '"']) {
253 return Err(Error::NotMail);
254 }
255 match mailbox.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
256 Some(name) if name.bytes().all(|b| b.is_ascii_alphanumeric() || b" !#$%&'*+-/=?^_`{|}~".contains(&b)) => {
257 out.push(format!("{name} <{email}>"));
258 }
259 Some(name) if name.is_ascii() && !name.contains(['\r', '\n']) => {
260 out.push(format!("\"{}\" <{email}>", name.replace('\\', "\\\\").replace('"', "\\\"")));
261 }
262 Some(name) => out.push(format!("{} <{email}>", words(name))),
263 None => out.push(email),
264 }
265 }
266 Ok(out.join(", "))
267}
268
269fn ids(list: &[String]) -> Result<String> {
270 list.iter().map(|id| plain(id).map(|id| format!("<{id}>"))).collect::<Result<Vec<_>>>().map(|l| l.join(" "))
271}
272
273fn body(content_type: &str, file: Option<&str>, data: &[u8]) -> String {
275 let mut out = format!("Content-Type: {content_type}");
276 if let Some(name) = file {
278 if name.is_ascii() {
279 out.push_str(&format!("; name=\"{}\"", ascii_name(name)));
280 } else {
281 out.push_str(&format!("; name=\"{}\"", words(name)));
282 }
283 }
284 out.push_str("\r\nContent-Transfer-Encoding: base64\r\n");
285 if let Some(name) = file {
286 if name.is_ascii() {
287 out.push_str(&format!("Content-Disposition: attachment; filename=\"{}\"\r\n", ascii_name(name)));
288 } else {
289 out.push_str(&format!("Content-Disposition: attachment; filename*=utf-8''{}\r\n", percent(name)));
290 }
291 }
292 out.push_str("\r\n");
293 let encoded = STANDARD.encode(data);
294 for line in encoded.as_bytes().chunks(76) {
295 out.push_str(std::str::from_utf8(line).unwrap_or_default());
296 out.push_str("\r\n");
297 }
298 out
299}
300
301fn multipart(subtype: &str, parts: &[String]) -> String {
302 let mut random = [0u8; 12];
303 OsRng.fill_bytes(&mut random);
304 let boundary = format!("kernoid-{}", hex(&random));
305 let mut out = format!("Content-Type: multipart/{subtype}; boundary=\"{boundary}\"\r\n\r\n");
306 for part in parts {
307 out.push_str(&format!("--{boundary}\r\n{part}"));
308 }
309 out.push_str(&format!("--{boundary}--\r\n"));
310 out
311}
312
313fn ascii_name(name: &str) -> String {
315 name.chars().map(|c| if c.is_ascii() && !matches!(c, '"' | '\\' | '\r' | '\n') { c } else { '_' }).collect()
316}
317
318fn percent(text: &str) -> String {
320 text.bytes()
321 .map(|b| {
322 if b.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&b) {
323 (b as char).to_string()
324 } else {
325 format!("%{b:02X}")
326 }
327 })
328 .collect()
329}