Files
jcoffey-dev 5931b06392 Rename to inbuxa-migrate
The crate, binary, library path and every name the tool writes or reads
become inbuxa-migrate: the CLI name and about text, the HTTP user agent,
the credential variables (INBUXA_MIGRATE_PASSWORD, _TOKEN,
_EWS_CLIENT_SECRET and _GRAPH_TOKEN, with no fallback to the old names),
thread names, synthesized UIDs and calendar addresses
(urn:x-inbuxa-migrate:attendee:), and the Maildir file suffix used by the
export script. This is a new tool, so archives written by Vandelay are not
read back.

The version moves to the date scheme the release tags use (2026.9.30), and
the package metadata points at the inbuxa-migrate repository. The cargo-dist
and MSI metadata goes with the release tooling it served.

Modified files carry an added copyright line; the original notices stay.
2026-09-30 10:09:10 -07:00

810 lines
26 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use inbuxa_migrate::logging::Logger;
use inbuxa_migrate::sync::CommonConfig;
use inbuxa_migrate::sync::import_maildir::{MaildirImportConfig, run};
use rusqlite::Connection;
use tempfile::TempDir;
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"inbuxa-migrate-mock-maildir-{tag}-{}-{}.sqlite",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&p);
p
}
fn common(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 1,
allow_invalid_certs: false,
logger: Logger::from_flags(true, 0),
}
}
fn base_cfg(root: &Path) -> MaildirImportConfig {
MaildirImportConfig {
maildir: root.to_path_buf(),
include: Vec::new(),
exclude: Vec::new(),
folder: Vec::new(),
automap: true,
include_deleted: false,
allow_source_change: false,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn ensure_maildir(root: &Path) {
for sub in ["cur", "new", "tmp"] {
fs::create_dir_all(root.join(sub)).unwrap();
}
}
fn ensure_subfolder(root: &Path, dotted: &str) -> PathBuf {
let sub = root.join(dotted);
for s in ["cur", "new", "tmp"] {
fs::create_dir_all(sub.join(s)).unwrap();
}
sub
}
fn write_message(folder: &Path, sub: &str, filename: &str, body: &[u8]) -> PathBuf {
let dir = folder.join(sub);
fs::create_dir_all(&dir).unwrap();
let p = dir.join(filename);
fs::write(&p, body).unwrap();
p
}
fn rfc5322(id: &str, subject: &str, body: &str) -> Vec<u8> {
format!(
"From: [email protected]\r\nTo: [email protected]\r\n\
Subject: {subject}\r\nMessage-ID: <{id}@example.com>\r\n\
Date: Mon, 12 May 2025 10:00:00 +0000\r\n\r\n{body}\r\n"
)
.into_bytes()
}
fn folder_mailbox_id(conn: &Connection, canonical: &str) -> Option<i64> {
conn.query_row(
"SELECT local_id FROM sync_id_maildir
WHERE type_name = 'mailbox' AND folder = ?1 AND unique_id = ''",
rusqlite::params![canonical],
|r| r.get(0),
)
.ok()
}
fn emails_for_folder(conn: &Connection, canonical: &str) -> Vec<(String, String)> {
let mailbox_id = folder_mailbox_id(conn, canonical).expect("mailbox row");
let mut stmt = conn
.prepare(
"SELECT b.hash, e.keywords FROM emails e
JOIN blobs b ON b.id = e.blob_id
WHERE EXISTS (
SELECT 1 FROM json_each(e.mailbox_ids) WHERE value = ?1
)
ORDER BY e.id",
)
.unwrap();
let rows = stmt
.query_map(rusqlite::params![mailbox_id], |r| {
let hash: Vec<u8> = r.get(0)?;
let kw: String = r.get(1)?;
Ok((hex(&hash), kw))
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
fn dovecot_fixture(root: &Path) -> HashMap<&'static str, Vec<&'static str>> {
ensure_maildir(root);
write_message(
root,
"cur",
"1739471123.M001P00123V0000I0000aaaa.host01,S=128,W=131:2,S",
&rfc5322("dovecot-inbox-1", "Welcome", "Welcome to Dovecot"),
);
write_message(
root,
"cur",
"1739471124.M002P00123V0000I0000aaab.host01,S=140,W=143:2,RS",
&rfc5322("dovecot-inbox-2", "Re: Welcome", "Reply body"),
);
write_message(
root,
"new",
"1739471200.M003P00123V0000I0000aaac.host01,S=64,W=66",
&rfc5322("dovecot-inbox-3", "Unread mail", "Body of the unread"),
);
let sent = ensure_subfolder(root, ".Sent");
write_message(
&sent,
"cur",
"1739472000.M001P00123V0000I0000bbb1.host01,S=80,W=82:2,S",
&rfc5322("dovecot-sent-1", "Sent to friend", "Hi friend"),
);
let drafts = ensure_subfolder(root, ".Drafts");
write_message(
&drafts,
"cur",
"1739472100.M002P00123V0000I0000bbb2.host01,S=50,W=52:2,DS",
&rfc5322("dovecot-draft-1", "Half-written", "Almost there"),
);
let trash = ensure_subfolder(root, ".Trash");
write_message(
&trash,
"cur",
"1739472200.M003P00123V0000I0000bbb3.host01,S=30,W=32:2,ST",
&rfc5322("dovecot-trash-1", "Junk", "trashed"),
);
ensure_subfolder(root, ".Archive");
let arch25 = ensure_subfolder(root, ".Archive.2025");
write_message(
&arch25,
"cur",
"1739473000.M001P00123V0000I0000cccc.host01,S=200,W=204:2,S",
&rfc5322("dovecot-arch25-1", "Last year", "Old mail"),
);
write_message(
&arch25,
"cur",
"1739473001.M002P00123V0000I0000cccd.host01,S=200,W=204:2,SF",
&rfc5322("dovecot-arch25-2", "Important", "Flagged"),
);
fs::write(root.join("dovecot.index.log"), b"index").unwrap();
fs::write(root.join("dovecot-uidlist"), b"uidlist").unwrap();
fs::create_dir_all(root.join(".dovecot.imap")).unwrap();
fs::write(root.join("cur/.uidvalidity"), b"sidecar").unwrap();
let mut expected: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
expected.insert("INBOX", vec![]);
expected.insert("Sent", vec![]);
expected.insert("Drafts", vec![]);
expected.insert("Trash", vec![]);
expected.insert("Archive", vec![]);
expected.insert("Archive.2025", vec![]);
expected
}
#[test]
fn dovecot_layout_imports_every_folder_with_correct_roles() {
let td = TempDir::new().unwrap();
let expected = dovecot_fixture(td.path());
let archive = tmp_archive("dovecot");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "import failed: {summary:?}");
let conn = Connection::open(&archive).unwrap();
for name in expected.keys() {
assert!(
folder_mailbox_id(&conn, name).is_some(),
"missing mailbox row for {name:?}"
);
}
let inbox = emails_for_folder(&conn, "INBOX");
assert_eq!(inbox.len(), 3, "3 INBOX messages expected");
let seen_count = inbox.iter().filter(|(_, kw)| kw.contains("$seen")).count();
assert_eq!(seen_count, 2, "two cur/ msgs are $seen");
let sent = emails_for_folder(&conn, "Sent");
assert_eq!(sent.len(), 1);
let trash = emails_for_folder(&conn, "Trash");
assert!(trash.is_empty(), "Trashed message skipped by default");
let arch_2025 = emails_for_folder(&conn, "Archive.2025");
assert_eq!(arch_2025.len(), 2);
let flagged = arch_2025.iter().any(|(_, kw)| kw.contains("$flagged"));
assert!(flagged, "one Archive.2025 message has $flagged");
let archive_parent_id = folder_mailbox_id(&conn, "Archive").unwrap();
let arch_2025_parent: Option<i64> = conn
.query_row(
"SELECT parent_id FROM mailboxes WHERE name = '2025'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(arch_2025_parent, Some(archive_parent_id));
let _ = fs::remove_file(&archive);
}
#[test]
fn dovecot_include_deleted_keeps_trash_message() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("dovecot-include-deleted");
let mut cfg = base_cfg(td.path());
cfg.include_deleted = true;
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let trash = emails_for_folder(&conn, "Trash");
assert_eq!(trash.len(), 1);
assert!(trash[0].1.contains("$deleted"));
let _ = fs::remove_file(&archive);
}
fn courier_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
"cur",
"1234567890.12345_0.mail.example:2,S",
&rfc5322("courier-1", "Hi", "Hello from Courier"),
);
write_message(
root,
"new",
"1234567891.12345_1.mail.example",
&rfc5322("courier-2", "Unread", "New body"),
);
let sent = ensure_subfolder(root, ".Sent");
write_message(
&sent,
"cur",
"1234567900.12350_0.mail.example:2,S",
&rfc5322("courier-sent-1", "Sent reply", "Bye"),
);
let lists = ensure_subfolder(root, ".Lists.maildir-dev");
write_message(
&lists,
"cur",
"1234568000.12400_0.mail.example:2,RS",
&rfc5322("courier-list-1", "[Maildir-dev] thread", "list body"),
);
fs::write(root.join("courierimapsubscribed"), b"INBOX.Sent\n").unwrap();
}
#[test]
fn courier_layout_with_orphan_parent_creates_ephemeral_lists_row() {
let td = TempDir::new().unwrap();
courier_fixture(td.path());
let archive = tmp_archive("courier");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
assert!(folder_mailbox_id(&conn, "Sent").is_some());
let lists_id = folder_mailbox_id(&conn, "Lists").expect("ephemeral Lists row created");
let leaf_id = folder_mailbox_id(&conn, "Lists.maildir-dev").expect("Lists.maildir-dev row");
let leaf_parent: Option<i64> = conn
.query_row(
"SELECT parent_id FROM mailboxes WHERE id = ?1",
rusqlite::params![leaf_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(leaf_parent, Some(lists_id));
let role: Option<String> = conn
.query_row(
"SELECT role FROM mailboxes WHERE id = ?1",
rusqlite::params![lists_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(role, None);
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 2);
assert_eq!(emails_for_folder(&conn, "Sent").len(), 1);
assert_eq!(emails_for_folder(&conn, "Lists.maildir-dev").len(), 1);
let _ = fs::remove_file(&archive);
}
fn qmail_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
"new",
"1700000000.12345.box01",
&rfc5322("qmail-1", "qmail one", "body 1"),
);
write_message(
root,
"new",
"1700000001.12346.box01",
&rfc5322("qmail-2", "qmail two", "body 2"),
);
write_message(
root,
"cur",
"1700000002.12347.box01:2,S",
&rfc5322("qmail-3", "Seen", "body 3"),
);
}
#[test]
fn qmail_root_only_maildir_imports_inbox_only() {
let td = TempDir::new().unwrap();
qmail_fixture(td.path());
let archive = tmp_archive("qmail");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mboxes = count(&conn, "mailboxes");
assert_eq!(mboxes, 1, "INBOX only");
let inbox = emails_for_folder(&conn, "INBOX");
assert_eq!(inbox.len(), 3);
let seen = inbox.iter().filter(|(_, kw)| kw.contains("$seen")).count();
assert_eq!(seen, 1, "only the cur/ message has $seen");
let _ = fs::remove_file(&archive);
}
fn cyrus_export_fixture(root: &Path) {
ensure_maildir(root);
write_message(
root,
"cur",
"1701000000.X1.cyrus-export:2,S",
&rfc5322("cyrus-1", "Cyrus mail 1", "exported"),
);
write_message(
root,
"cur",
"1701000001.X2.cyrus-export:2,",
&rfc5322("cyrus-2", "Cyrus mail 2", "exported"),
);
let sent = ensure_subfolder(root, ".Sent");
write_message(
&sent,
"cur",
"1701000100.X3.cyrus-export:2,S",
&rfc5322("cyrus-sent-1", "Sent from Cyrus", "exported sent"),
);
let arch = ensure_subfolder(root, ".Archive");
write_message(
&arch,
"cur",
"1701000200.X4.cyrus-export:2,S",
&rfc5322("cyrus-arch-1", "Archived", "old"),
);
}
#[test]
fn cyrus_style_export_imports_cleanly() {
let td = TempDir::new().unwrap();
cyrus_export_fixture(td.path());
let archive = tmp_archive("cyrus");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 2);
assert_eq!(emails_for_folder(&conn, "Sent").len(), 1);
assert_eq!(emails_for_folder(&conn, "Archive").len(), 1);
let sent_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'Sent'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(sent_role.as_deref(), Some("sent"));
let _ = fs::remove_file(&archive);
}
#[test]
fn include_filter_keeps_only_matching_folders() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("filter-include");
let mut cfg = base_cfg(td.path());
cfg.include = vec![regex::Regex::new(r"^(INBOX|Sent)$").unwrap()];
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
assert!(folder_mailbox_id(&conn, "Sent").is_some());
assert!(folder_mailbox_id(&conn, "Trash").is_none());
assert!(folder_mailbox_id(&conn, "Archive.2025").is_none());
let _ = fs::remove_file(&archive);
}
#[test]
fn exclude_filter_drops_matching_folders() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("filter-exclude");
let mut cfg = base_cfg(td.path());
cfg.exclude = vec![regex::Regex::new(r"^Trash$").unwrap()];
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "Trash").is_none());
assert!(folder_mailbox_id(&conn, "Sent").is_some());
let _ = fs::remove_file(&archive);
}
#[test]
fn explicit_folder_overrides_include_exclude() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("filter-folder");
let mut cfg = base_cfg(td.path());
cfg.folder = vec!["Sent".to_owned()];
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mbox_count = count(&conn, "mailboxes");
assert_eq!(mbox_count, 1);
assert!(folder_mailbox_id(&conn, "Sent").is_some());
let _ = fs::remove_file(&archive);
}
#[test]
fn noautomap_drops_roles_except_inbox() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("noautomap");
let mut cfg = base_cfg(td.path());
cfg.automap = false;
let summary = run(common(&archive), cfg).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let sent_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'Sent'", [], |r| {
r.get(0)
})
.unwrap();
assert!(sent_role.is_none(), "automap off -> Sent has no role");
let inbox_role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'INBOX'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(inbox_role.as_deref(), Some("inbox"));
let _ = fs::remove_file(&archive);
}
#[test]
fn dry_run_reports_diff_without_writing_emails() {
let td = TempDir::new().unwrap();
dovecot_fixture(td.path());
let archive = tmp_archive("dry-run");
let mut common_cfg = common(&archive);
common_cfg.dry_run = true;
let summary = run(common_cfg, base_cfg(td.path())).expect("dryrun");
let (mailbox_label, mailbox_counts) = &summary.per_type[0];
let (email_label, email_counts) = &summary.per_type[1];
assert_eq!(*mailbox_label, "mailbox");
assert_eq!(*email_label, "email");
assert!(mailbox_counts.created >= 6);
assert!(email_counts.created >= 6);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0, "dry-run wrote no emails");
assert_eq!(count(&conn, "mailboxes"), 0, "dry-run wrote no mailboxes");
let _ = fs::remove_file(&archive);
}
#[test]
fn rejects_path_without_cur_subdir() {
let td = TempDir::new().unwrap();
fs::create_dir_all(td.path().join("new")).unwrap();
let archive = tmp_archive("not-a-maildir");
let err = run(common(&archive), base_cfg(td.path())).expect_err("should refuse");
assert!(matches!(err, inbuxa_migrate::error::Error::Usage(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn rejects_dovecot_layout_fs_tree() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
for s in ["cur", "new", "tmp"] {
fs::create_dir_all(td.path().join("Sent").join(s)).unwrap();
}
let archive = tmp_archive("layout-fs");
let err = run(common(&archive), base_cfg(td.path())).expect_err("should refuse");
match err {
inbuxa_migrate::error::Error::Usage(msg) => {
assert!(msg.contains("Maildir++"), "msg was: {msg}");
}
other => panic!("expected Usage error, got {other:?}"),
}
let _ = fs::remove_file(&archive);
}
#[test]
fn rejects_nonexistent_path() {
let archive = tmp_archive("nonexistent");
let mut cfg = base_cfg(Path::new("/definitely/not/a/real/maildir"));
cfg.maildir = PathBuf::from("/definitely/not/a/real/maildir");
let err = run(common(&archive), cfg).expect_err("should refuse");
assert!(matches!(err, inbuxa_migrate::error::Error::Usage(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn source_change_protection_refuses_second_path() {
let td_a = TempDir::new().unwrap();
dovecot_fixture(td_a.path());
let td_b = TempDir::new().unwrap();
qmail_fixture(td_b.path());
let archive = tmp_archive("source-change");
run(common(&archive), base_cfg(td_a.path())).expect("first import");
let err = run(common(&archive), base_cfg(td_b.path())).expect_err("second import refused");
assert!(matches!(err, inbuxa_migrate::error::Error::SourceChange(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn allow_source_change_unlocks_second_path() {
let td_a = TempDir::new().unwrap();
dovecot_fixture(td_a.path());
let td_b = TempDir::new().unwrap();
qmail_fixture(td_b.path());
let archive = tmp_archive("allow-source-change");
run(common(&archive), base_cfg(td_a.path())).expect("first import");
let mut second = base_cfg(td_b.path());
second.allow_source_change = true;
run(common(&archive), second).expect("second import permitted");
let _ = fs::remove_file(&archive);
}
#[test]
fn folder_and_include_are_mutually_exclusive() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
let archive = tmp_archive("mutex-folder-include");
let mut cfg = base_cfg(td.path());
cfg.folder = vec!["INBOX".to_owned()];
cfg.include = vec![regex::Regex::new("^Sent$").unwrap()];
let err = run(common(&archive), cfg).expect_err("mutex");
match err {
inbuxa_migrate::error::Error::Usage(msg) => {
assert!(
msg.contains("--folder") && msg.contains("--include"),
"msg was: {msg}"
);
}
other => panic!("expected Usage, got {other:?}"),
}
let _ = fs::remove_file(&archive);
}
#[test]
fn folder_and_exclude_are_mutually_exclusive() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
let archive = tmp_archive("mutex-folder-exclude");
let mut cfg = base_cfg(td.path());
cfg.folder = vec!["INBOX".to_owned()];
cfg.exclude = vec![regex::Regex::new("^Trash$").unwrap()];
let err = run(common(&archive), cfg).expect_err("mutex");
assert!(matches!(err, inbuxa_migrate::error::Error::Usage(_)));
let _ = fs::remove_file(&archive);
}
#[test]
fn three_level_folder_delete_is_leaf_first() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
ensure_subfolder(td.path(), ".A");
ensure_subfolder(td.path(), ".A.B");
let leaf = ensure_subfolder(td.path(), ".A.B.C");
write_message(
&leaf,
"cur",
"1.M0.host:2,S",
&rfc5322("3lvl-1", "x", "body"),
);
let archive = tmp_archive("three-level-delete");
run(common(&archive), base_cfg(td.path())).expect("first import");
fs::remove_dir_all(td.path().join(".A.B.C")).unwrap();
fs::remove_dir_all(td.path().join(".A.B")).unwrap();
let summary = run(common(&archive), base_cfg(td.path())).expect("second import");
assert!(!summary.any_failed(), "{summary:?}");
let (_, mbox) = &summary.per_type[0];
let (_, email) = &summary.per_type[1];
assert_eq!(mbox.deleted, 2, "B and B.C deleted leaf-first");
assert_eq!(email.deleted, 1);
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "A").is_some());
assert!(folder_mailbox_id(&conn, "A.B").is_none());
assert!(folder_mailbox_id(&conn, "A.B.C").is_none());
let _ = fs::remove_file(&archive);
}
#[test]
fn same_unique_id_in_two_folders_produces_two_rows_one_blob() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
let sent = ensure_subfolder(td.path(), ".Sent");
let body = rfc5322("dup-1", "shared", "shared body");
write_message(td.path(), "cur", "1.M0.host:2,S", &body);
write_message(&sent, "cur", "1.M0.host:2,S", &body);
let archive = tmp_archive("cross-folder-dup");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "blobs"), 1, "BLAKE3 dedup");
assert_eq!(count(&conn, "emails"), 2, "one row per folder");
let inbox = emails_for_folder(&conn, "INBOX");
let sent_rows = emails_for_folder(&conn, "Sent");
assert_eq!(inbox.len(), 1);
assert_eq!(sent_rows.len(), 1);
assert_eq!(inbox[0].0, sent_rows[0].0);
let _ = fs::remove_file(&archive);
}
#[cfg(unix)]
#[test]
fn unreadable_file_is_counted_and_warned_not_aborted() {
use std::os::unix::fs::PermissionsExt;
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
write_message(
td.path(),
"cur",
"1.M0.host:2,S",
&rfc5322("ok-1", "ok", "ok"),
);
let bad = write_message(
td.path(),
"cur",
"2.M0.host:2,S",
&rfc5322("bad-1", "denied", "denied"),
);
let mut perms = fs::metadata(&bad).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&bad, perms).unwrap();
let archive = tmp_archive("unreadable");
let summary = run(common(&archive), base_cfg(td.path())).expect("not aborted");
let mut perms = fs::metadata(&bad).unwrap().permissions();
perms.set_mode(0o600);
fs::set_permissions(&bad, perms).unwrap();
let (_, email) = &summary.per_type[1];
assert!(summary.any_failed(), "exit-5 worthy");
assert_eq!(email.failed, 1, "the bad file is counted");
assert_eq!(email.created, 1, "the readable file imports");
let _ = fs::remove_file(&archive);
}
#[test]
fn pointing_at_a_dot_subfolder_warns_but_imports() {
let parent = TempDir::new().unwrap();
ensure_maildir(parent.path());
let sub = ensure_subfolder(parent.path(), ".Sent");
write_message(&sub, "cur", "1.M0.host:2,S", &rfc5322("sub-1", "x", "x"));
let archive = tmp_archive("dot-subfolder-as-root");
let summary = run(common(&archive), base_cfg(&sub)).expect("import proceeds");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(folder_mailbox_id(&conn, "INBOX").is_some());
assert_eq!(emails_for_folder(&conn, "INBOX").len(), 1);
let _ = fs::remove_file(&archive);
}
#[test]
fn malformed_message_yields_zero_message_match_but_imports() {
let td = TempDir::new().unwrap();
ensure_maildir(td.path());
write_message(td.path(), "cur", "1.M0.host:2,S", b"");
write_message(
td.path(),
"cur",
"2.M0.host:2,S",
b"X-Garbage: yes\r\n\r\nno headers",
);
let archive = tmp_archive("malformed");
let summary = run(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
let inbox = emails_for_folder(&conn, "INBOX");
assert_eq!(inbox.len(), 2);
let _ = fs::remove_file(&archive);
}
#[test]
fn received_at_comes_from_the_filename_not_a_clobbered_mtime() {
let td = TempDir::new().unwrap();
let root = td.path();
ensure_maildir(root);
let with_stamp = write_message(
root,
"cur",
"1763065233.M1P1.host,S=120:2,S",
&rfc5322("stamped", "stamped", "body"),
);
let without_stamp = write_message(
root,
"cur",
"M2P2.host:2,S",
&rfc5322("unstamped", "unstamped", "body"),
);
let restored =
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000);
for p in [&with_stamp, &without_stamp] {
let f = fs::File::options().write(true).open(p).unwrap();
f.set_modified(restored).unwrap();
}
let archive = tmp_archive("received_at");
let summary = run(common(&archive), base_cfg(root)).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mut stmt = conn
.prepare(
"SELECT e.received_at FROM emails e
JOIN blobs b ON b.id = e.blob_id
WHERE CAST(b.data AS TEXT) LIKE ?1",
)
.unwrap();
let stamped: String = stmt
.query_row(rusqlite::params!["%<[email protected]>%"], |r| r.get(0))
.unwrap();
assert_eq!(
stamped, "2025-11-13T20:20:33Z",
"the maildir filename timestamp wins over a restored mtime"
);
let unstamped: String = stmt
.query_row(rusqlite::params!["%<[email protected]>%"], |r| r.get(0))
.unwrap();
assert_eq!(
unstamped, "2025-05-12T10:00:00Z",
"without a filename timestamp the message Date header is used"
);
drop(stmt);
let _ = fs::remove_file(&archive);
}