Skip to main content

kernoid_e2e/
compose.rs

1// SPDX-License-Identifier: MIT
2//! Mail the apps write and read when kernoid must not see it (RFC 0006, 5.3–5.4): a message built from its parts –
3//! here, because the server builds it otherwise (JMAP `Email/set`) and must not see the text –, and an encrypted
4//! message's inner message read into its parts for display, with mail-parser (never sent back to the server's
5//! `Email/parse`). Building is plain RFC 5322 and MIME: header fields with RFC 2047 encoded words where they are not
6//! ASCII, file names after RFC 2231, every body in base64.
7
8use 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/// An address: a name (may be empty) and the address itself.
16#[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/// A file to attach, its content base64.
24#[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/// What a message is built from. `bcc` is not written – it is the envelope's.
34#[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    /// The `Message-ID` without angle brackets.
47    pub message_id: String,
48    #[serde(default)]
49    pub in_reply_to: Vec<String>,
50    #[serde(default)]
51    pub references: Vec<String>,
52    /// `Date`, as written (RFC 5322, 3.3) – the client knows the time and its zone.
53    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/// One part of a message as an app shows it.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct Part {
65    /// The part's index, for [`part`].
66    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/// A message read into what an app shows.
76#[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    /// RFC 3339, UTC.
85    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
94/// Builds the message of a draft.
95pub 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
139/// Reads a message into its parts.
140pub 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
189/// The decoded content of a part of a message (an index of [`Part`]).
190pub 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
199/// A header field, folded where its value has spaces and lines would be long.
200fn 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
216/// Text that must be ASCII without line breaks (dates, ids, media types).
217fn 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
225/// Unstructured text: as it is if it is printable ASCII, else RFC 2047 encoded words (UTF-8, base64), each at most
226/// 75 characters.
227fn 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    // 45 bytes become 60 characters of base64, plus `=?utf-8?b?` and `?=`: 72.
233    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
273/// A leaf part: its content type, a file name if it is an attachment, the content in base64 lines of 76.
274fn body(content_type: &str, file: Option<&str>, data: &[u8]) -> String {
275    let mut out = format!("Content-Type: {content_type}");
276    // A name that is not ASCII: RFC 2231 in the disposition, an encoded word in `name` for older readers.
277    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
313/// A file name for the plain `name`/`filename` parameters: ASCII, quotes and backslashes replaced.
314fn ascii_name(name: &str) -> String {
315    name.chars().map(|c| if c.is_ascii() && !matches!(c, '"' | '\\' | '\r' | '\n') { c } else { '_' }).collect()
316}
317
318/// RFC 2231's percent-encoding of UTF-8.
319fn 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}