Skip to main content

postvak_core/
trash.rs

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
31/// How much of a backup is read to find its headers.
32const HEADER_BYTES: u64 = 64 * 1024;
33
34impl TrashEntry {
35    /// The backup's headers, such as its sender and subject; reads only the start of the file.
36    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        // After a UIDVALIDITY change the same folder and uid name another message, which can be deleted within the same
54        // second: never overwrite a backup, take the next free second instead.
55        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    /// Removes the backups saved more than `retention_days` before `now`; a retention too large to count in seconds keeps all.
89    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                // A concurrent `trash restore` may have taken it already.
101                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    /// Appends the backup to its original folder with the restored keyword, then removes the file. Returns the folder.
109    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    /// Found by tests/sync_model.rs: a delete after a UIDVALIDITY reset overwrote the backup of another message.
159    #[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        // A directory with a backup's name cannot be removed with remove_file.
208        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}