Files
inbuxa-migrate/tests/sync_takeout.rs
T
2026-05-29 18:02:15 +02:00

786 lines
24 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use tempfile::TempDir;
use vandelay::logging::Logger;
use vandelay::sync::CommonConfig;
use vandelay::sync::import_takeout::{TakeoutImportConfig, run};
const RESOURCES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/resources");
fn common_for(archive: &Path) -> CommonConfig {
CommonConfig {
archive: archive.to_path_buf(),
threads: 1,
dry_run: false,
max_retries: 0,
allow_invalid_certs: false,
logger: Logger::from_flags(true, 0),
}
}
fn config_for(root: PathBuf) -> TakeoutImportConfig {
TakeoutImportConfig {
takeout_root: root,
allow_source_change: false,
automap: true,
}
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn mailbox_names(conn: &Connection) -> HashSet<String> {
let mut stmt = conn.prepare("SELECT name FROM mailboxes").unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
}
struct SyntheticTakeout {
root: PathBuf,
}
impl SyntheticTakeout {
fn build(root: &Path) -> Self {
Self::write_mail(root);
Self::write_calendar(root);
Self::write_contacts(root);
Self::write_noise_files(root);
Self {
root: root.to_path_buf(),
}
}
fn path(&self) -> &Path {
&self.root
}
fn write_mail(root: &Path) {
let mail_dir = root.join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
let mut all_mail = String::new();
let messages = [
(1, "Inbox,Opened", "Plain inbox read"),
(
2,
"Inbox,Important,Opened,WorkProject",
"Tagged with custom label",
),
(3, "Inbox,Opened,Starred", "Starred mail"),
(4, "Archived,Sent,Opened", "Sent and archived"),
(
5,
"Archived,Sent,Opened,WorkProject/2026-Q1",
"Nested custom label",
),
(
6,
"Trash,Category Social,Unread",
"Trash + Category dropped, unread",
),
(
7,
"Inbox,Opened,Category Promotions",
"Inbox + Category Promotions dropped",
),
];
for (n, labels, subject) in messages {
all_mail.push_str(&takeout_message(n, labels, subject));
}
fs::write(
mail_dir.join("All Mail Including Spam and Trash.mbox"),
all_mail,
)
.unwrap();
let mut per_label = String::new();
for n in 8..=9 {
per_label.push_str(&takeout_message(
n,
"Inbox,Opened,Github",
"Per-label export",
));
}
fs::write(mail_dir.join("Github.mbox"), per_label).unwrap();
}
fn write_calendar(root: &Path) {
let cal_dir = root.join("Takeout/Calendar");
fs::create_dir_all(&cal_dir).unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/000.ics"),
cal_dir.join("Weekly.ics"),
)
.unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/002.ics"),
cal_dir.join("no_calname.ics"),
)
.unwrap();
fs::write(
cal_dir.join("meet_settings.json"),
br#"{"Meeting data":[{"Meeting code":"abc-defg-hij"}]}"#,
)
.unwrap();
}
fn write_contacts(root: &Path) {
let all_dir = root.join("Takeout/Contacts/All Contacts");
let my_dir = root.join("Takeout/Contacts/My Contacts");
let starred_dir = root.join("Takeout/Contacts/Starred in Android");
fs::create_dir_all(&all_dir).unwrap();
fs::create_dir_all(&my_dir).unwrap();
fs::create_dir_all(&starred_dir).unwrap();
let all = synthetic_vcards_with_and_without_uid();
fs::write(all_dir.join("All Contacts.vcf"), &all).unwrap();
fs::write(my_dir.join("My Contacts.vcf"), &all).unwrap();
fs::write(starred_dir.join("Starred in Android.vcf"), "").unwrap();
fs::write(
all_dir.join("Alice Example.jpg"),
[0xff_u8, 0xd8, 0xff, 0xe0],
)
.unwrap();
fs::write(
my_dir.join("Alice Example.jpg"),
[0xff_u8, 0xd8, 0xff, 0xe0],
)
.unwrap();
}
fn write_noise_files(root: &Path) {
fs::write(root.join("Takeout/archive_browser.html"), b"<html/>").unwrap();
fs::write(root.join("Takeout/user-generated-memory.json"), b"{}").unwrap();
}
}
fn takeout_message(n: u64, labels: &str, subject: &str) -> String {
format!(
"From {n}@xxx Thu Nov 13 20:20:33 +0000 2025\n\
X-GM-THRID: {n}\n\
X-Gmail-Labels: {labels}\n\
Message-ID: <synthetic-{n}@example.com>\n\
From: \"Anon\" <[email protected]>\n\
To: \"Anon\" <[email protected]>\n\
Subject: {subject}\n\
Date: Thu, 13 Nov 2025 20:20:33 +0000\n\
Content-Type: text/plain; charset=us-ascii\n\
\n\
Synthetic body for message {n}.\n\
\n"
)
}
fn synthetic_vcards_with_and_without_uid() -> String {
let mut buf = String::new();
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:synthetic-uid-1\r\n\
FN:Alice Example\r\n\
N:Example;Alice;;;\r\n\
EMAIL;TYPE=INTERNET:[email protected]\r\n\
TEL;TYPE=CELL:+1-555-0100\r\n\
END:VCARD\r\n",
);
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
UID:synthetic-uid-2\r\n\
FN:Bob Example\r\n\
N:Example;Bob;;;\r\n\
EMAIL;TYPE=INTERNET:[email protected]\r\n\
END:VCARD\r\n",
);
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
FN:Carol Example\r\n\
N:Example;Carol;;;\r\n\
EMAIL;TYPE=INTERNET:[email protected]\r\n\
END:VCARD\r\n",
);
buf.push_str(
"BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
item1.EMAIL;TYPE=INTERNET:[email protected]\r\n\
item1.X-ABLabel:\r\n\
END:VCARD\r\n",
);
buf
}
#[test]
fn empty_directory_fails_with_usage() {
let td = TempDir::new().unwrap();
let archive = td.path().join("a.sqlite");
let err = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap_err();
assert!(format!("{err}").contains("no .mbox"));
assert!(matches!(err, vandelay::error::Error::Usage(_)));
assert_eq!(err.exit_code(), 1);
}
#[test]
fn nonexistent_path_fails_with_usage() {
let td = TempDir::new().unwrap();
let archive = td.path().join("a.sqlite");
let missing = td.path().join("does-not-exist");
let err = run(common_for(&archive), config_for(missing)).unwrap_err();
assert!(matches!(err, vandelay::error::Error::Usage(_)));
assert_eq!(err.exit_code(), 1);
}
#[test]
fn synthetic_takeout_imports_expected_row_counts() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 9);
assert_eq!(count(&conn, "calendars"), 2);
assert!(count(&conn, "calendar_events") >= 2);
assert_eq!(count(&conn, "address_books"), 1);
assert_eq!(
count(&conn, "contact_cards"),
6,
"2 UID-bearing cards dedup across the two .vcf files (=2); \
2 UID-less cards get distinct synthetic UIDs per file (=4); total 6"
);
let by = |t: &str| {
summary
.per_type
.iter()
.find(|(n, _)| *n == t)
.unwrap()
.1
.clone()
};
assert!(by("mailbox").created >= 6);
assert_eq!(by("email").created, 9);
assert_eq!(by("email").failed, 0);
assert_eq!(by("calendarevent").failed, 0);
assert_eq!(by("contactcard").failed, 0);
}
#[test]
fn label_mapping_produces_expected_mailbox_tree() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let names = mailbox_names(&conn);
for required in [
"Inbox",
"Sent",
"Archive",
"Trash",
"WorkProject",
"2026-Q1",
"Github",
] {
assert!(
names.contains(required),
"missing mailbox {required:?}: have {names:?}"
);
}
assert!(
names.iter().all(|n| !n.starts_with("Category")),
"Category tokens must be dropped: {names:?}"
);
assert!(
!names.contains("Starred"),
"Starred is a keyword, not a mailbox: {names:?}"
);
assert!(
!names.contains("Important"),
"Important is a keyword, not a mailbox: {names:?}"
);
let nested_parent_id: i64 = conn
.query_row(
"SELECT parent_id FROM mailboxes WHERE name = '2026-Q1'",
[],
|r| r.get(0),
)
.unwrap();
let parent_name: String = conn
.query_row(
"SELECT name FROM mailboxes WHERE id = ?1",
[nested_parent_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(parent_name, "WorkProject");
}
#[test]
fn system_roles_assigned_when_automap_on() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let expected = [
("Inbox", "inbox"),
("Sent", "sent"),
("Trash", "trash"),
("Archive", "archive"),
];
for (name, role) in expected {
let got: String = conn
.query_row("SELECT role FROM mailboxes WHERE name = ?1", [name], |r| {
r.get(0)
})
.unwrap_or_else(|_| panic!("mailbox {name} missing"));
assert_eq!(got, role, "{name} should have role {role}");
}
let custom_role: Option<String> = conn
.query_row(
"SELECT role FROM mailboxes WHERE name = 'WorkProject'",
[],
|r| r.get(0),
)
.unwrap();
assert!(custom_role.is_none());
}
#[test]
fn keyword_translation_matches_label_table() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let starred: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$flagged%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(starred, 1);
let important: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$important%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(important, 1);
let seen: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords LIKE '%$seen%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(seen, 8);
let unseen: i64 = conn
.query_row(
"SELECT COUNT(*) FROM emails WHERE keywords NOT LIKE '%$seen%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(unseen, 1);
}
#[test]
fn calendar_xwr_calname_honored_else_falls_back_to_imported() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let names: HashSet<String> = {
let mut s = conn.prepare("SELECT name FROM calendars").unwrap();
s.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect()
};
assert!(
names.contains("weekly"),
"weekly from icals/000.ics: {names:?}"
);
assert!(
names.contains("Imported"),
"Imported fallback for icals/002.ics: {names:?}"
);
}
#[test]
fn contacts_dedup_by_uid_across_two_vcf_files() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "address_books"), 1);
let with_uid: i64 = conn
.query_row(
"SELECT COUNT(*) FROM contact_cards WHERE uid IN ('synthetic-uid-1', 'synthetic-uid-2')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(with_uid, 2);
let synthetic_uids: i64 = conn
.query_row(
"SELECT COUNT(*) FROM contact_cards WHERE uid LIKE 'vandelay-syn-%'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
synthetic_uids >= 2,
"expected synthetic UIDs for the 2 UID-less cards * 2 files"
);
}
#[test]
fn rerun_against_unchanged_source_is_idempotent() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let s2 = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let email_counts = &s2.per_type.iter().find(|(n, _)| *n == "email").unwrap().1;
assert_eq!(email_counts.created, 0);
assert_eq!(email_counts.failed, 0);
assert_eq!(email_counts.skipped, 9);
let event_counts = &s2
.per_type
.iter()
.find(|(n, _)| *n == "calendarevent")
.unwrap()
.1;
assert_eq!(event_counts.created, 0);
assert_eq!(event_counts.failed, 0);
assert!(event_counts.skipped > 0);
let card_counts = &s2
.per_type
.iter()
.find(|(n, _)| *n == "contactcard")
.unwrap()
.1;
assert_eq!(card_counts.created, 0);
assert_eq!(card_counts.failed, 0);
assert!(card_counts.skipped > 0);
}
#[test]
fn message_without_xgmail_labels_lands_in_filename_mailbox() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(
mail_dir.join("ImportedFromThunderbird.mbox"),
b"From a@b Thu Nov 13 20:20:33 +0000 2025\nFrom: a@b\nSubject: nolabels\n\nbody\n",
)
.unwrap();
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert!(mailbox_names(&conn).contains("ImportedFromThunderbird"));
}
#[test]
fn nonmatching_siblings_are_ignored_silently() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
assert_eq!(
summary
.per_type
.iter()
.find(|(n, _)| *n == "email")
.unwrap()
.1
.failed,
0,
"the .json / .html / .jpg sidecars must not cause failures"
);
}
#[test]
fn source_change_protection_refuses_different_path() {
let td = TempDir::new().unwrap();
let a = td.path().join("a");
let b = td.path().join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
SyntheticTakeout::build(&a);
SyntheticTakeout::build(&b);
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(a.clone())).unwrap();
let err = run(common_for(&archive), config_for(b.clone())).unwrap_err();
assert!(matches!(err, vandelay::error::Error::SourceChange(_)));
let mut cfg2 = config_for(b);
cfg2.allow_source_change = true;
run(common_for(&archive), cfg2).expect("--allow-source-change permits");
}
#[test]
fn re_extraction_to_same_canonical_path_converges() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let initial: i64 = {
let c = Connection::open(&archive).unwrap();
count(&c, "emails")
};
fs::remove_dir_all(td.path().join("Takeout")).unwrap();
SyntheticTakeout::build(td.path());
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let c = Connection::open(&archive).unwrap();
assert_eq!(count(&c, "emails"), initial);
}
#[test]
fn noautomap_disables_role_assignment() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let mut cfg = config_for(td.path().to_path_buf());
cfg.automap = false;
run(common_for(&archive), cfg).unwrap();
let conn = Connection::open(&archive).unwrap();
let with_role: i64 = conn
.query_row(
"SELECT COUNT(*) FROM mailboxes WHERE role IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
with_role, 0,
"no mailbox should have a role under --noautomap"
);
}
#[test]
fn synthetic_takeout_layout_mirrors_real_takeout_directory_shape() {
let td = TempDir::new().unwrap();
let synth = SyntheticTakeout::build(td.path());
let root = synth.path();
assert!(
root.join("Takeout/Mail/All Mail Including Spam and Trash.mbox")
.exists()
);
assert!(root.join("Takeout/Mail/Github.mbox").exists());
assert!(root.join("Takeout/Calendar/Weekly.ics").exists());
assert!(root.join("Takeout/Calendar/no_calname.ics").exists());
assert!(root.join("Takeout/Calendar/meet_settings.json").exists());
assert!(
root.join("Takeout/Contacts/All Contacts/All Contacts.vcf")
.exists()
);
assert!(
root.join("Takeout/Contacts/All Contacts/Alice Example.jpg")
.exists()
);
assert!(
root.join("Takeout/Contacts/My Contacts/My Contacts.vcf")
.exists()
);
assert!(
root.join("Takeout/Contacts/Starred in Android/Starred in Android.vcf")
.exists()
);
assert!(root.join("Takeout/archive_browser.html").exists());
assert!(root.join("Takeout/user-generated-memory.json").exists());
}
#[test]
fn dry_run_creates_no_object_rows_in_archive() {
let td = TempDir::new().unwrap();
SyntheticTakeout::build(td.path());
let archive = td.path().join("a.sqlite");
let mut common = common_for(&archive);
common.dry_run = true;
let summary = run(common, config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0);
assert_eq!(count(&conn, "mailboxes"), 0);
assert_eq!(count(&conn, "calendars"), 0);
assert_eq!(count(&conn, "calendar_events"), 0);
assert_eq!(count(&conn, "address_books"), 0);
assert_eq!(count(&conn, "contact_cards"), 0);
assert_eq!(count(&conn, "blobs"), 0);
for (_, counts) in &summary.per_type {
assert_eq!(counts.created, 0);
assert_eq!(counts.fetched, 0);
assert_eq!(counts.failed, 0);
}
}
#[test]
fn byte_identical_message_in_two_mbox_files_dedupes_to_one_row() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
let body = takeout_message(1, "Inbox,Opened,Github", "shared subject");
fs::write(mail_dir.join("All.mbox"), &body).unwrap();
fs::write(mail_dir.join("Github.mbox"), &body).unwrap();
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert_eq!(
count(&conn, "emails"),
1,
"the same bytes across two .mbox files must dedupe to one emails row"
);
assert_eq!(count(&conn, "blobs"), 1, "blob layer dedupes too");
let email = &summary
.per_type
.iter()
.find(|(n, _)| *n == "email")
.unwrap()
.1;
assert_eq!(email.created, 1);
assert!(
email.skipped + email.fetched >= 1,
"the second occurrence must go through the present-row path"
);
}
#[test]
fn two_ics_files_without_calname_merge_into_single_imported_calendar() {
let td = TempDir::new().unwrap();
let cal_dir = td.path().join("Takeout/Calendar");
fs::create_dir_all(&cal_dir).unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/002.ics"),
cal_dir.join("first.ics"),
)
.unwrap();
fs::copy(
Path::new(RESOURCES).join("icals/002.ics"),
cal_dir.join("second.ics"),
)
.unwrap();
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
let imported_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM calendars WHERE name = 'Imported'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
imported_count, 1,
"two .ics files without X-WR-CALNAME must share one Imported calendar"
);
}
#[test]
fn empty_mbox_file_imports_with_no_failures() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(mail_dir.join("Empty.mbox"), b"").unwrap();
let archive = td.path().join("a.sqlite");
let summary = run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let email = &summary
.per_type
.iter()
.find(|(n, _)| *n == "email")
.unwrap()
.1;
assert_eq!(email.created, 0);
assert_eq!(email.failed, 0);
let conn = Connection::open(&archive).unwrap();
assert_eq!(count(&conn, "emails"), 0);
assert_eq!(count(&conn, "mailboxes"), 0);
}
#[test]
fn chat_label_creates_chat_mailbox_with_no_role() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(
mail_dir.join("All.mbox"),
takeout_message(1, "Chat,Opened", "hello"),
)
.unwrap();
let archive = td.path().join("a.sqlite");
run(common_for(&archive), config_for(td.path().to_path_buf())).unwrap();
let conn = Connection::open(&archive).unwrap();
assert!(mailbox_names(&conn).contains("Chat"));
let role: Option<String> = conn
.query_row("SELECT role FROM mailboxes WHERE name = 'Chat'", [], |r| {
r.get(0)
})
.unwrap();
assert!(role.is_none(), "Chat is a system label with no JMAP role");
}
#[test]
fn noautomap_leaves_nested_mailboxes_role_null() {
let td = TempDir::new().unwrap();
let mail_dir = td.path().join("Takeout/Mail");
fs::create_dir_all(&mail_dir).unwrap();
fs::write(
mail_dir.join("All.mbox"),
takeout_message(1, "Inbox,Opened,Project/2026-Q1", "nested"),
)
.unwrap();
let archive = td.path().join("a.sqlite");
let mut cfg = config_for(td.path().to_path_buf());
cfg.automap = false;
run(common_for(&archive), cfg).unwrap();
let conn = Connection::open(&archive).unwrap();
let with_role: i64 = conn
.query_row(
"SELECT COUNT(*) FROM mailboxes WHERE role IS NOT NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(with_role, 0);
let names = mailbox_names(&conn);
assert!(names.contains("Inbox"));
assert!(names.contains("Project"));
assert!(names.contains("2026-Q1"));
}