Files
inbuxa-migrate/tests/sync_maildir.rs
T
2026-05-30 08:09:57 +02:00

649 lines
20 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
#![allow(dead_code, clippy::needless_borrows_for_generic_args)]
mod integration;
mod seeder;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use integration::stalwart::shared as shared_stalwart;
use rusqlite::Connection;
fn base_url() -> &'static str {
shared_stalwart().base_url()
}
use vandelay::jmap::account::AccountSelector;
use vandelay::jmap::http::Auth;
use vandelay::logging::Logger;
use vandelay::sync::import_maildir::{MaildirImportConfig, run as run_maildir};
use vandelay::sync::{self, CommonConfig, ConnectConfig, ImportConfig};
fn tmp_archive(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"vandelay-sync-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: true,
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 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(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 msg(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 build_fixture(root: &Path) {
ensure_maildir(root);
write(
root,
"cur",
"100.M001.host:2,S",
&msg("conv-i1", "i1", "body 1"),
);
write(
root,
"cur",
"101.M002.host:2,",
&msg("conv-i2", "i2", "body 2"),
);
write(
root,
"new",
"102.M003.host",
&msg("conv-i3", "i3", "body 3"),
);
let sent = ensure_subfolder(root, ".Sent");
write(
&sent,
"cur",
"200.M001.host:2,S",
&msg("conv-s1", "sent-1", "sent body"),
);
ensure_subfolder(root, ".Archive");
let arch25 = ensure_subfolder(root, ".Archive.2025");
write(
&arch25,
"cur",
"300.M001.host:2,S",
&msg("conv-a1", "arch-1", "arch body 1"),
);
write(
&arch25,
"cur",
"301.M002.host:2,SF",
&msg("conv-a2", "arch-2", "arch body 2"),
);
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn snapshot_hashes_by_folder(conn: &Connection) -> BTreeMap<String, BTreeMap<String, String>> {
let mut stmt = conn
.prepare(
"SELECT m.folder, b.hash, e.keywords
FROM emails e
JOIN blobs b ON b.id = e.blob_id
JOIN sync_id_maildir m ON m.local_id = e.id AND m.type_name = 'email'
ORDER BY m.folder, m.unique_id",
)
.unwrap();
let rows = stmt
.query_map([], |r| {
let folder: String = r.get(0)?;
let hash: Vec<u8> = r.get(1)?;
let kw: String = r.get(2)?;
Ok((folder, hex(&hash), kw))
})
.unwrap();
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for r in rows {
let (folder, hash, kw) = r.unwrap();
out.entry(folder).or_default().insert(hash, kw);
}
out
}
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
}
#[test]
fn second_run_with_no_changes_creates_nothing_and_deletes_nothing() {
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("idempotent");
let first = run_maildir(common(&archive), base_cfg(td.path())).expect("first");
assert!(!first.any_failed());
let (_, mbox_first) = &first.per_type[0];
let (_, email_first) = &first.per_type[1];
assert!(mbox_first.created >= 4);
assert_eq!(email_first.created, 6);
let second = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, mbox_second) = &second.per_type[0];
let (_, email_second) = &second.per_type[1];
assert_eq!(mbox_second.created, 0);
assert_eq!(mbox_second.deleted, 0);
assert_eq!(email_second.created, 0);
assert_eq!(email_second.deleted, 0);
assert_eq!(email_second.fetched, 0, "no body re-read on convergent run");
assert!(email_second.skipped >= 6);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn flag_change_between_runs_updates_keywords_only() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let path = write(
td.path(),
"cur",
"1.M0.host:2,",
&msg("flag-1", "subj", "body"),
);
let archive = tmp_archive("flag-update");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
let email_id: i64 = conn
.query_row("SELECT id FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
let blob_id: i64 = conn
.query_row(
"SELECT blob_id FROM emails WHERE id = ?1",
[email_id],
|r| r.get(0),
)
.unwrap();
let kw: String = conn
.query_row(
"SELECT keywords FROM emails WHERE id = ?1",
[email_id],
|r| r.get(0),
)
.unwrap();
assert!(!kw.contains("$seen"));
drop(conn);
fs::rename(&path, path.with_file_name("1.M0.host:2,S")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.created, 0);
assert_eq!(email_counts.deleted, 0);
assert_eq!(email_counts.fetched, 1, "exactly one flag-only update");
let conn = Connection::open(&archive).unwrap();
let new_id: i64 = conn
.query_row("SELECT id FROM emails LIMIT 1", [], |r| r.get(0))
.unwrap();
let new_blob_id: i64 = conn
.query_row("SELECT blob_id FROM emails WHERE id = ?1", [new_id], |r| {
r.get(0)
})
.unwrap();
let new_kw: String = conn
.query_row("SELECT keywords FROM emails WHERE id = ?1", [new_id], |r| {
r.get(0)
})
.unwrap();
assert_eq!(new_id, email_id, "row identity preserved");
assert_eq!(new_blob_id, blob_id, "blob is unchanged");
assert!(new_kw.contains("$seen"));
let _ = std::fs::remove_file(&archive);
}
#[test]
fn delete_on_disk_removes_email_row_and_orphan_blob() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let p1 = write(
td.path(),
"cur",
"1.M0.host:2,",
&msg("del-1", "subj-1", "body-1"),
);
write(
td.path(),
"cur",
"2.M0.host:2,",
&msg("del-2", "subj-2", "body-2"),
);
let archive = tmp_archive("delete");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 2);
assert_eq!(count(&conn, "blobs"), 2);
drop(conn);
fs::remove_file(&p1).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.deleted, 1);
assert_eq!(email_counts.created, 0);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 1);
assert_eq!(count(&conn, "blobs"), 1);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn add_on_disk_inserts_one_new_email_only() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
write(td.path(), "cur", "1.M0.host:2,", &msg("a-1", "1", "body"));
let archive = tmp_archive("add");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
write(td.path(), "cur", "2.M0.host:2,", &msg("a-2", "2", "body2"));
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.created, 1);
assert_eq!(email_counts.deleted, 0);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn folder_delete_vanishes_emails_leaf_first() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
ensure_subfolder(td.path(), ".Archive");
let arch25 = ensure_subfolder(td.path(), ".Archive.2025");
write(&arch25, "cur", "1.M0.host:2,", &msg("d-1", "del-1", "del"));
let archive = tmp_archive("folder-delete");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = '2025'", [], |r| r
.get::<_, i64>(0))
.is_ok()
);
drop(conn);
fs::remove_dir_all(td.path().join(".Archive.2025")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, mbox_counts) = &summary.per_type[0];
let (_, email_counts) = &summary.per_type[1];
assert_eq!(mbox_counts.deleted, 1);
assert_eq!(email_counts.deleted, 1);
let conn = Connection::open(&archive).unwrap();
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = '2025'", [], |r| r
.get::<_, i64>(0))
.is_err()
);
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Archive'", [], |r| {
r.get::<_, i64>(0)
})
.is_ok()
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn folder_rename_vanishes_old_and_inserts_new() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let old = ensure_subfolder(td.path(), ".Old");
write(
&old,
"cur",
"1.M0.host:2,",
&msg("r-1", "to-rename", "body"),
);
let archive = tmp_archive("folder-rename");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
fs::rename(td.path().join(".Old"), td.path().join(".New")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, mbox_counts) = &summary.per_type[0];
let (_, email_counts) = &summary.per_type[1];
assert_eq!(mbox_counts.deleted, 1);
assert_eq!(mbox_counts.created, 1);
assert_eq!(email_counts.deleted, 1);
assert_eq!(email_counts.created, 1);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "blobs"), 1, "blob retained via dedup");
let _ = std::fs::remove_file(&archive);
}
fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn relative_path_resolves_to_same_canonical_source() {
let _g = cwd_lock();
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("canonical");
run_maildir(common(&archive), base_cfg(td.path())).expect("first import");
let parent = td.path().parent().unwrap();
let dir_name = td.path().file_name().unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(parent).unwrap();
let mut second = base_cfg(Path::new(dir_name));
second.allow_source_change = false;
let result = run_maildir(common(&archive), second);
std::env::set_current_dir(cwd).unwrap();
assert!(result.is_ok(), "{result:?}");
let _ = std::fs::remove_file(&archive);
}
#[test]
fn dry_run_then_real_run_produces_same_counts_for_new() {
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("dry-then-real");
let mut dry = common(&archive);
dry.dry_run = true;
let dry_summary = run_maildir(dry, base_cfg(td.path())).expect("dry");
let (_, dry_email) = &dry_summary.per_type[1];
let new_via_dry = dry_email.created;
let real_summary = run_maildir(common(&archive), base_cfg(td.path())).expect("real run");
let (_, real_email) = &real_summary.per_type[1];
assert_eq!(new_via_dry, real_email.created);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn trashed_flag_added_between_runs_deletes_present_row() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let path = write(
td.path(),
"cur",
"trash.M0.host:2,",
&msg("t-1", "subj", "body"),
);
let archive = tmp_archive("trash-on-present");
run_maildir(common(&archive), base_cfg(td.path())).expect("first");
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 1);
drop(conn);
fs::rename(&path, path.with_file_name("trash.M0.host:2,T")).unwrap();
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("second");
let (_, email_counts) = &summary.per_type[1];
assert_eq!(email_counts.deleted, 1, "T flag drops the present row");
assert_eq!(email_counts.fetched, 0);
assert_eq!(email_counts.created, 0);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0);
let _ = std::fs::remove_file(&archive);
}
#[cfg(unix)]
#[test]
fn symlinked_subfolder_is_followed_and_appears_as_its_own_folder() {
let td = tempfile::TempDir::new().unwrap();
ensure_maildir(td.path());
let real = ensure_subfolder(td.path(), ".Real");
write(
&real,
"cur",
"1.M0.host:2,S",
&msg("sym-1", "shared", "shared body"),
);
std::os::unix::fs::symlink(&real, td.path().join(".Shared")).unwrap();
let archive = tmp_archive("symlink");
let summary = run_maildir(common(&archive), base_cfg(td.path())).expect("import");
assert!(!summary.any_failed(), "{summary:?}");
let conn = Connection::open(&archive).unwrap();
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Real'", [], |r| r
.get::<_, i64>(0))
.is_ok(),
"Real folder discovered"
);
assert!(
conn.query_row("SELECT 1 FROM mailboxes WHERE name = 'Shared'", [], |r| r
.get::<_, i64>(
0
))
.is_ok(),
"Shared (symlink) folder discovered"
);
assert_eq!(count(&conn, "blobs"), 1);
assert_eq!(count(&conn, "emails"), 2);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn convergence_is_stable_across_three_runs() {
let td = tempfile::TempDir::new().unwrap();
build_fixture(td.path());
let archive = tmp_archive("triple-converge");
run_maildir(common(&archive), base_cfg(td.path())).expect("r1");
let snap1 = {
let conn = Connection::open(&archive).unwrap();
snapshot_hashes_by_folder(&conn)
};
run_maildir(common(&archive), base_cfg(td.path())).expect("r2");
let snap2 = {
let conn = Connection::open(&archive).unwrap();
snapshot_hashes_by_folder(&conn)
};
run_maildir(common(&archive), base_cfg(td.path())).expect("r3");
let snap3 = {
let conn = Connection::open(&archive).unwrap();
snapshot_hashes_by_folder(&conn)
};
assert_eq!(snap1, snap2);
assert_eq!(snap2, snap3);
let _ = std::fs::remove_file(&archive);
}
fn lay_seeder_mbox_as_maildir(root: &Path, messages: &[seeder::data::MboxMessage]) {
ensure_maildir(root);
for (i, m) in messages.iter().enumerate() {
let filename = format!("{seq}.M0.parity-host:2,S", seq = 1_700_000_000 + i as u64);
write(root, "cur", &filename, &m.raw);
}
}
#[test]
#[ignore = "requires Docker"]
fn maildir_and_jmap_imports_share_blob_bytes() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let corpus = seeder::data::load_mbox(50).expect("mbox corpus");
let td = tempfile::TempDir::new().unwrap();
lay_seeder_mbox_as_maildir(td.path(), &corpus);
let md_archive = tmp_archive("parity-maildir");
let jmap_archive = tmp_archive("parity-jmap");
run_maildir(common(&md_archive), base_cfg(td.path())).expect("maildir import");
let jmap_cfg = ImportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: acc.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(acc.account_id.clone()),
},
objects: Some(vec![
vandelay::types::ObjectType::Mailbox,
vandelay::types::ObjectType::Email,
]),
allow_source_change: false,
};
let jmap_common = CommonConfig {
archive: jmap_archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
let md_hashes = blob_hashes(&Connection::open(&md_archive).unwrap());
let jmap_hashes = blob_hashes(&Connection::open(&jmap_archive).unwrap());
assert!(
!md_hashes.is_empty(),
"maildir archive must have at least one blob"
);
let missing: Vec<&String> = md_hashes
.iter()
.filter(|h| !jmap_hashes.contains(*h))
.collect();
assert!(
missing.is_empty(),
"{} blobs in maildir archive missing from JMAP archive: {:?}",
missing.len(),
missing.iter().take(3).collect::<Vec<_>>()
);
let _ = std::fs::remove_file(&md_archive);
let _ = std::fs::remove_file(&jmap_archive);
seeder::teardown(base_url()).expect("teardown");
}
fn blob_hashes(conn: &Connection) -> std::collections::HashSet<String> {
let mut stmt = conn.prepare("SELECT hash FROM blobs").unwrap();
stmt.query_map([], |r| Ok(hex(&r.get::<_, Vec<u8>>(0)?)))
.unwrap()
.filter_map(Result::ok)
.collect()
}
#[test]
#[ignore = "requires Docker"]
fn maildir_message_count_matches_jmap_for_same_corpus() {
let fx = seeder::provision(base_url()).expect("provision");
let acc = fx.account("test1").expect("test1");
let corpus = seeder::data::load_mbox(30).expect("mbox corpus");
let td = tempfile::TempDir::new().unwrap();
lay_seeder_mbox_as_maildir(td.path(), &corpus);
let md_archive = tmp_archive("count-maildir");
run_maildir(common(&md_archive), base_cfg(td.path())).expect("maildir import");
let md_conn = Connection::open(&md_archive).unwrap();
let md_count = count(&md_conn, "emails");
assert_eq!(
md_count as usize,
corpus.len(),
"all corpus messages imported"
);
let jmap_archive = tmp_archive("count-jmap");
let jmap_cfg = ImportConfig {
connect: ConnectConfig {
url: fx.base_url.clone(),
auth: Auth::Basic {
user: acc.email.clone(),
password: seeder::USER_PASSWORD.to_owned(),
},
account: AccountSelector::Id(acc.account_id.clone()),
},
objects: Some(vec![
vandelay::types::ObjectType::Mailbox,
vandelay::types::ObjectType::Email,
]),
allow_source_change: false,
};
let jmap_common = CommonConfig {
archive: jmap_archive.clone(),
threads: 4,
dry_run: false,
max_retries: 5,
allow_invalid_certs: true,
logger: Logger::from_flags(false, 0),
};
sync::import_jmap::run(jmap_common, jmap_cfg).expect("jmap import");
let _ = std::fs::remove_file(&md_archive);
let _ = std::fs::remove_file(&jmap_archive);
seeder::teardown(base_url()).expect("teardown");
}