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}
80
81/// The line above a signature (RFC 3676 §4.3): readers recognise and fold what follows.
82pub const SIGNATURE_LINE: &str = "-- ";
83
84impl Draft {
85    /// A new draft with its own name.
86    pub fn new(account: &str) -> Draft {
87        Draft { id: new_id(), account: account.to_string(), ..Draft::default() }
88    }
89
90    /// The folder drafts are kept in.
91    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    /// Every saved draft, the newest first.
115    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    /// Removes the draft once sent, or when you discard it, with its copy of
128    /// the original and the files Sioul copied for it from another
129    /// application (`handed`): yours, where they came from, stay.
130    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    /// Keeps a copy of the message answered or forwarded next to the draft: it
143    /// may be archived, deleted or renamed before the answer leaves.
144    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, &copy).map_err(|e| format!("{}: {e}", original.display()))?;
152        self.original = Some(copy);
153        Ok(())
154    }
155
156    /// A saved draft by its name; none for a name that is not one of Sioul's.
157    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    /// Every recipient, for the envelope: To, Cc and Bcc.
165    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    /// Puts the account's signature below the text, once, after the usual "-- " line.
170    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    /// Whether anything was written: an untouched draft is not worth keeping.
178    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    /// The original's attachments a forward carries, by index and name.
184    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
191/// A new message's own name, the left part of its Message-ID: unique to this moment and this program.
192pub fn new_message_id() -> String {
193    new_id()
194}
195
196/// A name unique enough for a draft or a Message-ID: the time, and a little of the process.
197fn 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
206/// Whether `path` is one of the files Sioul copied for a draft from another
207/// application (`handed`), which go with their draft.
208pub 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
217/// One of those copies removed, with its request's folder once empty;
218/// nothing for any other file.
219pub 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        // Only when empty: another of its files may still be attached.
230        let _ = std::fs::remove_dir(folder);
231    }
232}
233
234/// "Jane <jane@example.org>" or "jane@example.org" → "jane@example.org".
235pub 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
246/// Splits what was typed in an address field: commas, semicolons and line
247/// breaks separate, but not inside a quoted name or between "<" and ">"
248/// ("\"Doe, Jane\" <jane@example.org>" is one recipient). A line whose quote
249/// or "<" is left open splits at every comma and semicolon, so no one is lost.
250pub 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
278/// A reply, a reply to all or a forward, from the message at `original`:
279/// recipients, subject and thread prefilled, the text left to you.
280/// `own` holds your addresses, never answered to.
281pub 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    // Your own message (in Sent): the answer goes where it went.
291    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
341/// A name as an address field writes it (RFC 5322 §3.2.5): as it is when it
342/// holds only words, else between quotes, its quotes and backslashes escaped,
343/// so that "Doe, Jane" stays one name and never ends the field or the address.
344pub 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
349/// "Re: Subject", once only: "Re: Re: Re:" helps nobody. `known`: the
350/// prefixes your correspondents' mail programs write (`words::Replies`:
351/// "TR:", "AW:"…), capitals and accents aside.
352fn 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
357/// CommonMark with tables and strikethrough, as HTML. A line break typed in a
358/// mail is meant, as in a chat or a GitHub comment: it is kept.
359///
360/// HTML written in the text (a note quoting a mail, a draft) shows as it was
361/// written, never read: only marks of plain formatting (`<b>`, `<br>`) and
362/// pictures of this computer or of the text itself stay, as the pictures a
363/// vault's notes embed (`Vault::with_links`). A picture from elsewhere
364/// ("![](https://…)", a tracking pixel) is a link to it: showing the text
365/// fetches nothing.
366pub 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    // Links open around here, the lines of a block of HTML so far, and a
371    // picture from elsewhere being written: its address, whether it has words,
372    // whether it is a link of its own (in a link, its words are enough).
373    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
426/// Marks of plain formatting kept from HTML written in Markdown: they fetch nothing.
427const PLAIN_MARKS: &[&str] = &["b", "i", "u", "s", "em", "strong", "del", "sub", "sup", "mark", "kbd", "br"];
428
429/// One piece of HTML written in Markdown: a mark of plain formatting, or a
430/// picture of this computer rebuilt from its address, size and words alone;
431/// anything else as text, shown as it was written.
432fn 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
440/// "<b>", "</b>", "<br/>": a mark of `PLAIN_MARKS`, without attributes.
441fn 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
448/// "<img src=… width=… height=… alt=…>" whose picture is on this computer or
449/// in the text itself (`local_source`); its other attributes are left out.
450fn 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
472/// A tag's attributes after its name, names lowercase; none when the text is
473/// not one tag's attributes (another tag, a stray quote).
474fn 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(&quote) 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
524/// Whether a picture's address stays on this computer: a path (from the
525/// note's folder, or from the root), a `file:` address without a host, or the
526/// picture itself (`data:image/…`). Never "//host/…", "\\host\…" nor
527/// "file://host/…", which reach a server (on Windows, with your credentials).
528fn 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    // An address with a scheme ("https:", "smb:", "c:") is not a path.
541    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('"', "&quot;")
547}
548
549/// What goes out, besides the bytes: who it is from and to whom it goes.
550#[derive(Debug, Clone, PartialEq, Eq)]
551pub struct Outgoing {
552    pub raw: Vec<u8>,
553    pub from: String,
554    pub recipients: Vec<String>,
555}
556
557/// The message as it goes out, from a draft: your text, then what it quotes or
558/// forwards, the attachments, the thread. `from` is the account's name and address.
559pub 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
563/// How a message is protected with OpenPGP: signed with your key, encrypted
564/// to these keys (the recipients' and yours, to read it in Sent).
565pub struct Protection<'a> {
566    /// Your key's signer: a key here, opened with its passphrase
567    /// (`pgp::here_signer`), or a security key's (`securitykey::CardSigner`).
568    pub sign: Option<&'a mut (dyn sequoia_openpgp::crypto::Signer + Send + Sync)>,
569    pub encrypt_to: Vec<&'a sequoia_openpgp::Cert>,
570}
571
572/// The message as it goes out, protected when asked: PGP/MIME (RFC 3156),
573/// multipart/signed for a signature alone, multipart/encrypted otherwise,
574/// the signature then inside. `autocrypt` is your Autocrypt header's value.
575pub 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 &quote {
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    // Signed text must come out exactly as it went in: quoted-printable keeps
590    // trailing spaces (the "-- " line) and long lines from being changed on the way.
591    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            // The line break before the next boundary belongs to the boundary (RFC 2046 §5.1.1):
610            // what is signed ends without it, as receivers cut it.
611            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
664/// Quoted-printable (RFC 2045 §6.7), lines kept, CRLF, soft breaks before 76 characters.
665fn 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
688/// An answer to an invitation (iMIP, RFC 6047 §2.4): a sentence for people,
689/// and the iTIP REPLY for their calendar, as two versions of one message.
690pub 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
709/// The name part of "Jane <jane@example.org>", else empty; a quoted name
710/// ("\"Doe, Jane\" <…>") as it reads, without its quotes and escapes.
711fn 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
723/// The files attached by hand, then, for a forward, the original's attachments.
724fn 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
741/// The media type from a file's extension; unknown ones go as plain bytes.
742pub 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
766/// The message answered or forwarded, below your text.
767struct 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
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        // The "-- " line keeps its space through quoted-printable.
930        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        // A German forward, once its prefix is added; accents and capitals aside.
942        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        // A quote left open: every comma separates, as before; no one is lost.
959        assert_eq!(split_addresses("\"Doe, Jane <jane@example.org>, bob@example.org"), vec!["\"Doe", "Jane <jane@example.org>", "bob@example.org"]);
960        // Answered, saved by the window (joined, split again), sent: still one person.
961        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        // A note quoting a mail: its HTML shows as it was written, nothing loads.
979        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("&lt;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        // A picture from elsewhere is a link to it, by its words or its address.
984        let html = markdown_html("![chart](https://example.org/c.png) ![](//example.org/d.png)");
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        // Pictures of the vault, plain marks: kept.
988        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>");
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        // Yours, or a way out of the folder: never.
1010        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        // One taken off: its folder stays while another file is in it.
1014        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}