Skip to main content

sioul_core/
compose.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Writing: Markdown in, mail out.
5//!
6//! You write Markdown. The message goes out as `multipart/alternative`: the
7//! Markdown itself as plain text, readable as it is, and its HTML (CommonMark
8//! with tables and strikethrough). A reply or a forward appends the message it
9//! answers below your text, after your text was converted: in HTML, the
10//! original made safe (`reading::safe_html`) in a blockquote; in plain text,
11//! its lines quoted with "> ". `In-Reply-To` and `References` keep the thread
12//! together in every client (RFC 5322 §3.6.4).
13//!
14//! A draft is a small TOML file, saved as you type, so closing the window
15//! never loses anything (docs/client.md, "Calm first").
16
17use 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/// A message being written.
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct Draft {
38    /// The draft's own name, to save it in place.
39    #[serde(default)]
40    pub id: String,
41    /// The account it goes out from.
42    #[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    /// What you write, in Markdown.
53    #[serde(default)]
54    pub body: String,
55    /// Files to attach.
56    #[serde(default)]
57    pub attachments: Vec<PathBuf>,
58    #[serde(default)]
59    pub kind: DraftKind,
60    /// The message answered or forwarded, quoted below the text when sent.
61    #[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    /// For a forward: the original's attachments you left out, by index.
68    #[serde(default)]
69    pub dropped: Vec<u32>,
70    /// Signed with your OpenPGP key.
71    #[serde(default)]
72    pub sign: bool,
73    /// Encrypted to every recipient's key.
74    #[serde(default)]
75    pub encrypt: bool,
76    /// What it belongs with: a task, an event, a note (`links`).
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub links: Vec<String>,
79    /// Its copy of the message answered or forwarded was kept as it came
80    /// ([`Draft::keep_original`], from 19 October 2026): never a decrypted
81    /// one, so the look at the copies written before (`draftcopies`) passes
82    /// it over wherever the draft goes.
83    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
84    pub copy_as_received: bool,
85}
86
87/// The line above a signature (RFC 3676 §4.3): readers recognise and fold what follows.
88pub const SIGNATURE_LINE: &str = "-- ";
89
90impl Draft {
91    /// A new draft with its own name.
92    pub fn new(account: &str) -> Draft {
93        Draft { id: new_id(), account: account.to_string(), ..Draft::default() }
94    }
95
96    /// The folder drafts are kept in.
97    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    /// Every saved draft, the newest first.
121    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    /// Removes the draft once sent, or when you discard it, with its copy of
134    /// the original and the files Sioul copied for it from another
135    /// application (`handed`): yours, where they came from, stay.
136    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    /// The copy of the message answered or forwarded, here: where the draft
149    /// says, else this device's copy beside it (a draft made on another
150    /// device names that device's folder).
151    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    /// Keeps a copy of the message answered or forwarded next to the draft: it
161    /// may be archived, deleted or renamed before the answer leaves.
162    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, &copy).map_err(|e| format!("{}: {e}", original.display()))?;
170        self.original = Some(copy);
171        self.copy_as_received = true;
172        Ok(())
173    }
174
175    /// A saved draft by its name; none for a name that is not one of Sioul's.
176    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    /// Every recipient, for the envelope: To, Cc and Bcc.
184    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    /// Puts the account's signature below the text, once, after the usual "-- " line.
189    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    /// Whether anything was written: an untouched draft is not worth keeping.
197    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    /// The original's attachments a forward carries, by index and name; none
203    /// while it is encrypted (opened only at Send: [`Draft::forwarded_from`]).
204    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    /// The same from the original as it reads (`original`: opened in memory).
210    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
217/// A new message's own name, the left part of its Message-ID: unique to this moment and this program.
218pub fn new_message_id() -> String {
219    new_id()
220}
221
222/// A name unique enough for a draft or a Message-ID: the time, and a little of the process.
223fn 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
232/// Whether `path` is one of the files Sioul copied for a draft from another
233/// application (`handed`), which go with their draft.
234pub 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
243/// One of those copies removed, with its request's folder once empty;
244/// nothing for any other file.
245pub 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        // Only when empty: another of its files may still be attached.
256        let _ = std::fs::remove_dir(folder);
257    }
258}
259
260/// "Jane <jane@example.org>" or "jane@example.org" → "jane@example.org".
261pub 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
272/// Splits what was typed in an address field: commas, semicolons and line
273/// breaks separate, but not inside a quoted name or between "<" and ">"
274/// ("\"Doe, Jane\" <jane@example.org>" is one recipient). A line whose quote
275/// or "<" is left open splits at every comma and semicolon, so no one is lost.
276pub 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
304/// A reply, a reply to all or a forward, from the message at `original`:
305/// recipients, subject and thread prefilled, the text left to you.
306/// `own` holds your addresses, never answered to.
307pub 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    // Your own message (in Sent): the answer goes where it went.
317    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
367/// A name as an address field writes it (RFC 5322 §3.2.5): as it is when it
368/// holds only words, else between quotes, its quotes and backslashes escaped,
369/// so that "Doe, Jane" stays one name and never ends the field or the address.
370pub 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
375/// "Re: Subject", once only: "Re: Re: Re:" helps nobody. `known`: the
376/// prefixes your correspondents' mail programs write (`words::Replies`:
377/// "TR:", "AW:"…), capitals and accents aside.
378fn 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
383/// CommonMark with tables and strikethrough, as HTML. A line break typed in a
384/// mail is meant, as in a chat or a GitHub comment: it is kept.
385///
386/// HTML written in the text (a note quoting a mail, a draft) shows as it was
387/// written, never read: only marks of plain formatting (`<b>`, `<br>`) and
388/// pictures of this computer or of the text itself stay, as the pictures a
389/// vault's notes embed (`Vault::with_links`). A picture from elsewhere
390/// ("![](https://…)", a tracking pixel) is a link to it: showing the text
391/// fetches nothing.
392pub 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    // Links open around here, the lines of a block of HTML so far, and a
397    // picture from elsewhere being written: its address, whether it has words,
398    // whether it is a link of its own (in a link, its words are enough).
399    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
452/// Marks of plain formatting kept from HTML written in Markdown: they fetch nothing.
453const PLAIN_MARKS: &[&str] = &["b", "i", "u", "s", "em", "strong", "del", "sub", "sup", "mark", "kbd", "br"];
454
455/// One piece of HTML written in Markdown: a mark of plain formatting, or a
456/// picture of this computer rebuilt from its address, size and words alone;
457/// anything else as text, shown as it was written.
458fn 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
466/// "<b>", "</b>", "<br/>": a mark of `PLAIN_MARKS`, without attributes.
467fn 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
474/// "<img src=… width=… height=… alt=…>" whose picture is on this computer or
475/// in the text itself (`local_source`); its other attributes are left out.
476fn 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
498/// A tag's attributes after its name, names lowercase; none when the text is
499/// not one tag's attributes (another tag, a stray quote).
500fn 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(&quote) 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
550/// Whether a picture's address stays on this computer: a path (from the
551/// note's folder, or from the root), a `file:` address without a host, or the
552/// picture itself (`data:image/…`). Never "//host/…", "\\host\…" nor
553/// "file://host/…", which reach a server (on Windows, with your credentials).
554fn 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    // An address with a scheme ("https:", "smb:", "c:") is not a path.
567    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('"', "&quot;")
573}
574
575/// What goes out, besides the bytes: who it is from and to whom it goes.
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct Outgoing {
578    pub raw: Vec<u8>,
579    pub from: String,
580    pub recipients: Vec<String>,
581}
582
583/// The message as it goes out, from a draft: your text, then what it quotes or
584/// forwards, the attachments, the thread. `from` is the account's name and address.
585pub fn build(draft: &Draft, from: (&str, &str), tr: &Translator, date: i64) -> Result<Outgoing, String> {
586    // An encrypted original is never quoted unprotected.
587    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
591/// How a message is protected with OpenPGP: signed with your key, encrypted
592/// to these keys (the recipients' and yours, to read it in Sent).
593pub struct Protection<'a> {
594    /// Your key's signer: a key here, opened with its passphrase
595    /// (`pgp::here_signer`), or a security key's (`securitykey::CardSigner`).
596    pub sign: Option<&'a mut (dyn sequoia_openpgp::crypto::Signer + Send + Sync)>,
597    pub encrypt_to: Vec<&'a sequoia_openpgp::Cert>,
598}
599
600/// The message as it goes out, protected when asked: PGP/MIME (RFC 3156),
601/// multipart/signed for a signature alone, multipart/encrypted otherwise,
602/// the signature then inside. `autocrypt` is your Autocrypt header's value.
603/// `original`: the message answered or forwarded as it reads (opened in
604/// memory when it came encrypted), quoted below the text, a forward's
605/// attachments taken from it; none, nothing of it goes.
606pub 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 &quote {
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    // Signed text must come out exactly as it went in: quoted-printable keeps
621    // trailing spaces (the "-- " line) and long lines from being changed on the way.
622    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            // The line break before the next boundary belongs to the boundary (RFC 2046 §5.1.1):
641            // what is signed ends without it, as receivers cut it.
642            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
695/// Quoted-printable (RFC 2045 §6.7), lines kept, CRLF, soft breaks before 76 characters.
696fn 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
719/// An answer to an invitation (iMIP, RFC 6047 §2.4): a sentence for people,
720/// and the iTIP REPLY for their calendar, as two versions of one message.
721pub 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
740/// The name part of "Jane <jane@example.org>", else empty; a quoted name
741/// ("\"Doe, Jane\" <…>") as it reads, without its quotes and escapes.
742fn 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
754/// The files attached by hand, then, for a forward, the original's attachments.
755fn 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
772/// The media type from a file's extension; unknown ones go as plain bytes.
773pub 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
797/// The message answered or forwarded, below your text.
798struct 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
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        // The "-- " line keeps its space through quoted-printable.
960        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        // A German forward, once its prefix is added; accents and capitals aside.
972        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        // A quote left open: every comma separates, as before; no one is lost.
989        assert_eq!(split_addresses("\"Doe, Jane <jane@example.org>, bob@example.org"), vec!["\"Doe", "Jane <jane@example.org>", "bob@example.org"]);
990        // Answered, saved by the window (joined, split again), sent: still one person.
991        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        // A note quoting a mail: its HTML shows as it was written, nothing loads.
1009        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("&lt;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        // A picture from elsewhere is a link to it, by its words or its address.
1014        let html = markdown_html("![chart](https://example.org/c.png) ![](//example.org/d.png)");
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        // Pictures of the vault, plain marks: kept.
1018        let html = markdown_html("<img src=\"file:///notes/photo.png\" width=\"560\" height=\"420\" onerror=\"x\">\n\n![x](photo.png) <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        // Yours, or a way out of the folder: never.
1040        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        // One taken off: its folder stays while another file is in it.
1044        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}