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