1use crate::config::data_dir;
18use crate::i18n::{self, Translator};
19use crate::reading;
20use mail_builder::MessageBuilder;
21use mail_parser::{Address, MessageParser};
22use serde::{Deserialize, Serialize};
23use std::path::{Path, PathBuf};
24
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum DraftKind {
28 #[default]
29 New,
30 Reply,
31 ReplyAll,
32 Forward,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct Draft {
38 #[serde(default)]
40 pub id: String,
41 #[serde(default)]
43 pub account: String,
44 #[serde(default)]
45 pub to: Vec<String>,
46 #[serde(default)]
47 pub cc: Vec<String>,
48 #[serde(default)]
49 pub bcc: Vec<String>,
50 #[serde(default)]
51 pub subject: String,
52 #[serde(default)]
54 pub body: String,
55 #[serde(default)]
57 pub attachments: Vec<PathBuf>,
58 #[serde(default)]
59 pub kind: DraftKind,
60 #[serde(default)]
62 pub original: Option<PathBuf>,
63 #[serde(default)]
64 pub in_reply_to: Option<String>,
65 #[serde(default)]
66 pub references: Vec<String>,
67 #[serde(default)]
69 pub dropped: Vec<u32>,
70 #[serde(default)]
72 pub sign: bool,
73 #[serde(default)]
75 pub encrypt: bool,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub links: Vec<String>,
79 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
84 pub copy_as_received: bool,
85}
86
87pub const SIGNATURE_LINE: &str = "-- ";
89
90impl Draft {
91 pub fn new(account: &str) -> Draft {
93 Draft { id: new_id(), account: account.to_string(), ..Draft::default() }
94 }
95
96 pub fn folder() -> PathBuf {
98 data_dir().join("drafts")
99 }
100
101 pub fn path(&self) -> PathBuf {
102 Draft::folder().join(format!("{}.toml", self.id))
103 }
104
105 pub fn save(&self) -> Result<PathBuf, String> {
106 let path = self.path();
107 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
108 std::fs::create_dir_all(Draft::folder()).map_err(fail)?;
109 let text = toml::to_string(self).map_err(|e| e.to_string())?;
110 let temporary = path.with_extension("toml.new");
111 std::fs::write(&temporary, text).map_err(fail)?;
112 std::fs::rename(&temporary, &path).map_err(fail)?;
113 Ok(path)
114 }
115
116 pub fn load(path: &Path) -> Option<Draft> {
117 toml::from_str(&std::fs::read_to_string(path).ok()?).ok()
118 }
119
120 pub fn all() -> Vec<Draft> {
122 let Ok(entries) = std::fs::read_dir(Draft::folder()) else { return Vec::new() };
123 let mut found: Vec<(std::time::SystemTime, Draft)> = entries
124 .filter_map(Result::ok)
125 .map(|e| e.path())
126 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
127 .filter_map(|p| Some((std::fs::metadata(&p).and_then(|m| m.modified()).ok()?, Draft::load(&p)?)))
128 .collect();
129 found.sort_by(|a, b| b.0.cmp(&a.0));
130 found.into_iter().map(|(_, d)| d).collect()
131 }
132
133 pub fn discard(&self) {
137 let _ = std::fs::remove_file(self.path());
138 let _ = std::fs::remove_file(self.original_copy());
139 for path in &self.attachments {
140 remove_own_copy(path);
141 }
142 }
143
144 fn original_copy(&self) -> PathBuf {
145 Draft::folder().join(format!("{}.eml", self.id))
146 }
147
148 pub fn original_here(&self) -> Option<PathBuf> {
152 let named = self.original.as_ref()?;
153 if named.is_file() {
154 return Some(named.clone());
155 }
156 let copy = self.original_copy();
157 copy.is_file().then_some(copy)
158 }
159
160 pub fn keep_original(&mut self) -> Result<(), String> {
163 let Some(original) = self.original.clone() else { return Ok(()) };
164 let copy = self.original_copy();
165 if original == copy {
166 return Ok(());
167 }
168 std::fs::create_dir_all(Draft::folder()).map_err(|e| e.to_string())?;
169 std::fs::copy(&original, ©).map_err(|e| format!("{}: {e}", original.display()))?;
170 self.original = Some(copy);
171 self.copy_as_received = true;
172 Ok(())
173 }
174
175 pub fn by_id(id: &str) -> Option<Draft> {
177 if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric()) {
178 return None;
179 }
180 Draft::load(&Draft::folder().join(format!("{id}.toml")))
181 }
182
183 pub fn recipients(&self) -> Vec<String> {
185 self.to.iter().chain(&self.cc).chain(&self.bcc).filter_map(|r| address_of(r)).collect()
186 }
187
188 pub fn add_signature(&mut self, signature: Option<&str>) {
190 let Some(signature) = signature.map(str::trim).filter(|s| !s.is_empty()) else { return };
191 if !self.body.contains(signature) {
192 self.body = format!("{}\n\n{SIGNATURE_LINE}\n{signature}", self.body.trim_end());
193 }
194 }
195
196 pub fn is_empty(&self, signature: Option<&str>) -> bool {
198 let body = self.body.replace(signature.unwrap_or("\u{0}").trim(), "").replace(SIGNATURE_LINE.trim_end(), "");
199 body.trim().is_empty() && self.attachments.is_empty() && (self.kind != DraftKind::New || (self.to.is_empty() && self.subject.trim().is_empty()))
200 }
201
202 pub fn forwarded(&self) -> Vec<(u32, String)> {
205 let raw = self.original_here().and_then(|path| crate::filelock::read_whole(&path).ok()).filter(|raw| !crate::pgp::is_encrypted(raw));
206 self.forwarded_from(raw.as_deref())
207 }
208
209 pub fn forwarded_from(&self, original: Option<&[u8]>) -> Vec<(u32, String)> {
211 let Some(raw) = original.filter(|_| self.kind == DraftKind::Forward) else { return Vec::new() };
212 let Some(reading) = reading::read_bytes(raw) else { return Vec::new() };
213 reading.attachments.into_iter().filter(|a| !self.dropped.contains(&a.index)).map(|a| (a.index, a.name)).collect()
214 }
215}
216
217pub fn new_message_id() -> String {
219 new_id()
220}
221
222fn new_id() -> String {
224 use std::hash::{Hash, Hasher};
225 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
226 let mut hasher = std::collections::hash_map::DefaultHasher::new();
227 now.hash(&mut hasher);
228 std::process::id().hash(&mut hasher);
229 format!("{:x}{:08x}", now.as_secs(), hasher.finish() as u32)
230}
231
232pub fn is_own_copy(path: &Path) -> bool {
235 own_copy_in(&crate::handed::Incoming::here().files(), path)
236}
237
238fn own_copy_in(copies: &Path, path: &Path) -> bool {
239 use std::path::Component;
240 path.starts_with(copies) && path.components().all(|c| !matches!(c, Component::ParentDir | Component::CurDir))
241}
242
243pub fn remove_own_copy(path: &Path) {
246 remove_own_copy_in(&crate::handed::Incoming::here().files(), path);
247}
248
249fn remove_own_copy_in(copies: &Path, path: &Path) {
250 if !own_copy_in(copies, path) {
251 return;
252 }
253 let _ = std::fs::remove_file(path);
254 if let Some(folder) = path.parent().filter(|folder| *folder != copies) {
255 let _ = std::fs::remove_dir(folder);
257 }
258}
259
260pub fn address_of(entry: &str) -> Option<String> {
262 let entry = entry.trim();
263 let address = match (entry.rfind('<'), entry.rfind('>')) {
264 (Some(open), Some(close)) if open < close => &entry[open + 1..close],
265 _ => entry,
266 };
267 let address = address.trim();
268 let (local, domain) = address.split_once('@')?;
269 (!local.is_empty() && domain.contains('.') && !address.contains(char::is_whitespace)).then(|| address.to_string())
270}
271
272pub fn split_addresses(field: &str) -> Vec<String> {
277 let mut out = Vec::new();
278 for line in field.split('\n') {
279 let (mut quoted, mut escaped, mut angle) = (false, false, false);
280 let mut pieces = vec![String::new()];
281 for c in line.chars() {
282 match c {
283 _ if escaped => escaped = false,
284 '\\' if quoted => escaped = true,
285 '"' if !angle => quoted = !quoted,
286 '<' if !quoted => angle = true,
287 '>' if !quoted => angle = false,
288 ',' | ';' if !quoted && !angle => {
289 pieces.push(String::new());
290 continue;
291 }
292 _ => {}
293 }
294 pieces.last_mut().into_iter().for_each(|p| p.push(c));
295 }
296 if quoted || angle {
297 pieces = line.split([',', ';']).map(str::to_string).collect();
298 }
299 out.extend(pieces.iter().map(|p| p.trim()).filter(|p| !p.is_empty()).map(str::to_string));
300 }
301 out
302}
303
304pub fn answer(original: &Path, kind: DraftKind, account: &str, own: &[String]) -> Option<Draft> {
308 let raw = std::fs::read(original).ok()?;
309 let message = MessageParser::default().parse(&raw)?;
310 let mine = |address: &str| own.iter().any(|o| o.eq_ignore_ascii_case(address));
311 let entries = |list: Option<&Address>| -> Vec<String> {
312 list.map(|a| a.iter().filter_map(|p| p.address().map(|addr| entry(p.name(), addr))).collect()).unwrap_or_default()
313 };
314 let reply_to = entries(message.reply_to());
315 let from = entries(message.from());
316 let yours = !from.is_empty() && from.iter().all(|e| address_of(e).is_some_and(|a| mine(&a)));
318 let to_answer = if yours {
319 entries(message.to())
320 } else if reply_to.is_empty() {
321 from
322 } else {
323 reply_to
324 };
325 let subject = message.subject().unwrap_or("").to_string();
326 let message_id = message.message_id().map(str::to_string);
327 let mut references: Vec<String> = message.references().as_text_list().map(|l| l.iter().map(|r| r.to_string()).collect()).unwrap_or_default();
328 references.extend(message_id.clone());
329 let mut draft = Draft::new(account);
330 draft.kind = kind;
331 draft.original = Some(original.to_path_buf());
332 match kind {
333 DraftKind::Forward => {
334 draft.subject = prefixed(&subject, "Fwd:", &crate::words::current().replies.forward);
335 }
336 DraftKind::Reply | DraftKind::ReplyAll => {
337 draft.subject = prefixed(&subject, "Re:", &crate::words::current().replies.reply);
338 draft.in_reply_to = message_id;
339 draft.references = references;
340 draft.to = to_answer.into_iter().filter(|e| address_of(e).is_none_or(|a| !mine(&a))).collect();
341 if kind == DraftKind::ReplyAll {
342 let already: Vec<String> = draft.to.iter().filter_map(|e| address_of(e)).collect();
343 let mut cc: Vec<String> = Vec::new();
344 let others = if yours { Vec::new() } else { entries(message.to()) };
345 for e in others.into_iter().chain(entries(message.cc())) {
346 let Some(address) = address_of(&e) else { continue };
347 let taken = mine(&address) || already.iter().chain(cc.iter().filter_map(|c| address_of(c)).collect::<Vec<_>>().iter()).any(|a| a.eq_ignore_ascii_case(&address));
348 if !taken {
349 cc.push(e);
350 }
351 }
352 draft.cc = cc;
353 }
354 }
355 DraftKind::New => {}
356 }
357 Some(draft)
358}
359
360fn entry(name: Option<&str>, address: &str) -> String {
361 match name.filter(|n| !n.trim().is_empty() && *n != address) {
362 Some(name) => format!("{} <{address}>", phrase(name.trim())),
363 None => address.to_string(),
364 }
365}
366
367pub fn phrase(name: &str) -> String {
371 let special = |c: char| matches!(c, '(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | '\\' | ',' | '.' | '"');
372 if name.contains(special) { format!("\"{}\"", name.replace('\\', "\\\\").replace('"', "\\\"")) } else { name.to_string() }
373}
374
375fn prefixed(subject: &str, prefix: &str, known: &[String]) -> String {
379 let lower = crate::words::folded(subject);
380 if known.iter().map(|k| crate::words::folded(k)).any(|k| !k.is_empty() && lower.starts_with(&k)) { subject.trim().to_string() } else { format!("{prefix} {}", subject.trim()) }
381}
382
383pub fn markdown_html(markdown: &str) -> String {
393 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
394 let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
395 let mut events: Vec<Event> = Vec::new();
396 let (mut links, mut lines) = (0usize, 0usize);
400 let mut far: Option<(String, bool, bool)> = None;
401 for event in Parser::new_ext(markdown, options) {
402 if let (Some((_, words, _)), Event::Text(text) | Event::Code(text)) = (far.as_mut(), &event) {
403 *words |= !text.trim().is_empty();
404 }
405 match event {
406 Event::SoftBreak => events.push(Event::HardBreak),
407 Event::Start(Tag::HtmlBlock) => {
408 lines = 0;
409 events.push(Event::Start(Tag::Paragraph));
410 }
411 Event::End(TagEnd::HtmlBlock) => events.push(Event::End(TagEnd::Paragraph)),
412 Event::Html(raw) => {
413 if lines > 0 {
414 events.push(Event::HardBreak);
415 }
416 lines += 1;
417 events.push(written_html(raw.trim_end_matches('\n')));
418 }
419 Event::InlineHtml(raw) => events.push(written_html(&raw)),
420 Event::Start(Tag::Image { link_type, dest_url, title, id }) if !local_source(&dest_url) => {
421 far = Some((dest_url.to_string(), false, links == 0));
422 if links == 0 {
423 events.push(Event::Start(Tag::Link { link_type, dest_url, title, id }));
424 }
425 }
426 Event::End(TagEnd::Image) if far.is_some() => {
427 if let Some((address, words, link)) = far.take() {
428 if !words {
429 events.push(Event::Text(address.into()));
430 }
431 if link {
432 events.push(Event::End(TagEnd::Link));
433 }
434 }
435 }
436 Event::Start(Tag::Link { .. }) => {
437 links += 1;
438 events.push(event);
439 }
440 Event::End(TagEnd::Link) => {
441 links = links.saturating_sub(1);
442 events.push(event);
443 }
444 other => events.push(other),
445 }
446 }
447 let mut out = String::new();
448 html::push_html(&mut out, events.into_iter());
449 out
450}
451
452const PLAIN_MARKS: &[&str] = &["b", "i", "u", "s", "em", "strong", "del", "sub", "sup", "mark", "kbd", "br"];
454
455fn written_html(raw: &str) -> pulldown_cmark::Event<'static> {
459 use pulldown_cmark::{CowStr, Event};
460 match plain_mark(raw).or_else(|| local_picture(raw)) {
461 Some(kept) => Event::InlineHtml(CowStr::from(kept)),
462 None => Event::Text(CowStr::from(raw.to_string())),
463 }
464}
465
466fn plain_mark(raw: &str) -> Option<String> {
468 let inner = raw.trim().strip_prefix('<')?.strip_suffix('>')?;
469 let (closing, name) = inner.strip_prefix('/').map_or((false, inner), |n| (true, n));
470 let name = name.trim_end_matches('/').trim().to_ascii_lowercase();
471 PLAIN_MARKS.contains(&name.as_str()).then(|| if closing { format!("</{name}>") } else { format!("<{name}>") })
472}
473
474fn local_picture(raw: &str) -> Option<String> {
477 let inner = raw.trim().strip_prefix('<')?.strip_suffix('>')?.trim_end_matches('/');
478 let rest = inner.get(..3).filter(|tag| tag.eq_ignore_ascii_case("img")).and_then(|_| inner.get(3..))?;
479 if !rest.starts_with(char::is_whitespace) {
480 return None;
481 }
482 let attributes = tag_attributes(rest)?;
483 let value = |name: &str| attributes.iter().find(|(n, _)| n == name).map(|(_, v)| v.trim());
484 let src = value("src").filter(|s| local_source(s))?;
485 let mut out = format!("<img src=\"{}\"", escape_attribute(src));
486 for name in ["width", "height"] {
487 if let Some(size) = value(name).filter(|v| (1..=5).contains(&v.len()) && v.chars().all(|c| c.is_ascii_digit())) {
488 out.push_str(&format!(" {name}=\"{size}\""));
489 }
490 }
491 if let Some(alt) = value("alt") {
492 out.push_str(&format!(" alt=\"{}\"", escape_attribute(alt)));
493 }
494 out.push('>');
495 Some(out)
496}
497
498fn tag_attributes(text: &str) -> Option<Vec<(String, String)>> {
501 let chars: Vec<char> = text.chars().collect();
502 let mut out = Vec::new();
503 let mut i = 0;
504 loop {
505 while chars.get(i).is_some_and(|c| c.is_whitespace()) {
506 i += 1;
507 }
508 if i == chars.len() {
509 return Some(out);
510 }
511 let start = i;
512 while chars.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') {
513 i += 1;
514 }
515 if i == start {
516 return None;
517 }
518 let name = chars[start..i].iter().collect::<String>().to_ascii_lowercase();
519 while chars.get(i).is_some_and(|c| c.is_whitespace()) {
520 i += 1;
521 }
522 let mut value = String::new();
523 if chars.get(i) == Some(&'=') {
524 i += 1;
525 while chars.get(i).is_some_and(|c| c.is_whitespace()) {
526 i += 1;
527 }
528 match chars.get(i) {
529 Some("e) if quote == '"' || quote == '\'' => {
530 let end = i + 1 + chars[i + 1..].iter().position(|&c| c == quote)?;
531 value = chars[i + 1..end].iter().collect();
532 i = end + 1;
533 }
534 _ => {
535 let start = i;
536 while chars.get(i).is_some_and(|c| !c.is_whitespace() && !"\"'<>=`".contains(*c)) {
537 i += 1;
538 }
539 if i == start {
540 return None;
541 }
542 value = chars[start..i].iter().collect();
543 }
544 }
545 }
546 out.push((name, value));
547 }
548}
549
550fn local_source(src: &str) -> bool {
555 let lower = src.trim().to_ascii_lowercase();
556 if lower.is_empty() || lower.contains('\\') || lower.contains("%2f") || lower.contains("%5c") {
557 return false;
558 }
559 if let Some(data) = lower.strip_prefix("data:") {
560 return data.starts_with("image/");
561 }
562 if let Some(rest) = lower.strip_prefix("file://") {
563 let path = rest.strip_prefix("localhost").unwrap_or(rest);
564 return path.starts_with('/') && !path.starts_with("//");
565 }
566 let scheme = lower.split_once(':').is_some_and(|(scheme, _)| !scheme.contains(['/', '?', '#']));
568 !scheme && !lower.starts_with("//")
569}
570
571fn escape_attribute(text: &str) -> String {
572 escape(text).replace('"', """)
573}
574
575#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct Outgoing {
578 pub raw: Vec<u8>,
579 pub from: String,
580 pub recipients: Vec<String>,
581}
582
583pub fn build(draft: &Draft, from: (&str, &str), tr: &Translator, date: i64) -> Result<Outgoing, String> {
586 let original = draft.original_here().and_then(|path| crate::filelock::read_whole(&path).ok()).filter(|raw| !crate::pgp::is_encrypted(raw));
588 build_with(draft, from, tr, date, None, None, original.as_deref())
589}
590
591pub struct Protection<'a> {
594 pub sign: Option<&'a mut (dyn sequoia_openpgp::crypto::Signer + Send + Sync)>,
597 pub encrypt_to: Vec<&'a sequoia_openpgp::Cert>,
598}
599
600pub fn build_with(draft: &Draft, from: (&str, &str), tr: &Translator, date: i64, protection: Option<&mut Protection>, autocrypt: Option<&str>, original: Option<&[u8]>) -> Result<Outgoing, String> {
607 use mail_builder::headers::content_type::ContentType;
608 use mail_builder::mime::MimePart;
609 let recipients = draft.recipients();
610 if recipients.is_empty() {
611 return Err(tr.text("compose-no-recipient", None));
612 }
613 let quote = original.and_then(|raw| quote(raw, draft.kind, tr));
614 let html_text = markdown_html(&draft.body);
615 let (text, html) = match "e {
616 Some(q) => (format!("{}\n\n{}", draft.body.trim_end(), q.text), format!("{html_text}\n{}", q.html)),
617 None => (draft.body.clone(), html_text),
618 };
619 let html = format!("<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>{html}</body></html>");
620 let protected = protection.is_some();
623 let text_part = |kind: &'static str, body: String| match protected {
624 true => MimePart::new(ContentType::new(kind).attribute("charset", "utf-8"), quoted_printable(&body)).transfer_encoding("quoted-printable"),
625 false => MimePart::new(kind, body),
626 };
627 let alternative = MimePart::new("multipart/alternative", vec![text_part("text/plain", text), text_part("text/html", html)]);
628 let files = attachments(draft, original)?;
629 let inner = if files.is_empty() {
630 alternative
631 } else {
632 let mut parts = vec![alternative];
633 parts.extend(files.into_iter().map(|(name, mime, bytes)| MimePart::new(mime, bytes).attachment(name)));
634 MimePart::new("multipart/mixed", parts)
635 };
636 let body = match protection {
637 Some(protection) if !protection.encrypt_to.is_empty() || protection.sign.is_some() => {
638 let mut entity = Vec::new();
639 inner.write_part(&mut entity);
640 if entity.ends_with(b"\r\n") {
643 entity.truncate(entity.len() - 2);
644 }
645 if !protection.encrypt_to.is_empty() {
646 let sealed = crate::pgp::encrypt(&entity, &protection.encrypt_to, protection.sign.as_deref_mut())?;
647 MimePart::new(
648 ContentType::new("multipart/encrypted").attribute("protocol", "application/pgp-encrypted"),
649 vec![
650 MimePart::new("application/pgp-encrypted", "Version: 1\r\n".as_bytes()).transfer_encoding("7bit"),
651 MimePart::new(ContentType::new("application/octet-stream").attribute("name", "encrypted.asc"), sealed.into_bytes()).inline().transfer_encoding("7bit"),
652 ],
653 )
654 } else {
655 let signer = protection.sign.as_deref_mut().ok_or("no key to sign with")?;
656 let (signature, micalg) = crate::pgp::sign_detached(&entity, signer)?;
657 MimePart::new(
658 ContentType::new("multipart/signed").attribute("micalg", micalg).attribute("protocol", "application/pgp-signature"),
659 vec![
660 MimePart::raw(entity),
661 MimePart::new(ContentType::new("application/pgp-signature").attribute("name", "signature.asc"), signature.into_bytes()).transfer_encoding("7bit"),
662 ],
663 )
664 }
665 }
666 _ => inner,
667 };
668 let domain = from.1.rsplit_once('@').map_or("localhost", |(_, d)| d);
669 let message_id = format!("{}@{domain}", new_id());
670 let to: Vec<(String, String)> = draft.to.iter().filter_map(|e| Some((name_of(e), address_of(e)?))).collect();
671 let cc: Vec<(String, String)> = draft.cc.iter().filter_map(|e| Some((name_of(e), address_of(e)?))).collect();
672 fn as_pairs(list: &[(String, String)]) -> Vec<(&str, &str)> {
673 list.iter().map(|(n, a)| (n.as_str(), a.as_str())).collect()
674 }
675 let mut message = MessageBuilder::new().from(from).subject(draft.subject.as_str()).message_id(message_id.as_str()).date(date).body(body);
676 if !to.is_empty() {
677 message = message.to(as_pairs(&to));
678 }
679 if !cc.is_empty() {
680 message = message.cc(as_pairs(&cc));
681 }
682 if let Some(reply_to) = &draft.in_reply_to {
683 message = message.in_reply_to(reply_to.as_str());
684 }
685 if !draft.references.is_empty() {
686 message = message.references(draft.references.iter().map(String::as_str).collect::<Vec<_>>());
687 }
688 if let Some(autocrypt) = autocrypt {
689 message = message.header("Autocrypt", mail_builder::headers::raw::Raw::new(autocrypt));
690 }
691 let raw = message.write_to_vec().map_err(|e| e.to_string())?;
692 Ok(Outgoing { raw, from: from.1.to_string(), recipients })
693}
694
695fn quoted_printable(text: &str) -> Vec<u8> {
697 let mut out = Vec::with_capacity(text.len() + text.len() / 8);
698 for (i, line) in text.split('\n').enumerate() {
699 if i > 0 {
700 out.extend_from_slice(b"\r\n");
701 }
702 let line = line.strip_suffix('\r').unwrap_or(line).as_bytes();
703 let mut width = 0;
704 for (j, &byte) in line.iter().enumerate() {
705 let last = j + 1 == line.len();
706 let plain = (byte == b' ' || byte == b'\t') && !last || (33..=126).contains(&byte) && byte != b'=';
707 let piece: Vec<u8> = if plain { vec![byte] } else { format!("={byte:02X}").into_bytes() };
708 if width + piece.len() > 75 {
709 out.extend_from_slice(b"=\r\n");
710 width = 0;
711 }
712 width += piece.len();
713 out.extend_from_slice(&piece);
714 }
715 }
716 out
717}
718
719pub fn invitation_answer(from: (&str, &str), to: &str, subject: &str, text: &str, ics: &str, date: i64) -> Result<Outgoing, String> {
722 use mail_builder::headers::content_type::ContentType;
723 use mail_builder::mime::MimePart;
724 let domain = from.1.rsplit_once('@').map_or("localhost", |(_, d)| d);
725 let message_id = format!("{}@{domain}", new_id());
726 let calendar = ContentType::new("text/calendar").attribute("method", "REPLY").attribute("charset", "utf-8");
727 let body = MimePart::new("multipart/alternative", vec![MimePart::new("text/plain", text), MimePart::new(calendar, ics)]);
728 let raw = MessageBuilder::new()
729 .from(from)
730 .to(to)
731 .subject(subject)
732 .message_id(message_id.as_str())
733 .date(date)
734 .body(body)
735 .write_to_vec()
736 .map_err(|e| e.to_string())?;
737 Ok(Outgoing { raw, from: from.1.to_string(), recipients: vec![to.to_string()] })
738}
739
740fn name_of(entry: &str) -> String {
743 let Some(open) = entry.rfind('<') else { return String::new() };
744 let name = entry[..open].trim();
745 let Some(inner) = name.strip_prefix('"').and_then(|n| n.strip_suffix('"')) else { return name.to_string() };
746 let mut out = String::with_capacity(inner.len());
747 let mut chars = inner.chars();
748 while let Some(c) = chars.next() {
749 out.extend(if c == '\\' { chars.next() } else { Some(c) });
750 }
751 out
752}
753
754fn attachments(draft: &Draft, original: Option<&[u8]>) -> Result<Vec<(String, &'static str, Vec<u8>)>, String> {
756 let mut out = Vec::new();
757 for path in &draft.attachments {
758 let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
759 let name = path.file_name().map_or_else(|| "attachment".to_string(), |n| n.to_string_lossy().to_string());
760 out.push((name.clone(), mime_of(&name), bytes));
761 }
762 if let Some(raw) = original {
763 for (index, _) in draft.forwarded_from(Some(raw)) {
764 if let Some((name, bytes)) = reading::attachment_from(raw, index) {
765 out.push((name.clone(), mime_of(&name), bytes));
766 }
767 }
768 }
769 Ok(out)
770}
771
772pub fn mime_of(name: &str) -> &'static str {
774 let extension = name.rsplit_once('.').map_or(String::new(), |(_, e)| e.to_ascii_lowercase());
775 match extension.as_str() {
776 "pdf" => "application/pdf",
777 "png" => "image/png",
778 "jpg" | "jpeg" => "image/jpeg",
779 "gif" => "image/gif",
780 "webp" => "image/webp",
781 "svg" => "image/svg+xml",
782 "txt" | "md" => "text/plain",
783 "csv" => "text/csv",
784 "html" | "htm" => "text/html",
785 "ics" => "text/calendar",
786 "vcf" => "text/vcard",
787 "zip" => "application/zip",
788 "odt" => "application/vnd.oasis.opendocument.text",
789 "ods" => "application/vnd.oasis.opendocument.spreadsheet",
790 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
791 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
792 "eml" => "message/rfc822",
793 _ => "application/octet-stream",
794 }
795}
796
797struct Quote {
799 text: String,
800 html: String,
801}
802
803fn quote(raw: &[u8], kind: DraftKind, tr: &Translator) -> Option<Quote> {
804 let message = MessageParser::default().parse(raw)?;
805 let reading = reading::read_bytes(raw)?;
806 let sender = message.from().and_then(|a| a.first()).map_or_else(String::new, |p| entry(p.name(), p.address().unwrap_or("")));
807 let date = message.date().map_or_else(String::new, |d| d.to_rfc822());
808 let body_text = message.body_text(0).map(|b| b.into_owned()).unwrap_or_default();
809 let body_html = match &reading.html {
810 Some(h) => format!("{}{}", h.main, h.quoted.clone().unwrap_or_default()),
811 None => reading::linkify(&body_text),
812 };
813 let mut args = i18n::args();
814 args.set("name", sender.clone());
815 args.set("date", date.clone());
816 if kind == DraftKind::Forward {
817 let people = |list: Option<&Address>| list.map(|a| a.iter().map(|p| entry(p.name(), p.address().unwrap_or(""))).collect::<Vec<_>>().join(", ")).unwrap_or_default();
818 let mut fields = vec![
819 (tr.text("compose-from", None), sender),
820 (tr.text("compose-date", None), date),
821 (tr.text("compose-subject", None), message.subject().unwrap_or("").to_string()),
822 (tr.text("compose-to", None), people(message.to())),
823 ];
824 let cc = people(message.cc());
825 if !cc.is_empty() {
826 fields.push((tr.text("compose-cc", None), cc));
827 }
828 let title = tr.text("compose-forwarded", None);
829 let text_fields: String = fields.iter().map(|(n, v)| format!("{n}: {v}\n")).collect();
830 let html_fields: String = fields.iter().map(|(n, v)| format!("<b>{}</b>: {}<br>", escape(n), escape(v))).collect();
831 return Some(Quote {
832 text: format!("{title}\n{text_fields}\n{body_text}"),
833 html: format!("<p>{}<br>{html_fields}</p>\n{body_html}", escape(&title)),
834 });
835 }
836 let attribution = tr.text("compose-attribution", Some(&args));
837 let quoted: String = body_text.lines().map(|l| if l.is_empty() { ">\n".to_string() } else { format!("> {l}\n") }).collect();
838 Some(Quote {
839 text: format!("{attribution}\n{quoted}"),
840 html: format!(
841 "<p>{}</p>\n<blockquote type=\"cite\" style=\"margin:0 0 0 .8ex;border-left:2px solid #999;padding-left:1ex\">{body_html}</blockquote>",
842 escape(&attribution)
843 ),
844 })
845}
846
847fn escape(text: &str) -> String {
848 text.replace('&', "&").replace('<', "<").replace('>', ">")
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854
855 fn original(dir: &Path) -> PathBuf {
856 let path = dir.join("original.eml");
857 let raw = "From: Jane Exemple <jane@example.org>\r\nTo: Me <me@example.net>, Other <other@example.com>\r\n\
858 Cc: me2@example.net\r\nSubject: The lease\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\
859 Message-ID: <lease1@example.org>\r\nReferences: <start@example.org>\r\n\
860 Content-Type: text/plain; charset=utf-8\r\n\r\nHere is the lease.\r\nSee https://example.org/lease\r\n";
861 std::fs::write(&path, raw).unwrap();
862 path
863 }
864
865 #[test]
866 fn answers_are_prefilled() {
867 let dir = std::env::temp_dir().join(format!("sioul-compose-{}", std::process::id()));
868 std::fs::create_dir_all(&dir).unwrap();
869 let path = original(&dir);
870 let own = vec!["me@example.net".to_string(), "me2@example.net".to_string()];
871 let reply = answer(&path, DraftKind::Reply, "me", &own).unwrap();
872 assert_eq!((reply.to.clone(), reply.subject.as_str()), (vec!["Jane Exemple <jane@example.org>".to_string()], "Re: The lease"));
873 assert_eq!(reply.in_reply_to.as_deref(), Some("lease1@example.org"));
874 assert_eq!(reply.references, vec!["start@example.org".to_string(), "lease1@example.org".to_string()]);
875 let all = answer(&path, DraftKind::ReplyAll, "me", &own).unwrap();
876 assert_eq!(all.cc, vec!["Other <other@example.com>".to_string()]);
877 let forward = answer(&path, DraftKind::Forward, "me", &own).unwrap();
878 assert!(forward.to.is_empty() && forward.subject == "Fwd: The lease");
879 std::fs::remove_dir_all(&dir).unwrap();
880 }
881
882 #[test]
883 fn markdown_goes_out_as_html_with_the_quote_below() {
884 let dir = std::env::temp_dir().join(format!("sioul-build-{}", std::process::id()));
885 std::fs::create_dir_all(&dir).unwrap();
886 let path = original(&dir);
887 let mut draft = answer(&path, DraftKind::Reply, "me", &["me@example.net".to_string()]).unwrap();
888 draft.body = "Thanks, **received**.\n\n- signed\n- dated".into();
889 let tr = Translator::new("en");
890 let out = build(&draft, ("Me", "me@example.net"), &tr, 1_790_000_000).unwrap();
891 let text = String::from_utf8_lossy(&out.raw).to_string();
892 let parsed = MessageParser::default().parse(&out.raw).unwrap();
893 let html = parsed.body_html(0).unwrap().to_string();
894 let plain = parsed.body_text(0).unwrap().to_string();
895 assert!(html.find("<strong>received</strong>").unwrap() < html.find("<blockquote").unwrap(), "{html}");
896 assert!(html.contains("<li>signed</li>") && html.contains("Here is the lease."), "{html}");
897 assert!(plain.starts_with("Thanks, **received**.") && plain.contains("> Here is the lease."), "{plain}");
898 assert!(text.contains("In-Reply-To: <lease1@example.org>"), "{text}");
899 assert_eq!(out.recipients, vec!["jane@example.org".to_string()]);
900 assert!(parsed.message_id().unwrap().ends_with("@example.net"));
901 std::fs::remove_dir_all(&dir).unwrap();
902 }
903
904 #[test]
905 fn line_breaks_and_signature_go_out_as_written() {
906 let html = markdown_html("Hello,\nsee you soon.");
907 assert!(html.contains("Hello,<br />"), "{html}");
908 let mut draft = Draft::new("me");
909 draft.add_signature(Some("Me\nPhotographer"));
910 assert!(draft.is_empty(Some("Me\nPhotographer")));
911 draft.body = format!("Thanks.{}", draft.body);
912 draft.add_signature(Some("Me\nPhotographer"));
913 assert_eq!(draft.body, "Thanks.\n\n-- \nMe\nPhotographer");
914 assert!(!draft.is_empty(Some("Me\nPhotographer")));
915 let html = markdown_html(&draft.body);
916 assert!(html.contains("<p>--<br />\nMe<br />\nPhotographer</p>"), "{html}");
917 }
918
919 #[test]
920 fn answering_your_own_message_goes_where_it_went() {
921 let dir = std::env::temp_dir().join(format!("sioul-own-{}", std::process::id()));
922 std::fs::create_dir_all(&dir).unwrap();
923 let path = dir.join("sent.eml");
924 std::fs::write(&path, "From: Me <me@example.net>\r\nTo: Jane <jane@example.org>\r\nCc: Paul <paul@example.org>\r\nSubject: Hi\r\n\r\nHi.\r\n").unwrap();
925 let own = vec!["me@example.net".to_string()];
926 let reply = answer(&path, DraftKind::ReplyAll, "me", &own).unwrap();
927 assert_eq!(reply.to, vec!["Jane <jane@example.org>".to_string()]);
928 assert_eq!(reply.cc, vec!["Paul <paul@example.org>".to_string()]);
929 std::fs::remove_dir_all(&dir).unwrap();
930 }
931
932 #[test]
933 fn protected_messages_open_again() {
934 use sequoia_openpgp::cert::prelude::*;
935 let make = |address: &str| {
936 CertBuilder::general_purpose(Some(format!("<{address}>"))).set_cipher_suite(CipherSuite::Cv25519).generate().unwrap().0
937 };
938 let (me, jane) = (make("me@example.net"), make("jane@example.org"));
939 let mut draft = Draft::new("me");
940 draft.to = vec!["Jane <jane@example.org>".into()];
941 draft.subject = "Keys".into();
942 draft.body = "Hello,\nhere it is.\n\n-- \nMe".into();
943 let tr = Translator::new("en");
944 let mut signer = crate::pgp::here_signer(&me, "").unwrap();
945 let signed = build_with(&draft, ("Me", "me@example.net"), &tr, 1_790_000_000, Some(&mut Protection { sign: Some(&mut signer), encrypt_to: vec![] }), None, None).unwrap();
946 let keys = crate::pgp::Keys { others: vec![me.clone().strip_secret_key_material()], ..crate::pgp::Keys::default() };
947 let none = |_: &sequoia_openpgp::Fingerprint| None;
948 let sessions = crate::pgp::SessionKeys::new();
949 let (_, view) = crate::pgp::open(&signed.raw, &keys, &crate::pgp::Unlock::new(&none, &sessions)).unwrap();
950 assert!(!view.encrypted && view.signatures.len() == 1 && view.signatures[0].good, "{view:?}");
951 let sealed = build_with(&draft, ("Me", "me@example.net"), &tr, 1_790_000_000, Some(&mut Protection { sign: Some(&mut signer), encrypt_to: vec![&jane, &me] }), Some("addr=me@example.net; keydata=AAAA"), None).unwrap();
952 let text = String::from_utf8_lossy(&sealed.raw).to_string();
953 assert!(text.contains("multipart/encrypted") && !text.contains("here it is") && text.contains("Autocrypt: addr=me@example.net"), "{text}");
954 let jane_keys = crate::pgp::Keys { own: vec![jane.clone()], others: vec![me.clone().strip_secret_key_material()], ..crate::pgp::Keys::default() };
955 let (opened, view) = crate::pgp::open(&sealed.raw, &jane_keys, &crate::pgp::Unlock::new(&none, &sessions)).unwrap();
956 let opened = opened.unwrap();
957 let parsed = MessageParser::default().parse(&opened).unwrap();
958 assert_eq!(parsed.subject(), Some("Keys"));
959 assert!(parsed.body_text(0).unwrap().replace("\r\n", "\n").contains("here it is.\n\n-- \nMe"), "{:?}", parsed.body_text(0));
961 assert!(view.encrypted && view.opened && view.signatures[0].good, "{view:?}");
962 assert_eq!(quoted_printable("a = b \nnext"), b"a =3D b=20\r\nnext".to_vec());
963 }
964
965 #[test]
966 fn addresses_as_typed() {
967 assert_eq!(split_addresses("a@example.org; Jane <j@example.org>,"), vec!["a@example.org", "Jane <j@example.org>"]);
968 assert_eq!(address_of("Jane <j@example.org>").as_deref(), Some("j@example.org"));
969 assert_eq!(address_of("not an address"), None);
970 assert_eq!(prefixed("RE: x", "Re:", &["re:".to_string()]), "RE: x");
971 assert_eq!(prefixed("WG: Termin", "Fwd:", &crate::words::Words::builtin().replies.forward), "Fwd: WG: Termin");
973 assert_eq!(prefixed("WG: Termin", "Fwd:", &["wg:".to_string()]), "WG: Termin");
974 assert_eq!(prefixed("RÉ : x", "Re:", &["ré :".to_string()]), "RÉ : x");
975 assert_eq!(mime_of("Bail.PDF"), "application/pdf");
976 }
977
978 #[test]
979 fn a_name_with_a_comma_is_one_recipient() {
980 assert_eq!(entry(Some("Doe, Jane"), "jane@example.org"), "\"Doe, Jane\" <jane@example.org>");
981 assert_eq!(entry(Some("Jane \"JJ\" Doe"), "jane@example.org"), "\"Jane \\\"JJ\\\" Doe\" <jane@example.org>");
982 assert_eq!(entry(Some("Jane Doe"), "jane@example.org"), "Jane Doe <jane@example.org>");
983 let field = "\"Doe, Jane\" <jane@example.org>, bob@example.org; \"x;y\" <x@example.org>\nPaul <paul@example.org>";
984 let split = split_addresses(field);
985 assert_eq!(split, vec!["\"Doe, Jane\" <jane@example.org>", "bob@example.org", "\"x;y\" <x@example.org>", "Paul <paul@example.org>"]);
986 assert_eq!((name_of(&split[0]), address_of(&split[0]).as_deref()), ("Doe, Jane".to_string(), Some("jane@example.org")));
987 assert_eq!(name_of(&entry(Some("Jane \"JJ\" Doe"), "j@example.org")), "Jane \"JJ\" Doe");
988 assert_eq!(split_addresses("\"Doe, Jane <jane@example.org>, bob@example.org"), vec!["\"Doe", "Jane <jane@example.org>", "bob@example.org"]);
990 let dir = std::env::temp_dir().join(format!("sioul-names-{}", std::process::id()));
992 std::fs::create_dir_all(&dir).unwrap();
993 let path = dir.join("comma.eml");
994 std::fs::write(&path, "From: \"Doe, Jane\" <jane@example.org>\r\nTo: me@example.net\r\nSubject: Hi\r\n\r\nHi.\r\n").unwrap();
995 let mut reply = answer(&path, DraftKind::Reply, "me", &["me@example.net".to_string()]).unwrap();
996 assert_eq!(reply.to, vec!["\"Doe, Jane\" <jane@example.org>".to_string()]);
997 reply.to = split_addresses(&reply.to.join(", "));
998 reply.body = "Hello.".into();
999 let out = build(&reply, ("Me", "me@example.net"), &Translator::new("en"), 1_790_000_000).unwrap();
1000 let parsed = MessageParser::default().parse(&out.raw).unwrap();
1001 let to: Vec<(Option<&str>, Option<&str>)> = parsed.to().unwrap().iter().map(|p| (p.name(), p.address())).collect();
1002 assert_eq!(to, vec![(Some("Doe, Jane"), Some("jane@example.org"))]);
1003 std::fs::remove_dir_all(&dir).unwrap();
1004 }
1005
1006 #[test]
1007 fn html_in_the_text_is_shown_not_read() {
1008 let html = markdown_html("> <img src=\"https://tracker.example/p.gif\" width=\"1\"> and <a href=\"https://example.org\">x</a>");
1010 assert!(!html.contains("<img") && !html.contains("<a ") && html.contains("<img src="), "{html}");
1011 let html = markdown_html("<div>\n<img src=\"https://tracker.example/p.gif\">\n</div>\n\nAfter.");
1012 assert!(!html.contains("<img") && !html.contains("<div") && html.contains("<p>After.</p>"), "{html}");
1013 let html = markdown_html(" ");
1015 assert!(!html.contains("<img") && html.contains("<a href=\"https://example.org/c.png\">chart</a>"), "{html}");
1016 assert!(html.contains(">//example.org/d.png</a>"), "{html}");
1017 let html = markdown_html("<img src=\"file:///notes/photo.png\" width=\"560\" height=\"420\" onerror=\"x\">\n\n <b>bold</b><br>");
1019 assert!(html.contains("<img src=\"file:///notes/photo.png\" width=\"560\" height=\"420\">"), "{html}");
1020 assert!(html.contains("<img src=\"photo.png\" alt=\"x\"") && html.contains("<b>bold</b><br>"), "{html}");
1021 for far in ["file://server/share/p.png", "\\\\server\\share\\p.png", "file:////server/p.png", "//server/p.png", "smb://server/p.png", "https://example.org/p.png", "data:text/html,x", "/%2F/server/p.png", ""] {
1022 assert!(!local_source(far), "{far}");
1023 }
1024 for near in ["photo.png", "../pictures/p.png", "/notes/p.png", "file:///C:/Notes/p.png", "file://localhost/notes/p.png", "data:image/png;base64,AAAA"] {
1025 assert!(local_source(near), "{near}");
1026 }
1027 }
1028
1029 #[test]
1030 fn copies_from_other_applications_go_with_their_draft() {
1031 let root = std::env::temp_dir().join(format!("sioul-own-copies-{}", std::process::id()));
1032 let copies = root.join("files");
1033 let shared = copies.join("r1");
1034 std::fs::create_dir_all(&shared).unwrap();
1035 let (photo, pdf, yours) = (shared.join("photo.jpg"), shared.join("lease.pdf"), root.join("yours.txt"));
1036 for path in [&photo, &pdf, &yours] {
1037 std::fs::write(path, b"x").unwrap();
1038 }
1039 assert!(!own_copy_in(&copies, &yours) && !own_copy_in(&copies, &shared.join("..").join("..").join("yours.txt")));
1041 remove_own_copy_in(&copies, &yours);
1042 assert!(yours.exists());
1043 remove_own_copy_in(&copies, &photo);
1045 assert!(!photo.exists() && shared.exists());
1046 remove_own_copy_in(&copies, &pdf);
1047 assert!(!shared.exists() && copies.exists());
1048 std::fs::remove_dir_all(&root).unwrap();
1049 }
1050}