1use std::io::{self, Read};
2use std::path::{Path, PathBuf};
3
4use crate::mail_ops::{MailError, MailOps};
5use crate::message::{Parsed, parse_headers};
6use crate::paths::write_atomic;
7use crate::rules::engine::RESTORED_KEYWORD;
8
9pub struct Trash {
10 dir: PathBuf,
11}
12
13#[derive(Debug, thiserror::Error)]
14pub enum RestoreError {
15 #[error("{0} is not a backup in this account's trash")]
16 NotInTrash(String),
17 #[error(transparent)]
18 Io(#[from] io::Error),
19 #[error(transparent)]
20 Mail(#[from] MailError),
21}
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct TrashEntry {
25 pub path: PathBuf,
26 pub folder: String,
27 pub uid: u32,
28 pub saved_at: i64,
29}
30
31const HEADER_BYTES: u64 = 64 * 1024;
33
34impl TrashEntry {
35 pub fn headers(&self) -> Parsed {
37 let mut head = Vec::new();
38 if let Ok(file) = std::fs::File::open(&self.path) {
39 let _ = file.take(HEADER_BYTES).read_to_end(&mut head);
40 }
41 parse_headers(&head)
42 }
43}
44
45impl Trash {
46 pub fn new(dir: PathBuf) -> Trash {
47 Trash { dir }
48 }
49
50 pub fn save(&self, folder: &str, uid: u32, raw: &[u8], now: i64) -> io::Result<PathBuf> {
51 let encoded = folder.replace('%', "%25").replace('/', "%2F");
52 let name = |at: i64| self.dir.join(format!("{at}-{encoded}-{uid}.eml"));
53 let mut at = now;
56 while name(at).symlink_metadata().is_ok() {
57 at += 1;
58 }
59 let path = name(at);
60 write_atomic(&path, raw)?;
61 Ok(path)
62 }
63
64 pub fn list(&self) -> io::Result<Vec<TrashEntry>> {
65 let mut entries = Vec::new();
66 let dir = match std::fs::read_dir(&self.dir) {
67 Ok(d) => d,
68 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(entries),
69 Err(e) => return Err(e),
70 };
71 for entry in dir {
72 let entry = entry?;
73 let name = entry.file_name();
74 let Some((saved_at, folder, uid)) = name.to_str().and_then(Trash::parse_name) else {
75 continue;
76 };
77 entries.push(TrashEntry {
78 path: entry.path(),
79 folder,
80 uid,
81 saved_at,
82 });
83 }
84 entries.sort_by(|a, b| b.saved_at.cmp(&a.saved_at).then(b.uid.cmp(&a.uid)));
85 Ok(entries)
86 }
87
88 pub fn purge(&self, retention_days: u64, now: i64) -> io::Result<usize> {
90 let retention_secs = i64::try_from(retention_days)
91 .unwrap_or(i64::MAX)
92 .saturating_mul(86_400);
93 let mut removed = 0;
94 for entry in self.list()? {
95 if now.saturating_sub(entry.saved_at) <= retention_secs {
96 continue;
97 }
98 match std::fs::remove_file(&entry.path) {
99 Ok(()) => removed += 1,
100 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
102 Err(e) => log::warn!("{}: could not purge: {e}", entry.path.display()),
103 }
104 }
105 Ok(removed)
106 }
107
108 pub fn restore(&self, ops: &mut dyn MailOps, file: &Path) -> Result<String, RestoreError> {
110 let not_in_trash = || RestoreError::NotInTrash(file.display().to_string());
111 let file = file.canonicalize().map_err(|_| not_in_trash())?;
112 let in_trash = self.dir.canonicalize().ok().as_deref() == file.parent();
113 let parsed = file
114 .file_name()
115 .and_then(|n| n.to_str())
116 .and_then(Trash::parse_name);
117 let (Some((_, folder, _)), true) = (parsed, in_trash) else {
118 return Err(not_in_trash());
119 };
120 let raw = std::fs::read(&file)?;
121 ops.append(&folder, &raw, &[RESTORED_KEYWORD])?;
122 std::fs::remove_file(&file)?;
123 Ok(folder)
124 }
125
126 pub fn parse_name(name: &str) -> Option<(i64, String, u32)> {
127 let stem = name.strip_suffix(".eml")?;
128 let (ts, rest) = stem.split_once('-')?;
129 let (encoded, uid) = rest.rsplit_once('-')?;
130 let folder = encoded.replace("%2F", "/").replace("%25", "%");
131 Some((ts.parse().ok()?, folder, uid.parse().ok()?))
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 const DAY: i64 = 86_400;
140
141 #[test]
142 fn save_list_and_parse_round_trip() {
143 let dir = tempfile::tempdir().unwrap();
144 let trash = Trash::new(dir.path().join("trash"));
145 let path = trash.save("Lists/GitHub", 7, b"raw", 1000).unwrap();
146 assert_eq!(path.file_name().unwrap(), "1000-Lists%2FGitHub-7.eml");
147 assert_eq!(std::fs::read(&path).unwrap(), b"raw");
148 trash.save("INBOX", 3, b"raw2", 2000).unwrap();
149 let list = trash.list().unwrap();
150 assert_eq!(list.len(), 2);
151 assert_eq!(
152 (list[0].saved_at, list[0].folder.as_str(), list[0].uid),
153 (2000, "INBOX", 3)
154 );
155 assert_eq!(list[1].folder, "Lists/GitHub");
156 }
157
158 #[test]
160 fn save_never_overwrites_a_backup_with_the_same_name() {
161 let dir = tempfile::tempdir().unwrap();
162 let trash = Trash::new(dir.path().to_path_buf());
163 let first = trash.save("INBOX", 2, b"first message", 1000).unwrap();
164 let second = trash.save("INBOX", 2, b"other message", 1000).unwrap();
165 assert_ne!(first, second);
166 assert_eq!(std::fs::read(&first).unwrap(), b"first message");
167 assert_eq!(std::fs::read(&second).unwrap(), b"other message");
168 assert_eq!(second.file_name().unwrap(), "1001-INBOX-2.eml");
169 }
170
171 #[test]
172 fn purge_removes_only_old_files() {
173 let dir = tempfile::tempdir().unwrap();
174 let trash = Trash::new(dir.path().to_path_buf());
175 trash.save("INBOX", 1, b"a", 0).unwrap();
176 trash.save("INBOX", 2, b"b", 5 * DAY).unwrap();
177 assert_eq!(trash.purge(3, 6 * DAY).unwrap(), 1);
178 assert_eq!(trash.list().unwrap().len(), 1);
179 }
180
181 #[test]
182 fn purge_keeps_everything_when_retention_overflows_seconds() {
183 let dir = tempfile::tempdir().unwrap();
184 let trash = Trash::new(dir.path().to_path_buf());
185 trash.save("INBOX", 1, b"a", 0).unwrap();
186 assert_eq!(trash.purge(u64::MAX, i64::MAX).unwrap(), 0);
187 assert_eq!(
188 trash.purge(i64::MAX as u64 / 86_400 + 1, i64::MAX).unwrap(),
189 0
190 );
191 }
192
193 #[test]
194 fn parse_name_rejects_garbage() {
195 assert_eq!(Trash::parse_name("notes.txt"), None);
196 assert_eq!(Trash::parse_name("12-INBOX-x.eml"), None);
197 assert_eq!(
198 Trash::parse_name("12-A%25B-4.eml"),
199 Some((12, "A%B".into(), 4))
200 );
201 }
202
203 #[test]
204 fn purge_skips_what_it_cannot_remove_and_continues() {
205 let dir = tempfile::tempdir().unwrap();
206 let trash = Trash::new(dir.path().to_path_buf());
207 std::fs::create_dir(dir.path().join("0-INBOX-9.eml")).unwrap();
209 trash.save("INBOX", 1, b"a", 0).unwrap();
210 assert_eq!(trash.purge(3, 6 * DAY).unwrap(), 1);
211 assert!(!dir.path().join("0-INBOX-1.eml").exists());
212 }
213}