vomit-m2dir 0.1.4

Library for the m2dir email storage format
Documentation
//! Library for working with the [m2dir] email storage format.
//!
//! [m2dir]: https://sr.ht/~bitfehler/m2dir
//!
use std::path::PathBuf;

mod flags;
mod m2dir;
mod m2store;
mod message;
mod util;

pub use flags::{Error as FlagsError, Flag, Flags};
pub use m2dir::M2dir;
pub use m2store::{Folder, Folders, M2store};
pub use message::{Message, Messages};

/// Errors that can occur while working with m2dirs.
#[derive(thiserror::Error, Debug)]
pub enum Error {
    // #[error("cannot copy: source equals destination")]
    // CopyError,
    #[error("error storing message: too many duplicate messages")]
    TooManyDuplicates,
    #[error("error handling flags in {0}: {1}")]
    Flags(PathBuf, #[source] FlagsError),
    #[error("invalid folder name: {0}")]
    InvalidFolderName(String),
    #[error("no such file or directory")]
    FolderNotFound,
    #[error("invalid file name: {0}")]
    InvalidFileName(PathBuf),
    #[error("IO error: {0}")]
    IO(#[from] std::io::Error),
    #[error("error writing flags to {0}: {1}")]
    WriteFlags(PathBuf, #[source] std::io::Error),
    #[error("message not found")]
    MessageNotFound,
    #[error("failed to parse message")]
    Parse(),
    #[error("IO error walking directory: {0}")]
    WalkDir(#[from] walkdir::Error),
}

#[cfg(test)]
mod tests {
    use std::{
        fs::OpenOptions,
        io::Write,
        path::Path,
        time::{Duration, SystemTime},
    };

    use lettre::message::header::Date;
    use tempfile::{tempdir, TempDir};

    use flags::Flag;

    use super::*;

    const TEST_MAIL_BODY: &[u8] = b"Date: Sun, 29 Nov 2020 03:49:23 +0000
From: \"Conrad Hoffmann\" <ch@bitfehler.net>
To: \"Testy McTestface\" <test@bitfehler.net>
Subject: Test email that is about to be delivered
Content-Transfer-Encoding: 7bit

With a test body";

    fn gen_message(from: &str, to: &str, subject: &str, body: &str) -> lettre::Message {
        let date = Date::from(SystemTime::UNIX_EPOCH + Duration::from_secs(1606621763));
        lettre::message::Message::builder()
            .header(date)
            .from(from.parse().unwrap())
            .to(to.parse().unwrap())
            .subject(subject)
            .body(body.to_string())
            .unwrap()
            .into()
    }

    pub(crate) fn generate_test_data(target: impl AsRef<Path>) {
        let store = M2store::create(&target).unwrap();

        // Create an INBOX and fill it with some stuff
        let inbox = store.create_folder("INBOX").unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Test email that is marked as read",
            "With a test body",
        );
        inbox
            .as_ref()
            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
            .unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Test email that is unread",
            "And will hence not have any flags",
        );
        inbox.as_ref().deliver(&mgs.formatted()).unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Test email with a custom flag",
            "With a test body",
        );
        inbox
            .as_ref()
            .store(
                &mgs.formatted(),
                &Flags::from([Flag::Seen, Flag::Custom("todo".to_string())]),
            )
            .unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Duplicate test email",
            "Which should work just fine, we'll use different flags so this can be easily verified",
        );
        inbox
            .as_ref()
            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
            .unwrap();
        inbox
            .as_ref()
            .store(&mgs.formatted(), &Flags::from([Flag::Seen, Flag::Answered]))
            .unwrap();

        // Time to create some folders

        let folder = store.create_folder("lists/m2dir-dev").unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Fake M2dir ML <m2dir@example.com>",
            "Test email in a nested subfolder",
            "Whose parent folder is not an m2dir (contains no emails)",
        );
        folder
            .as_ref()
            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
            .unwrap();

        let folder = store.create_folder("folder").unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Test email in a simple folder",
            "Nothing special here",
        );
        folder
            .as_ref()
            .store(
                &mgs.formatted(),
                &Flags::from([
                    Flag::Answered,
                    Flag::Seen,
                    Flag::Custom("patch".to_string()),
                ]),
            )
            .unwrap();

        let folder = store.create_folder("folder/subfolder").unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Test email in a nested folder",
            "Whose parent is also a folder with messages",
        );
        folder
            .as_ref()
            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
            .unwrap();

        let folder = store.create_folder("brokenflags").unwrap();

        let mgs = gen_message(
            "Conrad Hoffmann <ch@bitfehler.net>",
            "Testy McTestface <test@bitfehler.net>",
            "Test email with broken flags file",
            "To test error handling",
        );
        let m = folder
            .as_ref()
            .store(&mgs.formatted(), &Flags::from([Flag::Seen]))
            .unwrap();
        let mut f = OpenOptions::new()
            .append(true)
            .open(m.flags_path())
            .unwrap();
        f.write("\n\\InvalidFlag\n".as_ref()).unwrap();

        // TODO: folder with messages with broken filenames?
    }

    fn setup(dir: &TempDir, folder: &str) -> M2dir {
        let m2dir = PathBuf::from_iter([dir.as_ref(), folder.as_ref()]);
        generate_test_data(dir.as_ref());
        M2dir::try_from(m2dir.as_ref()).unwrap()
    }

    #[test]
    fn test_load_single() {
        let tmpdir = tempdir().unwrap();
        let m2dir = setup(&tmpdir, "folder");

        assert_eq!(m2dir.count(), 1);

        let msgs = m2dir.list();
        let msg = msgs.into_iter().next().unwrap().unwrap();

        assert!(msg
            .path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("2020-11-29_04:49_ch@bitfehler.net,3wAAALlW9_qepw6p"));

        let loaded_flags = msg.flags().unwrap();
        let flags = Flags::from([
            Flag::Answered,
            Flag::Seen,
            Flag::Custom(String::from("patch")),
        ]);

        assert_eq!(flags, loaded_flags);
    }

    #[test]
    fn test_deliver() {
        let tmpdir = tempdir().unwrap();
        let m2dir = M2dir::create(tmpdir.path()).unwrap();
        let msg = m2dir.deliver(TEST_MAIL_BODY).unwrap();

        eprintln!("{}", msg.path.file_name().unwrap().to_string_lossy());
        assert!(msg
            .path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("2020-11-29_04:49_ch@bitfehler.net,4AAAADUevRZ_R3P7"));

        assert_eq!(msg.flags().unwrap().len(), 0);
    }

    #[test]
    fn test_store() {
        let tmpdir = tempdir().unwrap();
        let m2dir = M2dir::create(tmpdir.path()).unwrap();
        let flags = Flags::from([Flag::Answered, Flag::Seen]);
        let msg = m2dir.store(TEST_MAIL_BODY, &flags).unwrap();

        assert!(msg
            .path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("2020-11-29_04:49_ch@bitfehler.net,4AAAADUevRZ_R3P7"));

        let loaded_flags = msg.flags().unwrap();

        assert_eq!(flags, loaded_flags);
    }

    #[test]
    fn test_copy() {
        let srcdir = tempdir().unwrap();

        let m2dir = setup(&srcdir, "folder");
        let srcfolder = m2dir.path();

        let msgs = m2dir.list();
        let msg = msgs.into_iter().next().unwrap().unwrap();

        let dstdir = tempdir().unwrap();
        let m2dir = M2dir::create(dstdir.path()).unwrap();

        assert_eq!(m2dir.count(), 0);

        let cpy = msg.copy_to(&m2dir).unwrap();

        assert_eq!(m2dir.count(), 1);
        assert_eq!(msg.path.parent().unwrap(), srcfolder);
        assert_eq!(cpy.path.parent().unwrap(), dstdir.path());

        assert!(cpy
            .path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("2020-11-29_04:49_ch@bitfehler.net,3wAAALlW9_qepw6p"));

        let loaded_flags = cpy.flags().unwrap();
        let flags = Flags::from([
            Flag::Answered,
            Flag::Seen,
            Flag::Custom(String::from("patch")),
        ]);

        assert_eq!(flags, loaded_flags);

        // Assert it aborts on src == dst
        assert!(cpy.copy_to(&m2dir).is_err());
    }

    #[test]
    fn test_move() {
        let srcdir = tempdir().unwrap();
        let m2dir = setup(&srcdir, "folder");

        let msgs = m2dir.list();
        let mut msg = msgs.into_iter().next().unwrap().unwrap();

        let dstdir = tempdir().unwrap();
        let m2dir = M2dir::create(dstdir.path()).unwrap();

        assert_eq!(m2dir.count(), 0);

        msg.move_to(&m2dir).unwrap();

        assert_eq!(m2dir.count(), 1);
        assert_eq!(msg.path.parent().unwrap(), dstdir.path());

        assert!(msg
            .path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("2020-11-29_04:49_ch@bitfehler.net,3wAAALlW9_qepw6p"));

        let loaded_flags = msg.flags().unwrap();
        let flags = Flags::from([
            Flag::Answered,
            Flag::Seen,
            Flag::Custom(String::from("patch")),
        ]);

        assert_eq!(flags, loaded_flags);
    }

    #[test]
    fn test_invalid_flags() {
        let srcdir = tempdir().unwrap();
        let m2dir = setup(&srcdir, "brokenflags");

        let msgs = m2dir.list();
        let msg = msgs.into_iter().next().unwrap().unwrap();

        assert!(msg.flags().is_err());
    }

    #[test]
    #[ignore]
    fn generate() {
        // Use this for generating test data to someplace where it can be inspected:
        // $ env M2DIR_GENERATE_TEST_DATA=/foo/bar cargo test -- --ignored
        let target = std::env::var("M2DIR_GENERATE_TEST_DATA")
            .expect("Env var M2DIR_GENERATE_TEST_DATA must be set to target path");
        generate_test_data(target)
    }
}