vomit_m2dir/
lib.rs

1//! Library for working with the [m2dir] email storage format.
2//!
3//! [m2dir]: https://sr.ht/~bitfehler/m2dir
4//!
5use std::path::PathBuf;
6
7mod flags;
8mod m2dir;
9mod m2store;
10mod message;
11mod util;
12
13pub use flags::{Error as FlagsError, Flag, Flags};
14pub use m2dir::M2dir;
15pub use m2store::{Folder, Folders, M2store};
16pub use message::{Message, Messages};
17
18/// Errors that can occur while working with m2dirs.
19#[derive(thiserror::Error, Debug)]
20pub enum Error {
21    // #[error("cannot copy: source equals destination")]
22    // CopyError,
23    #[error("error storing message: too many duplicate messages")]
24    TooManyDuplicates,
25    #[error("error handling flags in {0}: {1}")]
26    Flags(PathBuf, #[source] FlagsError),
27    #[error("invalid folder name: {0}")]
28    InvalidFolderName(String),
29    #[error("no such file or directory")]
30    FolderNotFound,
31    #[error("invalid file name: {0}")]
32    InvalidFileName(PathBuf),
33    #[error("IO error: {0}")]
34    IO(#[from] std::io::Error),
35    #[error("error writing flags to {0}: {1}")]
36    WriteFlags(PathBuf, #[source] std::io::Error),
37    #[error("message not found")]
38    MessageNotFound,
39    #[error("failed to parse message")]
40    Parse(),
41    #[error("IO error walking directory: {0}")]
42    WalkDir(#[from] walkdir::Error),
43}
44
45#[cfg(test)]
46mod tests {
47    use std::{
48        fs::OpenOptions,
49        io::Write,
50        path::Path,
51        time::{Duration, SystemTime},
52    };
53
54    use lettre::message::header::Date;
55    use tempfile::{tempdir, TempDir};
56
57    use flags::Flag;
58
59    use super::*;
60
61    const TEST_MAIL_BODY: &[u8] = b"Date: Sun, 29 Nov 2020 03:49:23 +0000
62From: \"Conrad Hoffmann\" <ch@bitfehler.net>
63To: \"Testy McTestface\" <test@bitfehler.net>
64Subject: Test email that is about to be delivered
65Content-Transfer-Encoding: 7bit
66
67With a test body";
68
69    fn gen_message(from: &str, to: &str, subject: &str, body: &str) -> lettre::Message {
70        let date = Date::from(SystemTime::UNIX_EPOCH + Duration::from_secs(1606621763));
71        lettre::message::Message::builder()
72            .header(date)
73            .from(from.parse().unwrap())
74            .to(to.parse().unwrap())
75            .subject(subject)
76            .body(body.to_string())
77            .unwrap()
78            .into()
79    }
80
81    pub(crate) fn generate_test_data(target: impl AsRef<Path>) {
82        let store = M2store::create(&target).unwrap();
83
84        // Create an INBOX and fill it with some stuff
85        let inbox = store.create_folder("INBOX").unwrap();
86
87        let mgs = gen_message(
88            "Conrad Hoffmann <ch@bitfehler.net>",
89            "Testy McTestface <test@bitfehler.net>",
90            "Test email that is marked as read",
91            "With a test body",
92        );
93        inbox
94            .as_ref()
95            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
96            .unwrap();
97
98        let mgs = gen_message(
99            "Conrad Hoffmann <ch@bitfehler.net>",
100            "Testy McTestface <test@bitfehler.net>",
101            "Test email that is unread",
102            "And will hence not have any flags",
103        );
104        inbox.as_ref().deliver(&mgs.formatted()).unwrap();
105
106        let mgs = gen_message(
107            "Conrad Hoffmann <ch@bitfehler.net>",
108            "Testy McTestface <test@bitfehler.net>",
109            "Test email with a custom flag",
110            "With a test body",
111        );
112        inbox
113            .as_ref()
114            .store(
115                &mgs.formatted(),
116                &Flags::from([Flag::Seen, Flag::Custom("todo".to_string())]),
117            )
118            .unwrap();
119
120        let mgs = gen_message(
121            "Conrad Hoffmann <ch@bitfehler.net>",
122            "Testy McTestface <test@bitfehler.net>",
123            "Duplicate test email",
124            "Which should work just fine, we'll use different flags so this can be easily verified",
125        );
126        inbox
127            .as_ref()
128            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
129            .unwrap();
130        inbox
131            .as_ref()
132            .store(&mgs.formatted(), &Flags::from([Flag::Seen, Flag::Answered]))
133            .unwrap();
134
135        // Time to create some folders
136
137        let folder = store.create_folder("lists/m2dir-dev").unwrap();
138
139        let mgs = gen_message(
140            "Conrad Hoffmann <ch@bitfehler.net>",
141            "Fake M2dir ML <m2dir@example.com>",
142            "Test email in a nested subfolder",
143            "Whose parent folder is not an m2dir (contains no emails)",
144        );
145        folder
146            .as_ref()
147            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
148            .unwrap();
149
150        let folder = store.create_folder("folder").unwrap();
151
152        let mgs = gen_message(
153            "Conrad Hoffmann <ch@bitfehler.net>",
154            "Testy McTestface <test@bitfehler.net>",
155            "Test email in a simple folder",
156            "Nothing special here",
157        );
158        folder
159            .as_ref()
160            .store(
161                &mgs.formatted(),
162                &Flags::from([
163                    Flag::Answered,
164                    Flag::Seen,
165                    Flag::Custom("patch".to_string()),
166                ]),
167            )
168            .unwrap();
169
170        let folder = store.create_folder("folder/subfolder").unwrap();
171
172        let mgs = gen_message(
173            "Conrad Hoffmann <ch@bitfehler.net>",
174            "Testy McTestface <test@bitfehler.net>",
175            "Test email in a nested folder",
176            "Whose parent is also a folder with messages",
177        );
178        folder
179            .as_ref()
180            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
181            .unwrap();
182
183        let folder = store.create_folder("brokenflags").unwrap();
184
185        let mgs = gen_message(
186            "Conrad Hoffmann <ch@bitfehler.net>",
187            "Testy McTestface <test@bitfehler.net>",
188            "Test email with broken flags file",
189            "To test error handling",
190        );
191        let m = folder
192            .as_ref()
193            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
194            .unwrap();
195        let mut f = OpenOptions::new()
196            .append(true)
197            .open(m.flags_path())
198            .unwrap();
199        f.write("\n\\InvalidFlag\n".as_ref()).unwrap();
200
201        // TODO: folder with messages with broken filenames?
202    }
203
204    fn setup(dir: &TempDir, folder: &str) -> M2dir {
205        let m2dir = PathBuf::from_iter([dir.as_ref(), folder.as_ref()]);
206        generate_test_data(dir.as_ref());
207        M2dir::try_from(m2dir.as_ref()).unwrap()
208    }
209
210    #[test]
211    fn test_load_single() {
212        let tmpdir = tempdir().unwrap();
213        let m2dir = setup(&tmpdir, "folder");
214
215        assert_eq!(m2dir.count(), 1);
216
217        let msgs = m2dir.list();
218        let msg = msgs.into_iter().next().unwrap().unwrap();
219
220        assert!(msg
221            .path
222            .file_name()
223            .unwrap()
224            .to_string_lossy()
225            .starts_with("2020-11-29_04:49_ch@bitfehler.net,3wAAALlW9_qepw6p"));
226
227        let loaded_flags = msg.flags().unwrap();
228        let flags = Flags::from([
229            Flag::Answered,
230            Flag::Seen,
231            Flag::Custom(String::from("patch")),
232        ]);
233
234        assert_eq!(flags, loaded_flags);
235    }
236
237    #[test]
238    fn test_deliver() {
239        let tmpdir = tempdir().unwrap();
240        let m2dir = M2dir::create(tmpdir.path()).unwrap();
241        let msg = m2dir.deliver(TEST_MAIL_BODY).unwrap();
242
243        eprintln!("{}", msg.path.file_name().unwrap().to_string_lossy());
244        assert!(msg
245            .path
246            .file_name()
247            .unwrap()
248            .to_string_lossy()
249            .starts_with("2020-11-29_04:49_ch@bitfehler.net,4AAAADUevRZ_R3P7"));
250
251        assert_eq!(msg.flags().unwrap().len(), 0);
252    }
253
254    #[test]
255    fn test_store() {
256        let tmpdir = tempdir().unwrap();
257        let m2dir = M2dir::create(tmpdir.path()).unwrap();
258        let flags = Flags::from([Flag::Answered, Flag::Seen]);
259        let msg = m2dir.store(TEST_MAIL_BODY, &flags).unwrap();
260
261        assert!(msg
262            .path
263            .file_name()
264            .unwrap()
265            .to_string_lossy()
266            .starts_with("2020-11-29_04:49_ch@bitfehler.net,4AAAADUevRZ_R3P7"));
267
268        let loaded_flags = msg.flags().unwrap();
269
270        assert_eq!(flags, loaded_flags);
271    }
272
273    #[test]
274    fn test_copy() {
275        let srcdir = tempdir().unwrap();
276
277        let m2dir = setup(&srcdir, "folder");
278        let srcfolder = m2dir.path();
279
280        let msgs = m2dir.list();
281        let msg = msgs.into_iter().next().unwrap().unwrap();
282
283        let dstdir = tempdir().unwrap();
284        let m2dir = M2dir::create(dstdir.path()).unwrap();
285
286        assert_eq!(m2dir.count(), 0);
287
288        let cpy = msg.copy_to(&m2dir).unwrap();
289
290        assert_eq!(m2dir.count(), 1);
291        assert_eq!(msg.path.parent().unwrap(), srcfolder);
292        assert_eq!(cpy.path.parent().unwrap(), dstdir.path());
293
294        assert!(cpy
295            .path
296            .file_name()
297            .unwrap()
298            .to_string_lossy()
299            .starts_with("2020-11-29_04:49_ch@bitfehler.net,3wAAALlW9_qepw6p"));
300
301        let loaded_flags = cpy.flags().unwrap();
302        let flags = Flags::from([
303            Flag::Answered,
304            Flag::Seen,
305            Flag::Custom(String::from("patch")),
306        ]);
307
308        assert_eq!(flags, loaded_flags);
309
310        // Assert it aborts on src == dst
311        assert!(cpy.copy_to(&m2dir).is_err());
312    }
313
314    #[test]
315    fn test_move() {
316        let srcdir = tempdir().unwrap();
317        let m2dir = setup(&srcdir, "folder");
318
319        let msgs = m2dir.list();
320        let mut msg = msgs.into_iter().next().unwrap().unwrap();
321
322        let dstdir = tempdir().unwrap();
323        let m2dir = M2dir::create(dstdir.path()).unwrap();
324
325        assert_eq!(m2dir.count(), 0);
326
327        msg.move_to(&m2dir).unwrap();
328
329        assert_eq!(m2dir.count(), 1);
330        assert_eq!(msg.path.parent().unwrap(), dstdir.path());
331
332        assert!(msg
333            .path
334            .file_name()
335            .unwrap()
336            .to_string_lossy()
337            .starts_with("2020-11-29_04:49_ch@bitfehler.net,3wAAALlW9_qepw6p"));
338
339        let loaded_flags = msg.flags().unwrap();
340        let flags = Flags::from([
341            Flag::Answered,
342            Flag::Seen,
343            Flag::Custom(String::from("patch")),
344        ]);
345
346        assert_eq!(flags, loaded_flags);
347    }
348
349    #[test]
350    fn test_invalid_flags() {
351        let srcdir = tempdir().unwrap();
352        let m2dir = setup(&srcdir, "brokenflags");
353
354        let msgs = m2dir.list();
355        let msg = msgs.into_iter().next().unwrap().unwrap();
356
357        assert!(msg.flags().is_err());
358    }
359
360    #[test]
361    #[ignore]
362    fn generate() {
363        // Use this for generating test data to someplace where it can be inspected:
364        // $ env M2DIR_GENERATE_TEST_DATA=/foo/bar cargo test -- --ignored
365        let target = std::env::var("M2DIR_GENERATE_TEST_DATA")
366            .expect("Env var M2DIR_GENERATE_TEST_DATA must be set to target path");
367        generate_test_data(target)
368    }
369}