Skip to main content

ecr_core/
compose.rs

1use crate::message::{Address, Message};
2use serde::{Deserialize, Serialize};
3
4/// A file travelling with a draft, base64 in the same request that sends it.
5#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(default)]
7pub struct Attachment {
8    pub filename: String,
9    pub content_type: String,
10    pub data_b64: String,
11}
12
13/// What every mail provider in practice refuses beyond, before encoding.
14pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
15
16impl Attachment {
17    /// Decoded size, from the encoded length: base64 is 4 characters per 3
18    /// bytes, so this needs no decoding to check a total against the cap.
19    pub fn approximate_bytes(&self) -> usize {
20        let padding = self
21            .data_b64
22            .bytes()
23            .rev()
24            .take_while(|b| *b == b'=')
25            .count();
26        self.data_b64.len() / 4 * 3 - padding.min(2)
27    }
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct Draft {
33    pub to: Vec<String>,
34    pub cc: Vec<String>,
35    pub bcc: Vec<String>,
36    pub subject: String,
37    pub body: String,
38    pub in_reply_to: Option<String>,
39    pub references: Vec<String>,
40    pub attachments: Vec<Attachment>,
41}
42
43impl Draft {
44    pub fn is_sendable(&self) -> Result<(), &'static str> {
45        if self.to.is_empty() && self.cc.is_empty() && self.bcc.is_empty() {
46            return Err("a message needs at least one recipient");
47        }
48        if self
49            .to
50            .iter()
51            .chain(&self.cc)
52            .chain(&self.bcc)
53            .any(|r| r.trim().is_empty() || r.contains('\n') || r.contains('\r'))
54        {
55            return Err("a recipient address cannot be empty or contain a line break");
56        }
57        if self.subject.contains('\n') || self.subject.contains('\r') {
58            return Err("a subject cannot contain a line break");
59        }
60
61        let mut total = 0;
62        for attachment in &self.attachments {
63            if attachment.filename.trim().is_empty() {
64                return Err("an attachment needs a filename");
65            }
66            // The filename and content type both reach MIME headers.
67            if attachment.filename.contains(['\n', '\r'])
68                || attachment.content_type.contains(['\n', '\r'])
69            {
70                return Err("an attachment name cannot contain a line break");
71            }
72            total += attachment.approximate_bytes();
73        }
74        if total > MAX_ATTACHMENT_BYTES {
75            return Err("attachments exceed the 25MB limit");
76        }
77
78        Ok(())
79    }
80
81    pub fn reply(message: &Message, self_addresses: &[String], reply_all: bool) -> Self {
82        let reply_to = if message.reply_to.is_empty() {
83            &message.from
84        } else {
85            &message.reply_to
86        };
87
88        let mut to: Vec<String> = reply_to.iter().map(|a| a.email.clone()).collect();
89        let mut cc = Vec::new();
90
91        if reply_all {
92            for address in message.to.iter().chain(message.cc.iter()) {
93                if !is_self(&address.email, self_addresses) && !to.contains(&address.email) {
94                    cc.push(address.email.clone());
95                }
96            }
97        }
98        to.retain(|a| !a.trim().is_empty());
99
100        let mut references = message.references.clone();
101        references.push(message.id.0.clone());
102
103        Self {
104            to,
105            cc,
106            bcc: Vec::new(),
107            subject: prefix_subject(&message.subject, "Re:"),
108            body: quote(message),
109            in_reply_to: Some(message.id.0.clone()),
110            references,
111            attachments: Vec::new(),
112        }
113    }
114
115    pub fn forward(message: &Message) -> Self {
116        Self {
117            to: Vec::new(),
118            cc: Vec::new(),
119            bcc: Vec::new(),
120            subject: prefix_subject(&message.subject, "Fwd:"),
121            body: forwarded(message),
122            in_reply_to: None,
123            references: Vec::new(),
124            attachments: Vec::new(),
125        }
126    }
127}
128
129fn is_self(email: &str, self_addresses: &[String]) -> bool {
130    self_addresses.iter().any(|s| s.eq_ignore_ascii_case(email))
131}
132
133fn prefix_subject(subject: &str, prefix: &str) -> String {
134    let trimmed = subject.trim();
135    if trimmed
136        .to_ascii_lowercase()
137        .starts_with(&prefix.to_ascii_lowercase())
138    {
139        trimmed.to_string()
140    } else {
141        format!("{prefix} {trimmed}")
142    }
143}
144
145fn quote(message: &Message) -> String {
146    let attribution = match message.from.first() {
147        Some(from) => format!("On {}, {} wrote:", message.date, from),
148        None => "Previously:".to_string(),
149    };
150    format!("\n\n{attribution}\n")
151}
152
153fn forwarded(message: &Message) -> String {
154    let mut out = String::from("\n\n---------- Forwarded message ----------\n");
155    out.push_str(&format!("From: {}\n", join(&message.from)));
156    out.push_str(&format!("Date: {}\n", message.date));
157    out.push_str(&format!("Subject: {}\n", message.subject));
158    out.push_str(&format!("To: {}\n\n", join(&message.to)));
159    out
160}
161
162fn join(addresses: &[Address]) -> String {
163    addresses
164        .iter()
165        .map(|a| a.to_string())
166        .collect::<Vec<_>>()
167        .join(", ")
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::message::{MessageId, ThreadId};
174    use std::collections::BTreeSet;
175
176    fn message() -> Message {
177        Message {
178            id: MessageId::from("original@example.com"),
179            thread_id: ThreadId::from("t1"),
180            subject: "Project update".to_string(),
181            from: vec![Address::new(Some("Alice".into()), "alice@example.com")],
182            to: vec![
183                Address::new(None, "me@example.com"),
184                Address::new(None, "bob@example.com"),
185            ],
186            cc: vec![Address::new(None, "carol@example.com")],
187            bcc: Vec::new(),
188            reply_to: Vec::new(),
189            date: "Wed, 01 Apr 2026 10:00:00 +0000".to_string(),
190            timestamp: 0,
191            tags: BTreeSet::new(),
192            in_reply_to: None,
193            references: vec!["older@example.com".to_string()],
194            parts: Vec::new(),
195            excluded: false,
196        }
197    }
198
199    fn attachment(name: &str, bytes: usize) -> Attachment {
200        Attachment {
201            filename: name.to_string(),
202            content_type: "application/octet-stream".to_string(),
203            data_b64: "A".repeat(bytes.div_ceil(3) * 4),
204        }
205    }
206
207    fn sendable(attachments: Vec<Attachment>) -> Draft {
208        Draft {
209            to: vec!["someone@example.com".to_string()],
210            attachments,
211            ..Default::default()
212        }
213    }
214
215    #[test]
216    fn an_attachment_without_a_filename_is_refused() {
217        let draft = sendable(vec![attachment("  ", 10)]);
218        assert!(draft.is_sendable().is_err());
219    }
220
221    #[test]
222    fn a_filename_with_a_line_break_is_refused() {
223        let draft = sendable(vec![attachment("a\r\nBcc: victim@example.com", 10)]);
224        assert!(draft.is_sendable().is_err());
225    }
226
227    #[test]
228    fn attachments_over_the_cap_are_refused() {
229        let draft = sendable(vec![attachment("big.bin", MAX_ATTACHMENT_BYTES + 1024)]);
230        assert_eq!(
231            draft.is_sendable(),
232            Err("attachments exceed the 25MB limit")
233        );
234    }
235
236    #[test]
237    fn attachments_under_the_cap_are_allowed() {
238        let draft = sendable(vec![attachment("small.bin", 1024)]);
239        assert!(draft.is_sendable().is_ok());
240    }
241
242    #[test]
243    fn the_cap_is_on_the_total_rather_than_each_file() {
244        let half = MAX_ATTACHMENT_BYTES / 2 + 1024;
245        let draft = sendable(vec![attachment("a.bin", half), attachment("b.bin", half)]);
246        assert!(draft.is_sendable().is_err());
247    }
248
249    #[test]
250    fn the_decoded_size_is_read_from_the_encoded_length() {
251        let payload = Attachment {
252            filename: "x".to_string(),
253            content_type: "text/plain".to_string(),
254            // 6 bytes encode to 8 characters with no padding.
255            data_b64: "AAAAAAAA".to_string(),
256        };
257        assert_eq!(payload.approximate_bytes(), 6);
258    }
259
260    #[test]
261    fn a_reply_goes_to_the_sender_only() {
262        let draft = Draft::reply(&message(), &["me@example.com".to_string()], false);
263
264        assert_eq!(draft.to, vec!["alice@example.com"]);
265        assert!(draft.cc.is_empty());
266    }
267
268    #[test]
269    fn a_reply_all_ccs_the_others_but_never_yourself() {
270        let draft = Draft::reply(&message(), &["me@example.com".to_string()], true);
271
272        assert_eq!(draft.to, vec!["alice@example.com"]);
273        assert!(draft.cc.contains(&"bob@example.com".to_string()));
274        assert!(draft.cc.contains(&"carol@example.com".to_string()));
275        assert!(!draft.cc.contains(&"me@example.com".to_string()));
276    }
277
278    #[test]
279    fn a_reply_honours_the_reply_to_header() {
280        let mut message = message();
281        message.reply_to = vec![Address::new(None, "list@example.com")];
282
283        let draft = Draft::reply(&message, &[], false);
284        assert_eq!(draft.to, vec!["list@example.com"]);
285    }
286
287    #[test]
288    fn a_reply_threads_correctly() {
289        let draft = Draft::reply(&message(), &[], false);
290
291        assert_eq!(draft.in_reply_to.as_deref(), Some("original@example.com"));
292        assert_eq!(
293            draft.references,
294            vec!["older@example.com", "original@example.com"]
295        );
296    }
297
298    #[test]
299    fn re_is_not_stacked_on_an_existing_reply_subject() {
300        let mut message = message();
301        message.subject = "Re: Project update".to_string();
302
303        assert_eq!(
304            Draft::reply(&message, &[], false).subject,
305            "Re: Project update"
306        );
307    }
308
309    #[test]
310    fn a_forward_has_no_recipients_and_quotes_the_headers() {
311        let draft = Draft::forward(&message());
312
313        assert!(draft.to.is_empty());
314        assert_eq!(draft.subject, "Fwd: Project update");
315        assert!(draft.body.contains("Forwarded message"));
316        assert!(draft.body.contains("alice@example.com"));
317        assert_eq!(draft.in_reply_to, None);
318    }
319
320    #[test]
321    fn a_draft_without_recipients_is_not_sendable() {
322        let draft = Draft::default();
323        assert!(draft.is_sendable().is_err());
324    }
325
326    #[test]
327    fn a_header_injection_attempt_is_refused() {
328        let draft = Draft {
329            to: vec!["a@b.c\r\nBcc: victim@example.com".to_string()],
330            ..Default::default()
331        };
332        assert!(draft.is_sendable().is_err());
333
334        let draft = Draft {
335            to: vec!["a@b.c".to_string()],
336            subject: "hi\r\nBcc: victim@example.com".to_string(),
337            ..Default::default()
338        };
339        assert!(draft.is_sendable().is_err());
340    }
341
342    #[test]
343    fn a_plain_draft_is_sendable() {
344        let draft = Draft {
345            to: vec!["a@b.c".to_string()],
346            subject: "Hello".to_string(),
347            body: "Hi there".to_string(),
348            ..Default::default()
349        };
350        assert!(draft.is_sendable().is_ok());
351    }
352}