1use crate::message::{Address, Message};
2use serde::{Deserialize, Serialize};
3
4#[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
13pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
15
16impl Attachment {
17 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 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 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}