Skip to main content

sioul_core/
handed.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What other applications hand Sioul to write: files and text shared to it
5//! (Android's share sheet, or one of your addresses straight from it), a
6//! `mailto:` link (Android, or the computer's mail program). Each becomes a
7//! draft, its window opened; nothing is sent until you press Send
8//! (docs/client.md, "Writing from other applications").
9//!
10//! A request waits in `handed/incoming/` (in Sioul's data folder) until the
11//! window takes it: `<id>.json`, the request ([`Handed`]), written once and
12//! whole by whoever hands it (Android's ShareActivity.java, a second
13//! `sioul-app mailto:…`); `<id>.done.json` ([`Arrived`]), the files copied for
14//! it, written once they all are; `<id>.draft` ([`Mark`]), the draft made from
15//! it while its files are still coming. The files are Sioul's own copies, in
16//! `handed/files/<id>/`: they go with the draft when it is sent or discarded
17//! (`Draft::discard`). Neither travels to your other devices: the sharing
18//! carries the drafts, not this folder, and a request is this device's.
19
20use crate::compose::{Draft, split_addresses};
21use crate::config::data_dir;
22use crate::config::{Account, Config};
23use crate::mailto;
24use serde::{Deserialize, Serialize};
25use std::path::{Component, Path, PathBuf};
26use std::time::{Duration, SystemTime};
27
28/// A message another application asks Sioul to write.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Handed {
31    /// Its own name, the name of its files' folder too: letters, digits and "-".
32    pub id: String,
33    /// The account to write from (one of your addresses chosen in Android's
34    /// share sheet), by its id or its address; "" for Sioul's usual one.
35    #[serde(default)]
36    pub account: String,
37    /// A `mailto:` address, as it came (RFC 6068, `mailto::parse`).
38    #[serde(default)]
39    pub mailto: String,
40    #[serde(default)]
41    pub to: Vec<String>,
42    #[serde(default)]
43    pub cc: Vec<String>,
44    #[serde(default)]
45    pub bcc: Vec<String>,
46    #[serde(default)]
47    pub subject: String,
48    /// The text shared, put in the message's text.
49    #[serde(default)]
50    pub text: String,
51    /// How many files are being copied for it; they come as `<id>.done.json`.
52    #[serde(default)]
53    pub files: u32,
54    /// The process copying them: another one, and they will never all come.
55    #[serde(default)]
56    pub copier: u32,
57    /// Files not taken at all, by name: one named by its path on the phone
58    /// (any application could name Sioul's own private files that way), or
59    /// one of Sioul's own.
60    #[serde(default)]
61    pub refused: Vec<String>,
62}
63
64impl Handed {
65    /// A request of this process, named after the moment it came.
66    pub fn new() -> Handed {
67        Handed { id: new_id(), copier: std::process::id(), ..Handed::default() }
68    }
69}
70
71/// The files copied for a request.
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Arrived {
74    #[serde(default)]
75    pub files: Vec<ArrivedFile>,
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ArrivedFile {
80    /// Its name, as the application that shared it gave it.
81    #[serde(default)]
82    pub name: String,
83    /// Where it was copied, in its request's folder; "" when it could not be.
84    #[serde(default)]
85    pub path: String,
86    /// Why it could not be, in the system's words.
87    #[serde(default)]
88    pub error: String,
89    /// Copied only in part: the copy stopped with Sioul.
90    #[serde(default)]
91    pub stopped: bool,
92}
93
94/// A request taken, its files still coming: the draft made from it.
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Mark {
97    /// The request's id.
98    #[serde(default)]
99    pub id: String,
100    pub draft: String,
101    #[serde(default)]
102    pub copier: u32,
103    #[serde(default)]
104    pub files: u32,
105}
106
107/// A name for a request: the moment, and a little of the process, as drafts' names are.
108fn new_id() -> String {
109    let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
110    format!("{:x}-{:x}-{:x}", now.as_secs(), now.subsec_nanos(), std::process::id())
111}
112
113/// Whether `id` can name a request: letters, digits and "-", nothing that
114/// leaves its folder.
115pub fn valid_id(id: &str) -> bool {
116    !id.is_empty() && id.len() <= 80 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
117}
118
119/// The folders of requests and of their files.
120#[derive(Debug, Clone)]
121pub struct Incoming {
122    root: PathBuf,
123}
124
125impl Incoming {
126    /// Those of this profile: `handed/` in Sioul's data folder.
127    pub fn here() -> Incoming {
128        Incoming { root: data_dir().join("handed") }
129    }
130
131    /// Those in `root` (tests).
132    pub fn at(root: &Path) -> Incoming {
133        Incoming { root: root.to_path_buf() }
134    }
135
136    fn requests(&self) -> PathBuf {
137        self.root.join("incoming")
138    }
139
140    /// Where the files of every request are copied, each in a folder of its own.
141    pub fn files(&self) -> PathBuf {
142        self.root.join("files")
143    }
144
145    fn named(&self, id: &str, suffix: &str) -> PathBuf {
146        self.requests().join(format!("{id}{suffix}"))
147    }
148
149    /// Where the files of request `id` are copied.
150    pub fn files_of(&self, id: &str) -> PathBuf {
151        self.files().join(id)
152    }
153
154    /// A request handed for the window to take: written whole or not at all.
155    pub fn hand(&self, handed: &Handed) -> Result<PathBuf, String> {
156        if !valid_id(&handed.id) {
157            return Err(format!("{}: not a request's name", handed.id));
158        }
159        let path = self.named(&handed.id, ".json");
160        write_whole(&path, &serde_json::to_string(handed).map_err(|e| e.to_string())?)?;
161        Ok(path)
162    }
163
164    /// The requests waiting, oldest first. One that does not read (cut, or
165    /// from a newer Sioul) is set aside as `.unread`, read no more.
166    pub fn waiting(&self) -> Vec<Handed> {
167        let Ok(entries) = std::fs::read_dir(self.requests()) else { return Vec::new() };
168        let mut found: Vec<Handed> = Vec::new();
169        for path in entries.filter_map(Result::ok).map(|e| e.path()) {
170            let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
171            let Some(id) = name.strip_suffix(".json").filter(|id| valid_id(id)) else { continue };
172            match std::fs::read_to_string(&path).ok().and_then(|text| serde_json::from_str::<Handed>(&text).ok()).filter(|h| h.id == id) {
173                Some(handed) => found.push(handed),
174                None => {
175                    let _ = std::fs::rename(&path, path.with_extension("unread"));
176                }
177            }
178        }
179        found.sort_by(|a, b| a.id.cmp(&b.id));
180        found
181    }
182
183    /// The files copied for request `id`, once they all are; none before.
184    pub fn arrived(&self, id: &str) -> Option<Arrived> {
185        serde_json::from_str(&std::fs::read_to_string(self.named(id, ".done.json")).ok()?).ok()
186    }
187
188    /// Request `id` taken, made into draft `draft`, `files` still coming from
189    /// process `copier`: kept, so that a Sioul started again finds them.
190    pub fn mark(&self, id: &str, draft: &str, copier: u32, files: u32) -> Result<(), String> {
191        let mark = Mark { id: id.to_string(), draft: draft.to_string(), copier, files };
192        write_whole(&self.named(id, ".draft"), &serde_json::to_string(&mark).map_err(|e| e.to_string())?)?;
193        let _ = std::fs::remove_file(self.named(id, ".json"));
194        Ok(())
195    }
196
197    /// The requests taken whose files were still coming.
198    pub fn marks(&self) -> Vec<Mark> {
199        let Ok(entries) = std::fs::read_dir(self.requests()) else { return Vec::new() };
200        let mut found: Vec<Mark> = entries
201            .filter_map(Result::ok)
202            .map(|e| e.path())
203            .filter(|p| p.extension().is_some_and(|x| x == "draft"))
204            .filter_map(|p| serde_json::from_str::<Mark>(&std::fs::read_to_string(&p).ok()?).ok())
205            .filter(|m| valid_id(&m.id))
206            .collect();
207        found.sort_by(|a, b| a.id.cmp(&b.id));
208        found
209    }
210
211    /// Request `id` done with: its request, its mark, its arrival.
212    pub fn done(&self, id: &str) {
213        for suffix in [".json", ".draft", ".done.json"] {
214            let _ = std::fs::remove_file(self.named(id, suffix));
215        }
216    }
217
218    /// Request `id` dropped, the files copied for it too (no draft to take them).
219    pub fn drop_request(&self, id: &str) {
220        self.done(id);
221        if valid_id(id) {
222            let _ = std::fs::remove_dir_all(self.files_of(id));
223        }
224    }
225
226    /// What arrived of request `id` when its copy stopped before the end
227    /// (Sioul stopped meanwhile): the files copied whole; those cut short
228    /// removed and said, as those never begun.
229    pub fn salvage(&self, id: &str, expected: u32) -> Arrived {
230        let mut arrived = Arrived::default();
231        if let Ok(entries) = std::fs::read_dir(self.files_of(id)) {
232            let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
233            paths.sort();
234            for path in paths {
235                let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
236                if let Some(cut) = name.strip_suffix(".part") {
237                    let _ = std::fs::remove_file(&path);
238                    arrived.files.push(ArrivedFile { name: cut.to_string(), stopped: true, ..ArrivedFile::default() });
239                } else if path.is_file() {
240                    arrived.files.push(ArrivedFile { name, path: path.to_string_lossy().to_string(), ..ArrivedFile::default() });
241                }
242            }
243        }
244        let missing = (expected as usize).saturating_sub(arrived.files.len());
245        arrived.files.extend((0..missing).map(|_| ArrivedFile { stopped: true, ..ArrivedFile::default() }));
246        arrived
247    }
248
249    /// Folders of copies nobody holds any more (a draft gone in a way that
250    /// left them, a Sioul stopped while copying), removed once a day old.
251    /// `held` are the files the drafts hold.
252    pub fn tidy(&self, held: &[PathBuf], now: SystemTime) {
253        let Ok(entries) = std::fs::read_dir(self.files()) else { return };
254        for folder in entries.filter_map(Result::ok).map(|e| e.path()) {
255            let Some(id) = folder.file_name().and_then(|n| n.to_str()).filter(|id| valid_id(id)).map(str::to_string) else { continue };
256            let waiting = [".json", ".draft", ".done.json"].iter().any(|suffix| self.named(&id, suffix).exists());
257            let old = std::fs::metadata(&folder).and_then(|m| m.modified()).is_ok_and(|at| now.duration_since(at).unwrap_or_default() > Duration::from_secs(24 * 3600));
258            if !waiting && old && !held.iter().any(|path| path.starts_with(&folder)) {
259                let _ = std::fs::remove_dir_all(&folder);
260            }
261        }
262    }
263}
264
265/// A text written whole: under another name, then renamed.
266fn write_whole(path: &Path, text: &str) -> Result<(), String> {
267    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
268    if let Some(parent) = path.parent() {
269        std::fs::create_dir_all(parent).map_err(fail)?;
270    }
271    let temporary = path.with_extension("new");
272    std::fs::write(&temporary, text).map_err(fail)?;
273    std::fs::rename(&temporary, path).map_err(fail)
274}
275
276/// The account a message handed to Sioul goes out from: the one asked (by
277/// its id or its address) when it sends, else Sioul's usual one, the first
278/// by priority among those that send, as for "Write". None when no account
279/// sends.
280pub fn sender<'a>(config: &'a Config, asked: &str) -> Option<&'a Account> {
281    let asked = asked.trim();
282    let sends = || config.accounts.iter().filter(|a| a.syncs());
283    sends()
284        .find(|a| !asked.is_empty() && (a.id == asked || a.address.as_deref().is_some_and(|address| address.eq_ignore_ascii_case(asked))))
285        .or_else(|| sends().min_by_key(|a| a.priority))
286}
287
288/// The draft a request asks for, not saved yet: its recipients (the
289/// `mailto:` address's, then those handed beside it, each once), its subject,
290/// its text (the address's body, then the text shared), the message it
291/// answers, and the account's signature below. None when no account sends.
292pub fn draft(handed: &Handed, config: &Config) -> Option<Draft> {
293    let account = sender(config, &handed.account)?;
294    let asked = mailto::parse(&handed.mailto).unwrap_or_default();
295    let mut draft = Draft::new(&account.id);
296    let list = |mut from_address: Vec<String>, beside: &[String]| {
297        from_address.extend(beside.iter().flat_map(|entry| split_addresses(entry)));
298        mailto::once_each(&mut from_address);
299        from_address
300    };
301    draft.to = list(asked.to, &handed.to);
302    draft.cc = list(asked.cc, &handed.cc);
303    draft.bcc = list(asked.bcc, &handed.bcc);
304    draft.subject = [asked.subject.as_str(), handed.subject.as_str()]
305        .iter()
306        .map(|s| s.replace(['\r', '\n'], " ").trim().to_string())
307        .find(|s| !s.is_empty())
308        .unwrap_or_default();
309    let text = handed.text.replace("\r\n", "\n");
310    draft.body = [asked.body.trim_end(), text.trim_end()].iter().filter(|t| !t.trim().is_empty()).copied().collect::<Vec<&str>>().join("\n\n");
311    if let Some(id) = asked.in_reply_to {
312        draft.references = vec![id.trim_matches(['<', '>']).to_string()];
313        draft.in_reply_to = Some(id.trim_matches(['<', '>']).to_string());
314    }
315    draft.add_signature(account.signature.as_deref());
316    Some(draft)
317}
318
319/// The files of `arrived` attached to `draft`, those copied into `folder`
320/// (their request's) only; returns those that could not be, by name, with why.
321pub fn attach(draft: &mut Draft, folder: &Path, arrived: &Arrived) -> Vec<ArrivedFile> {
322    let mut missing = Vec::new();
323    for file in &arrived.files {
324        let path = PathBuf::from(&file.path);
325        let inside = !file.path.is_empty() && path.starts_with(folder) && path.components().all(|c| !matches!(c, Component::ParentDir | Component::CurDir));
326        if inside && path.is_file() {
327            if !draft.attachments.contains(&path) {
328                draft.attachments.push(path);
329            }
330        } else {
331            missing.push(file.clone());
332        }
333    }
334    missing
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::config::{Priority, Security};
341
342    fn config() -> Config {
343        let mut config = Config::default();
344        let mut work = Account::imap("work", "me@work.example", "imap.work.example", 993, Security::Tls, None);
345        work.signature = Some("Me, at work".into());
346        let mut home = Account::imap("home", "me@home.example", "imap.home.example", 993, Security::Tls, None);
347        home.priority = Priority::Above;
348        // A calendar account sends nothing.
349        let calendars = Account::dav("calendars", "me@dav.example", "dav.example", None, None);
350        config.accounts = vec![work, home, calendars];
351        config
352    }
353
354    fn scratch(name: &str) -> PathBuf {
355        let dir = std::env::temp_dir().join(format!("sioul-handed-{name}-{}", std::process::id()));
356        let _ = std::fs::remove_dir_all(&dir);
357        std::fs::create_dir_all(&dir).unwrap();
358        dir
359    }
360
361    #[test]
362    fn the_sender_is_the_one_chosen_else_the_usual_one() {
363        let config = config();
364        // An address chosen in the share sheet: its account, by id or by address.
365        assert_eq!(sender(&config, "work").unwrap().id, "work");
366        assert_eq!(sender(&config, "ME@WORK.example").unwrap().id, "work");
367        // Nothing chosen, an account gone, one that sends nothing: the usual one, first by priority.
368        assert_eq!(sender(&config, "").unwrap().id, "home");
369        assert_eq!(sender(&config, "gone").unwrap().id, "home");
370        assert_eq!(sender(&config, "calendars").unwrap().id, "home");
371        // No account that sends: none.
372        let mut none = config.clone();
373        none.accounts.retain(|a| !a.syncs());
374        assert!(sender(&none, "work").is_none());
375        let handed = Handed { id: "x".into(), text: "hello".into(), ..Handed::default() };
376        assert!(draft(&handed, &none).is_none());
377    }
378
379    #[test]
380    fn a_share_becomes_a_draft() {
381        let config = config();
382        // A photo shared from the gallery to one address, with a line of text.
383        let handed = Handed {
384            id: "1a-2b".into(),
385            account: "work".into(),
386            subject: "The\nflat".into(),
387            text: "Here it is.\r\nSee you".into(),
388            to: vec!["jane@example.org, Bob <bob@example.org>".into()],
389            cc: vec!["JANE@example.org".into(), "carl@example.org".into()],
390            files: 1,
391            ..Handed::default()
392        };
393        let made = draft(&handed, &config).unwrap();
394        assert_eq!(made.account, "work");
395        assert_eq!(made.to, ["jane@example.org", "Bob <bob@example.org>"]);
396        // Once per list; the same address may be in To and Cc, as given.
397        assert_eq!(made.cc, ["JANE@example.org", "carl@example.org"]);
398        assert_eq!(made.subject, "The flat");
399        assert_eq!(made.body, "Here it is.\nSee you\n\n-- \nMe, at work");
400        assert!(made.attachments.is_empty() && made.kind == crate::compose::DraftKind::New);
401    }
402
403    #[test]
404    fn a_mailto_link_becomes_a_draft() {
405        let config = config();
406        let handed = Handed {
407            id: "x".into(),
408            mailto: "mailto:list@example.org?cc=a@example.org&subject=Re%3A%20Minutes&body=I%20agree.&In-Reply-To=%3Cm1@example.org%3E".into(),
409            to: vec!["list@example.org".into(), "b@example.org".into()],
410            subject: "ignored: the address's subject comes first".into(),
411            text: "Shared beside it.".into(),
412            ..Handed::default()
413        };
414        let made = draft(&handed, &config).unwrap();
415        // Nothing chosen: the usual sender, without a signature here.
416        assert_eq!(made.account, "home");
417        assert_eq!((made.to.as_slice(), made.cc.as_slice()), (&["list@example.org".to_string(), "b@example.org".to_string()][..], &["a@example.org".to_string()][..]));
418        assert_eq!(made.subject, "Re: Minutes");
419        assert_eq!(made.body, "I agree.\n\nShared beside it.");
420        assert_eq!((made.in_reply_to.as_deref(), made.references.as_slice()), (Some("m1@example.org"), &["m1@example.org".to_string()][..]));
421        // Not a mailto: address: the rest only.
422        let odd = Handed { id: "y".into(), mailto: "https://example.org".into(), subject: "S".into(), ..Handed::default() };
423        let made = draft(&odd, &config).unwrap();
424        assert_eq!((made.to.len(), made.subject.as_str(), made.body.as_str()), (0, "S", ""));
425    }
426
427    #[test]
428    fn requests_and_their_files_go_through_the_folder() {
429        let dir = scratch("folder");
430        let incoming = Incoming::at(&dir);
431        assert!(incoming.waiting().is_empty() && incoming.marks().is_empty());
432        let mut handed = Handed::new();
433        handed.text = "A PDF".into();
434        handed.files = 2;
435        handed.refused = vec!["id_rsa".into()];
436        incoming.hand(&handed).unwrap();
437        // Unreadable ones are set aside, a bad name never read.
438        std::fs::write(dir.join("incoming").join("0-cut.json"), "{\"id\": ").unwrap();
439        std::fs::write(dir.join("incoming").join("..json"), "{}").unwrap();
440        assert_eq!(incoming.waiting(), vec![handed.clone()]);
441        assert!(dir.join("incoming").join("0-cut.unread").exists());
442        assert!(Incoming::at(&dir).hand(&Handed { id: "../x".into(), ..Handed::default() }).is_err());
443        // Taken: marked with its draft while the files come.
444        incoming.mark(&handed.id, "d1", 42, 2).unwrap();
445        assert!(incoming.waiting().is_empty());
446        assert_eq!(incoming.marks(), vec![Mark { id: handed.id.clone(), draft: "d1".into(), copier: 42, files: 2 }]);
447        assert!(incoming.arrived(&handed.id).is_none());
448        // They come: one copied, one refused by its application.
449        let folder = incoming.files_of(&handed.id);
450        std::fs::create_dir_all(&folder).unwrap();
451        std::fs::write(folder.join("Lease.pdf"), b"%PDF").unwrap();
452        let arrived = Arrived {
453            files: vec![
454                ArrivedFile { name: "Lease.pdf".into(), path: folder.join("Lease.pdf").to_string_lossy().into(), ..ArrivedFile::default() },
455                ArrivedFile { name: "Photo.jpg".into(), error: "Permission denied".into(), ..ArrivedFile::default() },
456                // Never a file outside the request's own folder.
457                ArrivedFile { name: "x".into(), path: folder.join("..").join("..").join("secret").to_string_lossy().into(), ..ArrivedFile::default() },
458            ],
459        };
460        write_whole(&dir.join("incoming").join(format!("{}.done.json", handed.id)), &serde_json::to_string(&arrived).unwrap()).unwrap();
461        let mut made = Draft::new("work");
462        let missing = attach(&mut made, &folder, &incoming.arrived(&handed.id).unwrap());
463        assert_eq!(made.attachments, vec![folder.join("Lease.pdf")]);
464        assert_eq!(missing.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Photo.jpg", "x"]);
465        // Attached twice, once.
466        attach(&mut made, &folder, &arrived);
467        assert_eq!(made.attachments.len(), 1);
468        incoming.done(&handed.id);
469        assert!(incoming.marks().is_empty() && incoming.arrived(&handed.id).is_none());
470        // Folders nobody holds go after a day; a held one, or a young one, stays.
471        let later = SystemTime::now() + Duration::from_secs(25 * 3600);
472        incoming.tidy(&made.attachments, later);
473        assert!(folder.exists());
474        incoming.tidy(&[], SystemTime::now());
475        assert!(folder.exists());
476        incoming.tidy(&[], later);
477        assert!(!folder.exists());
478        std::fs::remove_dir_all(&dir).unwrap();
479    }
480
481    #[test]
482    fn a_copy_stopped_halfway_keeps_what_came_whole() {
483        let dir = scratch("salvage");
484        let incoming = Incoming::at(&dir);
485        let folder = incoming.files_of("r1");
486        std::fs::create_dir_all(&folder).unwrap();
487        std::fs::write(folder.join("a.jpg"), b"whole").unwrap();
488        std::fs::write(folder.join("b.mp4.part"), b"half").unwrap();
489        let arrived = incoming.salvage("r1", 3);
490        assert!(!folder.join("b.mp4.part").exists());
491        let mut made = Draft::new("work");
492        let missing = attach(&mut made, &folder, &arrived);
493        assert_eq!(made.attachments, vec![folder.join("a.jpg")]);
494        // The one cut short is named; the one never begun has no name to say.
495        assert_eq!(missing.iter().map(|f| (f.name.as_str(), f.stopped)).collect::<Vec<_>>(), [("b.mp4", true), ("", true)]);
496        // A request dropped takes its copies with it.
497        incoming.drop_request("r1");
498        assert!(!folder.exists());
499        std::fs::remove_dir_all(&dir).unwrap();
500    }
501}