1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Handed {
31 pub id: String,
33 #[serde(default)]
36 pub account: String,
37 #[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 #[serde(default)]
50 pub text: String,
51 #[serde(default)]
53 pub files: u32,
54 #[serde(default)]
56 pub copier: u32,
57 #[serde(default)]
61 pub refused: Vec<String>,
62}
63
64impl Handed {
65 pub fn new() -> Handed {
67 Handed { id: new_id(), copier: std::process::id(), ..Handed::default() }
68 }
69}
70
71#[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 #[serde(default)]
82 pub name: String,
83 #[serde(default)]
85 pub path: String,
86 #[serde(default)]
88 pub error: String,
89 #[serde(default)]
91 pub stopped: bool,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Mark {
97 #[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
107fn 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
113pub 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#[derive(Debug, Clone)]
121pub struct Incoming {
122 root: PathBuf,
123}
124
125impl Incoming {
126 pub fn here() -> Incoming {
128 Incoming { root: data_dir().join("handed") }
129 }
130
131 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 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 pub fn files_of(&self, id: &str) -> PathBuf {
151 self.files().join(id)
152 }
153
154 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 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 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 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 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 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 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 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 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
265fn 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
276pub 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
288pub 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
319pub 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 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 assert_eq!(sender(&config, "work").unwrap().id, "work");
366 assert_eq!(sender(&config, "ME@WORK.example").unwrap().id, "work");
367 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(missing.iter().map(|f| (f.name.as_str(), f.stopped)).collect::<Vec<_>>(), [("b.mp4", true), ("", true)]);
496 incoming.drop_request("r1");
498 assert!(!folder.exists());
499 std::fs::remove_dir_all(&dir).unwrap();
500 }
501}