use std::{fs, io::Write, path::Path, process::Command};
use io_pimdir::{
client::{blobs::PimdirBlobs, producer::PimdirProducer, reader::PimdirReader},
codec::PimdirAction,
collection::PimdirCollectionId,
object::PimdirObject,
};
const IMAP_ROOT: &str = "imap://127.0.0.1:143";
const IMAP_USER: &str = "test@pimalaya.org";
const IMAP_PASS: &str = "P!malaya-test-2026";
const DAV: &str = "http://127.0.0.1:5232";
const DAV_USER: &str = "test";
const ACCOUNT: &str = "multikind";
const MAILBOX: &str = "multikindmail";
const BOOK: &str = "multikindcards";
const MAIL_COLLECTION: &str = "imap/multikindmail";
const CARD_COLLECTION: &str = "carddav/multikindcards";
const CARD: &str = "multikind-card";
const KEPT: &str = "multikind-kept";
const GONE: &str = "multikind-gone";
fn card(tel: &str) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:{CARD}\r\n\
FN:Jane Doe\r\n\
TEL:{tel}\r\n\
END:VCARD\r\n",
)
}
fn message(marker: &str) -> Vec<u8> {
format!(
"Message-ID: <{marker}@pimalaya.org>\r\n\
From: alice@pimalaya.org\r\n\
To: bob@pimalaya.org\r\n\
Subject: neverest multikind {marker}\r\n\
Date: Tue, 25 Aug 2026 10:00:00 +0000\r\n\
\r\n\
{marker}\r\n",
)
.into_bytes()
}
#[test]
#[ignore = "requires Stalwart (./tests/stalwart2.sh) on :143, Radicale (./tests/radicale.sh) on :5232 and --ignored"]
fn one_account_syncing_mail_and_contacts_carries_both_and_dispatches_each_by_kind() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
let state = root.join("state");
let config = root.join("config.toml");
fs::create_dir_all(&state).unwrap();
fs::write(&config, account()).unwrap();
create_mailbox();
append(root, KEPT);
append(root, GONE);
create_book(root);
put(root, &card("+1"));
neverest(&["init", "-a", ACCOUNT], &config, &state, 0);
sync(&config, &state, 0);
let store = state.join("neverest").join(ACCOUNT);
assert_eq!(links(&store, MAIL_COLLECTION).len(), 2, "both messages");
assert_eq!(links(&store, CARD_COLLECTION).len(), 1, "the card");
imap("STORE 1 +Flags \\Flagged");
imap("STORE 2 +Flags \\Deleted");
imap("EXPUNGE");
edit_card_in_store(&store, &card("+2"));
put(root, &card("+3"));
let report = sync(&config, &state, 2);
assert!(
report.contains(r#""outstandingConflicts":1"#),
"one item is waiting for a decision, no more; report was:\n{report}",
);
assert!(
report.contains(&format!(
r#"{{"side":"carddav","collection":"{BOOK}","id":"{CARD}.vcf"}}"#
)),
"the contacts side is named as the one that parked; report was:\n{report}",
);
assert!(
report.contains(&format!(
r#""kind":"add-flags","side":"imap","collection":"{MAILBOX}""#
)),
"and the mail side as one that applied, in the same report; report was:\n{report}",
);
assert!(
report.contains(&format!(
r#""kind":"delete","side":"imap","collection":"{MAILBOX}""#
)),
"along with the removal the server made; report was:\n{report}",
);
let listed = neverest(
&["conflict", "list", "-a", ACCOUNT, "--json"],
&config,
&state,
0,
);
let listed: serde_json::Value = serde_json::from_str(&listed).expect("conflict listing");
let conflicts = listed["conflicts"].as_array().expect("a conflict array");
assert_eq!(conflicts.len(), 1, "one parked item: {listed}");
assert_eq!(
conflicts[0]["collection"], CARD_COLLECTION,
"the parked item is the card, not a message: {listed}",
);
assert_eq!(
conflicts[0]["handle"],
format!("{CARD}.vcf"),
"named by its handle on the CardDAV source: {listed}",
);
assert!(
get(CARD).contains("TEL:+3"),
"the CardDAV server keeps its own edit",
);
let reader = PimdirReader::open(&store).expect("open the store");
let kept = reader
.list_items(MAIL_COLLECTION, None, 10)
.expect("list the mailbox")
.into_iter()
.find(|item| item.link_id.0.contains(KEPT))
.expect("the kept message is still live");
assert!(
kept.flags.contains("\\Flagged"),
"the flag the server set applied, no decision needed; flags were {:?}",
kept.flags,
);
let live = links(&store, MAIL_COLLECTION);
assert_eq!(live.len(), 1, "the expunged message left the live listing");
let retained = reader
.list_retained(PimdirCollectionId(MAIL_COLLECTION.into()), None, 10)
.expect("list the retained mail");
assert!(
retained.iter().any(|item| item.link_id.0.contains(GONE)),
"and is retained rather than lost",
);
let collections: Vec<String> = reader
.list_collections()
.expect("list the store collections")
.into_iter()
.map(|collection| collection.id)
.collect();
assert!(
collections.iter().any(|id| id == MAIL_COLLECTION)
&& collections.iter().any(|id| id == CARD_COLLECTION),
"both kinds are keyed under their own source; store held:\n{collections:?}",
);
assert!(
!links(&store, MAIL_COLLECTION)
.iter()
.any(|link| link.contains(CARD)),
"no card reached the mailbox",
);
assert_eq!(
links(&store, CARD_COLLECTION),
vec![String::from(CARD)],
"and no message reached the address book",
);
}
fn account() -> String {
format!(
"[accounts.{ACCOUNT}]\n\
imap.server = \"{IMAP_ROOT}\"\n\
imap.starttls = false\n\
imap.sasl.plain.username = \"{IMAP_USER}\"\n\
imap.sasl.plain.password.raw = \"{IMAP_PASS}\"\n\
carddav.server = \"{DAV}/\"\n\
carddav.auth.basic.username = \"{DAV_USER}\"\n\
carddav.auth.basic.password.raw = \"{DAV_USER}\"\n",
)
}
fn sync(config: &Path, state: &Path, code: i32) -> String {
neverest(
&["sync", "-a", ACCOUNT, "-m", MAILBOX, "-m", BOOK, "--json"],
config,
state,
code,
)
}
fn edit_card_in_store(store: &Path, body: &str) {
let reader = PimdirReader::open(store).expect("open the store");
let seq = reader
.list_items(CARD_COLLECTION, None, 10)
.expect("list the address book")
.first()
.expect("the card is in the store")
.seq;
let mut producer = PimdirProducer::open(store, "neverest-tests").expect("open producer");
let hash = producer.hash(body.as_bytes());
let blobs = PimdirBlobs::open(store, producer.hash_algo());
let mut writer = blobs.writer().expect("blob writer");
writer.write_all(body.as_bytes()).unwrap();
let object = PimdirObject {
hash: hash.clone(),
size: writer.commit(&hash).expect("commit the body") as usize,
};
producer
.enqueue(
CARD_COLLECTION,
&PimdirAction::Update { seq, object: hash },
Some(&object),
)
.expect("enqueue the edit");
}
fn links(store: &Path, collection: &str) -> Vec<String> {
PimdirReader::open(store)
.expect("open the store")
.list_items(collection, None, 100)
.expect("list the collection")
.into_iter()
.map(|item| item.link_id.0)
.collect()
}
fn create_mailbox() {
let _ = Command::new("curl")
.args(["-sS", "-o", "/dev/null", "--url", IMAP_ROOT])
.args(["--user", &format!("{IMAP_USER}:{IMAP_PASS}")])
.args(["-X", &format!("DELETE {MAILBOX}")])
.output()
.expect("spawn curl delete mailbox");
let output = Command::new("curl")
.args(["-fsS", "-o", "/dev/null", "--url", IMAP_ROOT])
.args(["--user", &format!("{IMAP_USER}:{IMAP_PASS}")])
.args(["-X", &format!("CREATE {MAILBOX}")])
.output()
.expect("spawn curl create mailbox");
assert!(
output.status.success(),
"CREATE {MAILBOX} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn append(root: &Path, marker: &str) {
let file = root.join(format!("{marker}.eml"));
fs::write(&file, message(marker)).unwrap();
let output = Command::new("curl")
.args(["-fsS", "-T"])
.arg(&file)
.arg(format!("{IMAP_ROOT}/{MAILBOX}"))
.args(["--user", &format!("{IMAP_USER}:{IMAP_PASS}")])
.output()
.expect("spawn curl append");
assert!(
output.status.success(),
"APPEND {marker} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn imap(command: &str) {
let output = Command::new("curl")
.args(["-fsS", "-o", "/dev/null"])
.arg(format!("{IMAP_ROOT}/{MAILBOX}"))
.args(["--user", &format!("{IMAP_USER}:{IMAP_PASS}")])
.args(["-X", command])
.output()
.expect("spawn curl imap command");
assert!(
output.status.success(),
"`{command}` failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn create_book(root: &Path) {
let output = Command::new("curl")
.args(["-sS", "-o", "/dev/null", "-X", "DELETE"])
.args(["-u", &format!("{DAV_USER}:{DAV_USER}")])
.arg(format!("{DAV}/{DAV_USER}/{BOOK}/"))
.output()
.expect("spawn curl delete book");
assert!(output.status.success(), "DELETE of the address book failed");
let body = root.join("mkcol.xml");
fs::write(
&body,
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\
<D:mkcol xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:carddav\">\
<D:set><D:prop>\
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>\
<D:displayname>{BOOK}</D:displayname>\
</D:prop></D:set></D:mkcol>",
),
)
.unwrap();
let output = Command::new("curl")
.args([
"-fsS",
"-X",
"MKCOL",
"-u",
&format!("{DAV_USER}:{DAV_USER}"),
])
.args(["-H", "Content-Type: application/xml; charset=utf-8"])
.arg("--data-binary")
.arg(format!("@{}", body.display()))
.arg(format!("{DAV}/{DAV_USER}/{BOOK}/"))
.output()
.expect("spawn curl mkcol");
assert!(
output.status.success(),
"MKCOL {BOOK} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn put(root: &Path, body: &str) {
let file = root.join("card.vcf");
fs::write(&file, body).unwrap();
let output = Command::new("curl")
.args(["-fsS", "-X", "PUT", "-u", &format!("{DAV_USER}:{DAV_USER}")])
.args(["-H", "Content-Type: text/vcard; charset=utf-8"])
.arg("--data-binary")
.arg(format!("@{}", file.display()))
.arg(format!("{DAV}/{DAV_USER}/{BOOK}/{CARD}.vcf"))
.output()
.expect("spawn curl put");
assert!(
output.status.success(),
"PUT {CARD} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn get(id: &str) -> String {
let output = Command::new("curl")
.args(["-fsS", "-u", &format!("{DAV_USER}:{DAV_USER}")])
.arg(format!("{DAV}/{DAV_USER}/{BOOK}/{id}.vcf"))
.output()
.expect("spawn curl get");
assert!(
output.status.success(),
"GET {id} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn neverest(args: &[&str], config: &Path, state: &Path, code: i32) -> String {
let output = Command::new(env!("CARGO_BIN_EXE_neverest"))
.args(["-c", &config.to_string_lossy()])
.args(args)
.env("XDG_STATE_HOME", state)
.output()
.expect("spawn neverest");
assert_eq!(
output.status.code(),
Some(code),
"`neverest {}` ended on the wrong code:\n--- stdout ---\n{}\n--- stderr ---\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8_lossy(&output.stdout).into_owned()
}