/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use std::path::{Path, PathBuf}; use inbuxa_migrate::db; use inbuxa_migrate::jmap::account::AccountSelector; use inbuxa_migrate::jmap::http::Auth; use inbuxa_migrate::logging::Logger; use inbuxa_migrate::sync::{self, CommonConfig, ConnectConfig, ExportConfig, ImportConfig}; use inbuxa_migrate::types::ObjectType; use mockito::Matcher; use serde_json::{Value, json}; fn tmp() -> PathBuf { static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut p = std::env::temp_dir(); p.push(format!( "inbuxa-migrate-mocksync-{}-{:?}-{n}.sqlite", std::process::id(), std::thread::current().id(), )); let _ = std::fs::remove_file(&p); p } fn session_body(base: &str) -> String { json!({ "apiUrl": format!("{base}/jmap/api"), "uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"), "downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"), "capabilities": { "urn:ietf:params:jmap:core": { "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, "maxConcurrentRequests": 4, "maxConcurrentUpload": 4, "maxSizeRequest": 10000000, "maxSizeUpload": 50000000 } }, "accounts": { "w": { "name": "alice", "accountCapabilities": { "urn:ietf:params:jmap:mail": {} } } } }) .to_string() } #[test] fn export_email_already_exists_is_matched_not_failed() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); let blob = db::blobs::intern_blob( &conn, b"From: a@x\r\nSubject: hi\r\nMessage-ID: \r\n\r\nbody", ) .unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')", rusqlite::params![blob], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _mq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["t1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"t1","name":"Inbox","role":"inbox","parentId":null, "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _up = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPLOADED"}).to_string()) .expect(1) .create(); let _imp = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "notCreated":{"e1":{"type":"alreadyExists","existingId":"x9"}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( CommonConfig { archive: archive.clone(), threads: 1, dry_run: false, max_retries: 1, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, ExportConfig { connect: ConnectConfig { url: base.clone(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: None, prune: false, yes: true, }, ) .expect("export run"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.created, 0); assert_eq!(email.failed, 0, "alreadyExists must not be a failure"); assert_eq!(email.skipped, 1, "alreadyExists folds into matched"); assert!(!summary.any_failed()); let _ = std::fs::remove_file(&archive); } #[test] fn export_mailbox_name_collision_merges_without_create() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Junk Email',NULL,'junk'), (2,'Junk Mail',NULL,NULL)", [], ) .unwrap(); let blob = db::blobs::intern_blob( &conn, b"From: a@x\r\nSubject: spam\r\nMessage-ID: \r\n\r\nbody", ) .unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[2]','[\"$seen\"]')", rusqlite::params![blob], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _mq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["c"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"c","name":"Junk Mail","role":"junk","parentId":null, "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let no_set = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/set".into())) .expect(0) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _up = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPLOADED"}).to_string()) .expect(1) .create(); let import_into_c = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/import".into()), Matcher::Regex(r#""c":true"#.into()), ])) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e1":{"id":"E1","blobId":"b","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( CommonConfig { archive: archive.clone(), threads: 1, dry_run: false, max_retries: 1, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, ExportConfig { connect: ConnectConfig { url: base.clone(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: None, prune: false, yes: true, }, ) .expect("export run"); let mailbox = summary .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!(mailbox.created, 0, "no mailbox is created"); assert_eq!(mailbox.failed, 0, "the name collision is not a failure"); assert_eq!( mailbox.skipped, 2, "role-matched Junk Email + merged Junk Mail" ); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.created, 1, "the Junk Mail email lands on the target"); assert_eq!(email.failed, 0); assert!(!summary.any_failed()); no_set.assert(); import_into_c.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_mailbox_already_exists_maps_existing_id() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Junk Mail',NULL,NULL)", [], ) .unwrap(); let blob = db::blobs::intern_blob( &conn, b"From: a@x\r\nSubject: spam\r\nMessage-ID: \r\n\r\nbody", ) .unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')", rusqlite::params![blob], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _mq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["t1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"t1","name":"Inbox","role":"inbox","parentId":null, "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let set_collides = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/set".into())) .with_body( json!({"methodResponses":[["Mailbox/set",{"accountId":"w", "notCreated":{"c1":{"type":"alreadyExists","existingId":"c"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _up = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPLOADED"}).to_string()) .expect(1) .create(); let import_into_c = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/import".into()), Matcher::Regex(r#""c":true"#.into()), ])) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e1":{"id":"E1","blobId":"b","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( CommonConfig { archive: archive.clone(), threads: 1, dry_run: false, max_retries: 1, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, ExportConfig { connect: ConnectConfig { url: base.clone(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: None, prune: false, yes: true, }, ) .expect("export run"); let mailbox = summary .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!(mailbox.created, 0, "the create collided"); assert_eq!( mailbox.failed, 0, "alreadyExists on Mailbox/set is not a failure" ); assert_eq!(mailbox.skipped, 1, "existingId folds into matched"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.created, 1, "the email lands in the existing folder"); assert_eq!(email.failed, 0); assert!(!summary.any_failed()); set_collides.assert(); import_into_c.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn email_export_sends_one_email_per_import_call() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); for n in 1..=2 { let raw = format!("From: a@x\r\nSubject: m{n}\r\nMessage-ID: \r\n\r\nbody {n}",); let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')", rusqlite::params![blob], ) .unwrap(); } } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["t1"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _mq_empty = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/query".into()), Matcher::Regex("anchor".into()), ])) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"t1","name":"Inbox","role":"inbox","parentId":null, "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _ups = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UP1"}).to_string()) .expect(2) .create(); let single_only = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/import".into()), Matcher::Regex("e1".into()), Matcher::Regex("e2".into()), ])) .expect(0) .create(); let imports = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body( json!({"methodResponses":[["Email/import", {"accountId":"w","created":{"e":{"id":"x","blobId":"b","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(2) .create(); let summary = sync::export::run( CommonConfig { archive: archive.clone(), threads: 1, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, ExportConfig { connect: ConnectConfig { url: base.clone(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: None, prune: false, yes: true, }, ) .expect("export run"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.created, 2, "both emails imported in per-item rounds"); assert_eq!(email.failed, 0, "no per-unit failure"); assert!(!summary.any_failed(), "no whole-run failure"); single_only.assert(); imports.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_email_blob_not_found_reuploads_and_retries() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); let raw = b"From: a@x\r\nSubject: stale\r\nMessage-ID: \r\n\r\nbody"; let blob = db::blobs::intern_blob(&conn, raw).unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')", rusqlite::params![blob], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _mq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["t1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"t1","name":"Inbox","role":"inbox","parentId":null, "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); let up1 = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UP1"}).to_string()) .expect(1) .create(); let up2 = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UP2"}).to_string()) .expect(1) .create(); let imp_stale = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/import".into()), Matcher::Regex("e1".into()), Matcher::Regex("UP1".into()), ])) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "notCreated":{"e1":{"type":"blobNotFound"}}},"i"]]}) .to_string(), ) .expect(1) .create(); let imp_fresh = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/import".into()), Matcher::Regex("e1".into()), Matcher::Regex("UP2".into()), ])) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e1":{"id":"x1","blobId":"UP2","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( CommonConfig { archive: archive.clone(), threads: 1, dry_run: false, max_retries: 1, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, ExportConfig { connect: ConnectConfig { url: base.clone(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: None, prune: false, yes: true, }, ) .expect("export run"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!( email.created, 1, "the stale blob is re-uploaded and the email created" ); assert_eq!(email.failed, 0, "blobNotFound self-heals, not a failure"); assert_eq!(email.skipped, 0); assert!(!summary.any_failed()); up1.assert(); up2.assert(); imp_stale.assert(); imp_fresh.assert(); let _ = std::fs::remove_file(&archive); } fn session_body_full(base: &str) -> String { json!({ "apiUrl": format!("{base}/jmap/api"), "uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"), "downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"), "capabilities": { "urn:ietf:params:jmap:core": { "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, "maxConcurrentRequests": 4, "maxConcurrentUpload": 4, "maxSizeRequest": 10000000, "maxSizeUpload": 50000000 } }, "accounts": { "w": { "name": "alice", "accountCapabilities": { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:filenode": {} } } } }) .to_string() } fn import_cfg_objects(base: &str, objects: Vec) -> ImportConfig { ImportConfig { connect: ConnectConfig { url: base.to_owned(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: Some(objects), allow_source_change: false, } } fn export_cfg_objects(base: &str, objects: Vec) -> ExportConfig { ExportConfig { connect: ConnectConfig { url: base.to_owned(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: Some(objects), prune: false, yes: true, } } 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 common_dry(archive: &Path) -> CommonConfig { CommonConfig { dry_run: true, ..common(archive) } } fn anchor_terminator(server: &mut mockito::Server, api: &str, type_name: &str) -> mockito::Mock { server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex(format!("{type_name}/query")), Matcher::Regex("\"anchor\"".into()), ])) .with_body( json!({"methodResponses":[[format!("{type_name}/query"), {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_most(1024) .create() } #[test] fn import_removes_vanished_mailbox_from_archive_on_second_pass() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _q1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["A","B","C"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ {"id":"A","name":"alpha","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}, {"id":"B","name":"bravo","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true}, {"id":"C","name":"charlie","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let s1 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("first import"); let mb1 = s1 .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!(mb1.fetched, 3, "first pass fetched all three mailboxes"); { let conn = rusqlite::Connection::open(&archive).unwrap(); let n: i64 = conn .query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0)) .unwrap(); assert_eq!(n, 3); } let _q2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["A","C"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _ch2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/changes".into())) .with_body( json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s1", "newState":"s2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}) .to_string(), ) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("second import"); let mb2 = s2 .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!(mb2.fetched, 0, "second pass fetches nothing"); assert_eq!(mb2.updated, 0, "no changed mailboxes reported"); assert_eq!(mb2.deleted, 1, "vanished mailbox B is deleted"); { let conn = rusqlite::Connection::open(&archive).unwrap(); let names: Vec = conn .prepare("SELECT name FROM mailboxes ORDER BY name") .unwrap() .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(Result::unwrap) .collect(); assert_eq!(names, vec!["alpha".to_owned(), "charlie".to_owned()]); } let _ = std::fs::remove_file(&archive); } #[test] fn import_present_item_change_is_propagated_via_changes() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _q1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ {"id":"A","name":"OriginalName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("first import"); { let conn = rusqlite::Connection::open(&archive).unwrap(); let cursor: String = conn .query_row( "SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'", [], |r| r.get(0), ) .expect("first import records the state cursor"); assert_eq!(cursor, "s1"); } let _q2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); let changes = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/changes".into()), Matcher::Regex("\"sinceState\":\"s1\"".into()), ])) .with_body( json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"s1", "newState":"s2","hasMoreChanges":false,"created":[],"updated":["A"],"destroyed":[]},"c"]]}) .to_string(), ) .expect(1) .create(); let _g2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s2","list":[ {"id":"A","name":"UpdatedName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("second import"); changes.assert(); let mb2 = s2 .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!(mb2.fetched, 0, "no new objects on the second pass"); assert_eq!( mb2.updated, 1, "the changed mailbox is detected via /changes and refreshed in place" ); let conn = rusqlite::Connection::open(&archive).unwrap(); let name: String = conn .query_row("SELECT name FROM mailboxes WHERE id=1", [], |r| r.get(0)) .unwrap(); assert_eq!( name, "UpdatedName", "a server-side property change is propagated into the archive" ); let cursor: String = conn .query_row( "SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'", [], |r| r.get(0), ) .unwrap(); assert_eq!(cursor, "s2", "cursor advances to the changes newState"); let _ = std::fs::remove_file(&archive); } #[test] fn import_removes_vanished_email_and_drops_cross_ref() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mbq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mbg1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[ {"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let _eq1 = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":["E1","E2"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg1 = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[ {"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}}, {"id":"E2","blobId":"BLB2","receivedAt":"2020-01-02T00:00:00Z","mailboxIds":{"MX":true},"keywords":{}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let _dl1 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into())) .with_body("From: a@x\r\nMessage-ID: <1@h>\r\n\r\nbody-one") .expect(1) .create(); let _dl2 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB2/.*".into())) .with_body("From: b@x\r\nMessage-ID: <2@h>\r\n\r\nbody-two") .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("first import"); { let conn = rusqlite::Connection::open(&archive).unwrap(); assert_eq!( conn.query_row::("SELECT count(*) FROM emails", [], |r| r.get(0)) .unwrap(), 2 ); } let _mbq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eq2 = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":["E2"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mbch2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/changes".into())) .with_body( json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"sm1", "newState":"sm2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}) .to_string(), ) .expect(1) .create(); let _ech2 = server .mock("POST", api) .match_body(Matcher::Regex("Email/changes".into())) .with_body( json!({"methodResponses":[["Email/changes",{"accountId":"w","oldState":"se1", "newState":"se2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}) .to_string(), ) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("second import"); let em2 = s2 .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(em2.deleted, 1, "vanished email is deleted from archive"); let conn = rusqlite::Connection::open(&archive).unwrap(); let remaining: i64 = conn .query_row("SELECT count(*) FROM emails", [], |r| r.get(0)) .unwrap(); assert_eq!(remaining, 1, "only the still-present email remains"); let blobs: i64 = conn .query_row("SELECT count(*) FROM blobs", [], |r| r.get(0)) .unwrap(); assert_eq!(blobs, 1, "blob GC reclaims orphan blob of deleted email"); let _ = std::fs::remove_file(&archive); } #[test] fn import_unusable_received_at_falls_back_instead_of_dropping_the_email() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mbq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .create(); let _mbg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[ {"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":["E1","E2","E3"]},"q"]]}) .to_string(), ) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[ {"id":"E1","blobId":"BLB1","receivedAt":"30828-09-14T02:48:05Z","mailboxIds":{"MX":true},"keywords":{}}, {"id":"E2","blobId":"BLB2","mailboxIds":{"MX":true},"keywords":{}}, {"id":"E3","blobId":"BLB3","receivedAt":"2020-01-03 04:05:06Z","mailboxIds":{"MX":true},"keywords":{}} ],"notFound":[]},"g"]]}) .to_string(), ) .create(); let _dl1 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into())) .with_body( "From: a@x\r\nMessage-ID: <1@h>\r\nDate: Mon, 12 May 2025 10:00:00 +0200\r\n\r\none", ) .create(); let _dl2 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB2/.*".into())) .with_body("From: b@x\r\nMessage-ID: <2@h>\r\n\r\ntwo") .create(); let _dl3 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB3/.*".into())) .with_body("From: c@x\r\nMessage-ID: <3@h>\r\n\r\nthree") .create(); let summary = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("import does not abort on an unusable receivedAt"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.fetched, 3, "no email is dropped over its receivedAt"); assert_eq!(email.failed, 0, "an unusable receivedAt is not a failure"); assert!(!summary.any_failed()); let conn = rusqlite::Connection::open(&archive).unwrap(); let mut stmt = conn .prepare("SELECT received_at FROM emails ORDER BY id") .unwrap(); let dates: Vec = stmt .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(Result::unwrap) .collect(); drop(stmt); assert_eq!( dates[0], "2025-05-12T10:00:00+02:00", "an out-of-range receivedAt falls back to the Date header" ); assert_eq!( dates[1], "1970-01-01T00:00:00Z", "no receivedAt and no Date header falls back to the epoch" ); assert_eq!( dates[2], "2020-01-03T04:05:06Z", "a space-separated receivedAt is salvaged, not discarded" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_file_node_without_node_type_is_inferred_not_skipped() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _fnterm = anchor_terminator(&mut server, api, "FileNode"); let _fq = server .mock("POST", api) .match_body(Matcher::Regex("FileNode/query".into())) .with_body( json!({"methodResponses":[["FileNode/query", {"accountId":"w","ids":["F1","F2"]},"q"]]}) .to_string(), ) .create(); let _fg = server .mock("POST", api) .match_body(Matcher::Regex("FileNode/get".into())) .with_body( json!({"methodResponses":[["FileNode/get",{"accountId":"w","state":"sf1","list":[ {"id":"F1","parentId":null,"name":"docs","created":"2026-01-01T00:00:00Z"}, {"id":"F2","parentId":"F1","name":"a.txt","blobId":"BLBF","type":"text/plain","created":"2026-01-02T00:00:00Z"} ],"notFound":[]},"g"]]}) .to_string(), ) .create(); let _dl = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLBF/.*".into())) .with_body("file bytes") .create(); let summary = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::FileNode]), ) .expect("import does not abort when nodeType is absent"); let nodes = summary .per_type .iter() .find(|(t, _)| *t == "FileNode") .map(|(_, c)| c.clone()) .expect("file node counts"); assert_eq!( nodes.fetched, 2, "a server that omits nodeType still imports" ); assert_eq!(nodes.failed, 0); let conn = rusqlite::Connection::open(&archive).unwrap(); let mut stmt = conn .prepare("SELECT name, node_type FROM file_nodes ORDER BY name") .unwrap(); let rows: Vec<(String, String)> = stmt .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) .unwrap() .map(Result::unwrap) .collect(); drop(stmt); assert_eq!( rows, vec![ ("a.txt".to_owned(), "file".to_owned()), ("docs".to_owned(), "directory".to_owned()), ], "nodeType is inferred from the presence of a blobId" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_missing_email_blob_is_skipped_and_counted_once() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mbq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .create(); let _mbg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[ {"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":["E1","E2"]},"q"]]}) .to_string(), ) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[ {"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}}, {"id":"E2","blobId":"BLB2","receivedAt":"2020-01-02T00:00:00Z","mailboxIds":{"MX":true},"keywords":{}} ],"notFound":[]},"g"]]}) .to_string(), ) .create(); let _dl1 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into())) .with_body("From: a@x\r\nMessage-ID: <1@h>\r\n\r\nbody-one") .create(); let _dl2 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB2/.*".into())) .with_status(404) .with_body(json!({"status":404,"title":"Not Found"}).to_string()) .create(); let summary = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("import does not abort on a missing blob"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!( email.fetched, 1, "only the email with a present blob imports" ); assert_eq!( email.failed, 1, "a missing blob counts the email failed exactly once, not twice" ); assert!(summary.any_failed()); let conn = rusqlite::Connection::open(&archive).unwrap(); assert_eq!( conn.query_row::("SELECT count(*) FROM emails", [], |r| r.get(0)) .unwrap(), 1, "the skipped email leaves no row" ); assert_eq!( conn.query_row::( "SELECT count(*) FROM sync_id_jmap WHERE type_name='Email'", [], |r| r.get(0) ) .unwrap(), 1, "no id mapping is recorded for the skipped email, so a re-run retries it" ); let _ = std::fs::remove_file(&archive); } #[test] fn export_missing_target_email_is_created_on_rerun() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); for n in 1..=2 { let raw = format!("From: a@x\r\nSubject: m{n}\r\nMessage-ID: \r\n\r\nbody {n}",); let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap(); let mm = inbuxa_migrate::sync::keys::index_to_json( &inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw.as_bytes()), ); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords,message_match) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]', ?2)", rusqlite::params![blob, mm], ) .unwrap(); } } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["T1"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"T1","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":["X1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","list":[ {"id":"X1","messageId":["m-1@h"]} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"BUP"}).to_string()) .expect(1) .create(); let create = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e2":{"id":"Y2","blobId":"BUP","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("export"); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.skipped, 1, "Message-ID match with X1 skips it"); assert_eq!(email.created, 1, "missing email is created"); assert_eq!(email.failed, 0); upload.assert(); create.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_email_blake3_fallback_matches_when_no_message_id() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let raw = "From: a@x\r\nSubject: hello\r\n\r\nno-msg-id-body"; { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); let blob = db::blobs::intern_blob(&conn, raw.as_bytes()).unwrap(); let mm = inbuxa_migrate::sync::keys::index_to_json( &inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw.as_bytes()), ); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords,message_match) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]', ?2)", rusqlite::params![blob, mm], ) .unwrap(); } let local_idx = inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw.as_bytes()); assert!(local_idx.mids.is_empty(), "blob must lack Message-ID"); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["T1"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"T1","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":["X1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg_min = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/get".into()), Matcher::Regex("messageId".into()), ])) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","list":[ {"id":"X1","messageId":[]} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let _eg_full = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/get".into()), Matcher::Regex("from".into()), ])) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","list":[ {"id":"X1","messageId":[],"from":[{"email":"a@x"}],"subject":"hello","sentAt":"","to":[]} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let no_upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .expect(0) .create(); let no_import = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .expect(0) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("export"); no_upload.assert(); no_import.assert(); let email = summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(email.skipped, 1, "BLAKE3 fallback matched target"); assert_eq!(email.created, 0); assert_eq!(email.failed, 0); let _ = std::fs::remove_file(&archive); } #[test] fn export_leaves_an_existing_target_default_address_book_alone() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,description,is_default) VALUES (1,'Contacts',NULL,1)", [], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[ {"id":"P","name":"Personal","isDefault":true,"myRights":{"mayDelete":false}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let no_default_set = server .mock("POST", api) .match_body(Matcher::Regex("onSuccessSetIsDefault".into())) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w"},"d"]]}).to_string(), ) .expect(0) .create(); let create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("AddressBook/set".into()), Matcher::Regex("Contacts".into()), ])) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w", "created":{"c1":{"id":"CID"}}},"s"]]}) .to_string(), ) .expect(1) .create(); sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::AddressBook]), ) .expect("export"); create.assert(); no_default_set.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_claims_the_default_address_book_when_the_target_has_none() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,description,is_default) VALUES (1,'Contacts',NULL,1)", [], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[ {"id":"P","name":"Personal","isDefault":false,"myRights":{"mayDelete":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let default_set = server .mock("POST", api) .match_body(Matcher::Regex("onSuccessSetIsDefault".into())) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w"},"d"]]}).to_string(), ) .expect(1) .create(); let create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("AddressBook/set".into()), Matcher::Regex("Contacts".into()), ])) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w", "created":{"c1":{"id":"CID"}}},"s"]]}) .to_string(), ) .expect(1) .create(); sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::AddressBook]), ) .expect("export"); create.assert(); default_set.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn import_never_stores_two_default_address_books() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _abg = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","state":"sa1","list":[ {"id":"A1","name":"Contacts","isDefault":true,"isSubscribed":true,"sortOrder":0}, {"id":"A2","name":"GAL","isDefault":true,"isSubscribed":true,"sortOrder":0} ],"notFound":[]},"g"]]}) .to_string(), ) .create(); let summary = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::AddressBook]), ) .expect("import"); let books = summary .per_type .iter() .find(|(t, _)| *t == "AddressBook") .map(|(_, c)| c.clone()) .expect("address book counts"); assert_eq!(books.fetched, 2, "both address books are imported"); let conn = rusqlite::Connection::open(&archive).unwrap(); let defaults: i64 = conn .query_row( "SELECT count(*) FROM address_books WHERE is_default = 1", [], |r| r.get(0), ) .unwrap(); assert_eq!( defaults, 1, "an archive must never hold more than one default address book" ); let name: String = conn .query_row( "SELECT name FROM address_books WHERE is_default = 1", [], |r| r.get(0), ) .unwrap(); assert_eq!(name, "Contacts", "the first claimant keeps the default"); let _ = std::fs::remove_file(&archive); } #[test] fn export_address_book_creates_only_missing() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,description,is_default) VALUES (1,'Personal',NULL,1)", [], ) .unwrap(); conn.execute( "INSERT INTO address_books (id,name,description,is_default) VALUES (2,'Work',NULL,0)", [], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[ {"id":"P","name":"personal","isDefault":true,"myRights":{"mayDelete":false}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("AddressBook/set".into()), Matcher::Regex("Work".into()), ])) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w", "created":{"c2":{"id":"WID"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::AddressBook]), ) .expect("export"); create.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "AddressBook") .map(|(_, c)| c.clone()) .expect("address book counts"); assert_eq!(counts.skipped, 1, "Personal matches existing (case-fold)"); assert_eq!(counts.created, 1, "Work is created"); assert_eq!(counts.failed, 0); let _ = std::fs::remove_file(&archive); } #[test] fn export_calendar_creates_only_missing() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO calendars (id,name,is_default) VALUES (1,'Family',1)", [], ) .unwrap(); conn.execute( "INSERT INTO calendars (id,name,is_default) VALUES (2,'Team',0)", [], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("Calendar/get".into())) .with_body( json!({"methodResponses":[["Calendar/get",{"accountId":"w","list":[ {"id":"F","name":"family","isDefault":true,"myRights":{"mayDelete":false}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Calendar/set".into()), Matcher::Regex("Team".into()), ])) .with_body( json!({"methodResponses":[["Calendar/set",{"accountId":"w", "created":{"c2":{"id":"TID"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Calendar]), ) .expect("export"); create.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "Calendar") .map(|(_, c)| c.clone()) .expect("calendar counts"); assert_eq!(counts.skipped, 1, "Family matches existing (case-fold)"); assert_eq!(counts.created, 1, "Team is created"); assert_eq!(counts.failed, 0); let _ = std::fs::remove_file(&archive); } #[test] fn export_contact_and_event_blobs_are_inlined_never_blob_ids() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let photo: &[u8] = b"\x89PNG\r\n\x1a\nfake-photo-bytes"; let agenda: &[u8] = b"agenda for the review meeting"; { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)", [], ) .unwrap(); conn.execute( "INSERT INTO calendars (id,name,is_default) VALUES (1,'Work',1)", [], ) .unwrap(); let photo_blob = db::blobs::intern_blob(&conn, photo).unwrap(); let agenda_blob = db::blobs::intern_blob(&conn, agenda).unwrap(); let card = json!({ "@type": "Card", "version": "1.0", "name": { "full": "Photo Person" }, "media": { "photo": { "@type": "Media", "kind": "photo", "@blob": photo_blob, "mediaType": "image/png" } } }) .to_string(); conn.execute( "INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (1,'card-uid-1','[1]',?1)", [card.as_str()], ) .unwrap(); let event = json!({ "@type": "Event", "uid": "event-uid-1", "title": "Review", "start": "2026-03-01T09:00:00", "duration": "PT1H", "timeZone": "Etc/UTC", "links": { "1": { "@type": "Link", "rel": "enclosure", "@blob": agenda_blob, "contentType": "text/plain", "title": "agenda.txt" } } }) .to_string(); conn.execute( "INSERT INTO calendar_events (id,calendar_ids,is_draft,use_default_alerts,data) VALUES (1,'[1]',0,0,?1)", [event.as_str()], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let no_blob_id_in_card = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("ContactCard/set".into()), Matcher::Regex("blobId".into()), ])) .with_body( json!({"methodResponses":[["ContactCard/set",{"accountId":"w", "created":{"c1":{"id":"TCARD"}}},"s"]]}) .to_string(), ) .expect(0) .create(); let no_blob_id_in_event = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("CalendarEvent/set".into()), Matcher::Regex("blobId".into()), ])) .with_body( json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w", "created":{"c1":{"id":"TEVENT"}}},"s"]]}) .to_string(), ) .expect(0) .create(); let _ab_get = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[], "notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _ab_set = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/set".into())) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w", "created":{"c1":{"id":"TAB"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _cal_get = server .mock("POST", api) .match_body(Matcher::Regex("Calendar/get".into())) .with_body( json!({"methodResponses":[["Calendar/get",{"accountId":"w","list":[], "notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _cal_set = server .mock("POST", api) .match_body(Matcher::Regex("Calendar/set".into())) .with_body( json!({"methodResponses":[["Calendar/set",{"accountId":"w", "created":{"c1":{"id":"TCAL"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _cc_query = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/query".into())) .with_body( json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _ce_query = server .mock("POST", api) .match_body(Matcher::Regex("CalendarEvent/query".into())) .with_body( json!({"methodResponses":[["CalendarEvent/query",{"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let card_inlined = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("ContactCard/set".into()), Matcher::Regex("data:image/png;base64,".into()), ])) .with_body( json!({"methodResponses":[["ContactCard/set",{"accountId":"w", "created":{"c1":{"id":"TCARD"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let event_inlined = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("CalendarEvent/set".into()), Matcher::Regex("data:text/plain;base64,".into()), ])) .with_body( json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w", "created":{"c1":{"id":"TEVENT"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects( &base, vec![ ObjectType::AddressBook, ObjectType::Calendar, ObjectType::ContactCard, ObjectType::CalendarEvent, ], ), ) .expect("export"); no_blob_id_in_card.assert(); no_blob_id_in_event.assert(); card_inlined.assert(); event_inlined.assert(); for name in ["ContactCard", "CalendarEvent"] { let counts = summary .per_type .iter() .find(|(t, _)| *t == name) .map(|(_, c)| c.clone()) .unwrap_or_else(|| panic!("{name} counts")); assert_eq!(counts.created, 1, "{name} created: {counts:?}"); assert_eq!(counts.failed, 0, "{name} failed: {counts:?}"); } let _ = std::fs::remove_file(&archive); } fn session_body_small_request(base: &str, max_size_request: u64) -> String { json!({ "apiUrl": format!("{base}/jmap/api"), "uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"), "downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"), "capabilities": { "urn:ietf:params:jmap:core": { "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, "maxConcurrentRequests": 4, "maxConcurrentUpload": 4, "maxSizeRequest": max_size_request, "maxSizeUpload": 50000000 } }, "accounts": { "w": { "name": "alice", "accountCapabilities": { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:calendars": {} } } } }) .to_string() } #[test] fn export_oversized_contact_card_fails_alone_and_the_type_keeps_going() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)", [], ) .unwrap(); let photo = vec![b'P'; 8000]; let photo_blob = db::blobs::intern_blob(&conn, &photo).unwrap(); let huge = json!({ "@type": "Card", "name": { "full": "Photo Person" }, "media": { "photo": { "@type": "Media", "kind": "photo", "@blob": photo_blob, "mediaType": "image/png" } } }) .to_string(); conn.execute( "INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (1,'huge-card','[1]',?1)", [huge.as_str()], ) .unwrap(); let small = json!({ "@type": "Card", "name": { "full": "Small Person" } }).to_string(); conn.execute( "INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (2,'small-card','[1]',?1)", [small.as_str()], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_small_request(&base, 4000)) .expect_at_least(1) .create(); let _ccterm = anchor_terminator(&mut server, api, "ContactCard"); let _ab_get = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[], "notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _ab_set = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/set".into())) .with_body( json!({"methodResponses":[["AddressBook/set",{"accountId":"w", "created":{"c1":{"id":"TAB"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _cc_query = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/query".into())) .with_body( json!({"methodResponses":[["ContactCard/query",{"accountId":"w", "ids":["TSTALE"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _cc_get = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/get".into())) .with_body( json!({"methodResponses":[["ContactCard/get",{"accountId":"w","list":[ {"id":"TSTALE","uid":"stale-card"} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let destroyed = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("ContactCard/set".into()), Matcher::Regex("destroy".into()), ])) .with_body( json!({"methodResponses":[["ContactCard/set",{"accountId":"w", "destroyed":["TSTALE"]},"s"]]}) .to_string(), ) .expect(1) .create(); let created = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("ContactCard/set".into()), Matcher::Regex("Small Person".into()), ])) .with_body( json!({"methodResponses":[["ContactCard/set",{"accountId":"w", "created":{"c2":{"id":"TSMALL"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), ExportConfig { prune: true, ..export_cfg_objects( &base, vec![ObjectType::AddressBook, ObjectType::ContactCard], ) }, ) .expect("the oversized card must not abort the run"); created.assert(); destroyed.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "ContactCard") .map(|(_, c)| c.clone()) .expect("ContactCard counts"); assert_eq!(counts.created, 1, "{counts:?}"); assert_eq!(counts.failed, 1, "{counts:?}"); assert_eq!( counts.deleted, 1, "the prune plan stays complete: {counts:?}" ); let _ = std::fs::remove_file(&archive); } #[test] fn export_archive_read_failure_while_inlining_exits_seven() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)", [], ) .unwrap(); let photo_blob = db::blobs::intern_blob(&conn, b"photo-bytes").unwrap(); let card = json!({ "@type": "Card", "media": { "photo": { "@type": "Media", "kind": "photo", "@blob": photo_blob, "mediaType": "image/png" } } }) .to_string(); conn.execute( "INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (1,'card-uid-1','[1]',?1)", [card.as_str()], ) .unwrap(); conn.execute("ALTER TABLE blobs RENAME COLUMN data TO payload", []) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _cc_query = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/query".into())) .with_body( json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let no_create = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/set".into())) .with_body( json!({"methodResponses":[["ContactCard/set",{"accountId":"w", "created":{"c1":{"id":"TCARD"}}},"s"]]}) .to_string(), ) .expect(0) .create(); let err = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::ContactCard]), ) .expect_err("an unreadable blobs table must abort the run"); no_create.assert(); assert!(err.aborts_run(), "{err}"); assert_eq!(err.exit_code(), 7, "{err}"); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_script_matched_by_name_is_updated_when_its_content_differs() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let keepall_local = b"require [\"fileinto\"];\nkeep;\n"; let reject_local = b"require [\"reject\"];\nreject \"go away\";\n"; { let conn = db::init::open(&archive).unwrap(); let blob1 = db::blobs::intern_blob(&conn, keepall_local).unwrap(); let blob2 = db::blobs::intern_blob(&conn, reject_local).unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'keepall',1,?1)", rusqlite::params![blob1], ) .unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'reject',0,?1)", rusqlite::params![blob2], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body( json!({"methodResponses":[["SieveScript/get",{"accountId":"w","list":[ {"id":"S1","name":"keepall","isActive":false,"blobId":"BSRV"} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let download = server .mock("GET", Matcher::Regex("/jmap/dl/w/BSRV/.*".into())) .with_body(b"keep;\n".as_slice()) .expect(1) .create(); let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPN"}).to_string()) .expect(2) .create(); let update = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("\"update\":\\{\"S1\":\\{\"blobId\":\"UPN\"".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "updated":{"S1":null}},"s"]]}) .to_string(), ) .expect(1) .create(); let create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("reject".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "created":{"c2":{"id":"S2"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _activate = server .mock("POST", api) .match_body(Matcher::Regex("onSuccessActivateScript".into())) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); upload.assert(); create.assert(); download.assert(); update.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts"); assert_eq!( counts.updated, 1, "the name-matched script gets the archive's content" ); assert_eq!(counts.skipped, 0); assert_eq!(counts.created, 1, "the unmatched name is created"); assert_eq!(counts.failed, 0); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_script_matched_by_name_with_the_same_content_is_left_alone() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let keepall_local = b"require [\"fileinto\"];\nkeep;\n"; let reject_local = b"require [\"reject\"];\nreject \"go away\";\n"; { let conn = db::init::open(&archive).unwrap(); let blob1 = db::blobs::intern_blob(&conn, keepall_local).unwrap(); let blob2 = db::blobs::intern_blob(&conn, reject_local).unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'keepall',1,?1)", rusqlite::params![blob1], ) .unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'reject',0,?1)", rusqlite::params![blob2], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body( json!({"methodResponses":[["SieveScript/get",{"accountId":"w","list":[ {"id":"S1","name":"keepall","isActive":false,"blobId":"BSRV"} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let download = server .mock("GET", Matcher::Regex("/jmap/dl/w/BSRV/.*".into())) .with_body(keepall_local.as_slice()) .expect(1) .create(); let no_update = server .mock("POST", api) .match_body(Matcher::Regex("\"update\"".into())) .expect(0) .create(); let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPN"}).to_string()) .expect(1) .create(); let create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("reject".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "created":{"c2":{"id":"S2"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _activate = server .mock("POST", api) .match_body(Matcher::Regex("onSuccessActivateScript".into())) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); upload.assert(); create.assert(); download.assert(); no_update.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts"); assert_eq!( counts.skipped, 1, "same name and same content: nothing to do" ); assert_eq!(counts.updated, 0); assert_eq!(counts.created, 1, "the unmatched name is created"); assert_eq!(counts.failed, 0); let _ = std::fs::remove_file(&archive); } /// A session like `session_body_full`, whose Sieve capability lists /// `extensions` in `sieveExtensions`. fn session_body_sieve(base: &str, extensions: &[&str]) -> String { let mut v: serde_json::Value = serde_json::from_str(&session_body_full(base)).unwrap(); v["accounts"]["w"]["accountCapabilities"]["urn:ietf:params:jmap:sieve"] = json!({ "sieveExtensions": extensions }); v.to_string() } /// One active script `name` in a fresh archive, with `body` as its content. fn archive_with_active_sieve(name: &str, body: &[u8]) -> PathBuf { let archive = tmp(); let conn = db::init::open(&archive).unwrap(); let blob = db::blobs::intern_blob(&conn, body).unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,?1,1,?2)", rusqlite::params![name, blob], ) .unwrap(); archive } /// Mocks for exporting one script to an empty target: get, upload (matched /// by `upload_body`), create, and activation answered with `activation`. /// Returns the upload mock and the activation mock. fn mock_sieve_export( server: &mut mockito::ServerGuard, session: String, upload_body: Matcher, create_ok: bool, activation: serde_json::Value, ) -> (mockito::Mock, mockito::Mock, Vec) { let api = "/jmap/api"; let mut keep = vec![ server.mock("GET", "/").with_status(404).create(), server .mock("GET", "/.well-known/jmap") .with_body(session) .expect_at_least(1) .create(), server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body( json!({"methodResponses":[["SieveScript/get", {"accountId":"w","list":[],"notFound":[]},"g"]]}) .to_string(), ) .create(), ]; let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .match_body(upload_body) .with_body(json!({"blobId":"UPN"}).to_string()) .expect(1) .create(); let created = if create_ok { json!({"accountId":"w","created":{"c1":{"id":"S1"}}}) } else { json!({"accountId":"w","notCreated":{"c1":{"type":"invalidScript", "description":"unknown extension"}}}) }; keep.push( server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("\"create\"".into()), ])) .with_body(json!({"methodResponses":[["SieveScript/set", created, "s"]]}).to_string()) .create(), ); let activate = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("onSuccess".into()), ])) .with_body(json!({"methodResponses":[activation]}).to_string()) .expect(1) .create(); (upload, activate, keep) } fn sieve_counts(summary: &sync::Summary) -> sync::TypeCounts { summary .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts") } #[test] fn export_sieve_renames_stalwart_names_for_an_inbuxa_target() { let mut server = mockito::Server::new(); let base = server.url(); let archive = archive_with_active_sieve( "loop", b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n", ); let (upload, activate, _keep) = mock_sieve_export( &mut server, session_body_sieve( &base, &["fileinto", "vnd.inbuxa.while", "vnd.inbuxa.expressions"], ), Matcher::Exact("require [\"fileinto\", \"vnd.inbuxa.while\"];\nkeep;\n".into()), true, json!(["SieveScript/set", {"accountId":"w"}, "a"]), ); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); upload.assert(); activate.assert(); let c = sieve_counts(&summary); assert_eq!((c.created, c.failed), (1, 0)); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_keeps_stalwart_names_for_a_target_without_inbuxa_names() { let mut server = mockito::Server::new(); let base = server.url(); let archive = archive_with_active_sieve( "loop", b"require [\"fileinto\", \"vnd.stalwart.while\"];\nkeep;\n", ); let (upload, _activate, _keep) = mock_sieve_export( &mut server, session_body_sieve(&base, &["fileinto", "vnd.stalwart.while"]), Matcher::Regex("vnd\\.stalwart\\.while".into()), true, json!(["SieveScript/set", {"accountId":"w"}, "a"]), ); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); upload.assert(); assert_eq!(sieve_counts(&summary).failed, 0); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_activation_error_is_a_failure() { let mut server = mockito::Server::new(); let base = server.url(); let archive = archive_with_active_sieve("main", b"require [\"fileinto\"];\nkeep;\n"); let (_upload, activate, _keep) = mock_sieve_export( &mut server, session_body_full(&base), Matcher::Any, true, json!(["error", {"type":"invalidArguments","description":"cannot activate"}, "a"]), ); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); activate.assert(); let c = sieve_counts(&summary); assert_eq!(c.created, 1); assert_eq!( c.failed, 1, "a failed activation is a failure, not a warning" ); assert!(summary.any_failed(), "so export exits non-zero"); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_active_script_not_created_is_a_failure() { let mut server = mockito::Server::new(); let base = server.url(); let archive = archive_with_active_sieve("main", b"require [\"nope\"];\nkeep;\n"); let (_upload, _activate, _keep) = mock_sieve_export( &mut server, session_body_full(&base), Matcher::Any, false, json!(["SieveScript/set", {"accountId":"w"}, "a"]), ); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); let c = sieve_counts(&summary); assert_eq!((c.created, c.failed), (0, 1)); assert!(summary.any_failed()); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_scripts_identical_content_different_names_both_created() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let shared = b"require [\"fileinto\"];\nfileinto \"Archive\";\n"; { let conn = db::init::open(&archive).unwrap(); let blob = db::blobs::intern_blob(&conn, shared).unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'duplicate-A',0,?1)", rusqlite::params![blob], ) .unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'duplicate-B',0,?1)", rusqlite::params![blob], ) .unwrap(); let dup_count: i64 = conn .query_row( "SELECT count(DISTINCT blob_id) FROM sieve_scripts", [], |r| r.get(0), ) .unwrap(); assert_eq!(dup_count, 1, "both scripts share one blob (byte-identical)"); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body( json!({"methodResponses":[["SieveScript/get", {"accountId":"w","list":[],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPN"}).to_string()) .expect(1) .create(); let create_a = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("duplicate-A".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "created":{"c1":{"id":"S1"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let create_b = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("duplicate-B".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "created":{"c2":{"id":"S2"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _deactivate = server .mock("POST", api) .match_body(Matcher::Regex("onSuccessDeactivateScript".into())) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); upload.assert(); create_a.assert(); create_b.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts"); assert_eq!( counts.created, 2, "two scripts with identical content but distinct names must both reach the target" ); assert_eq!( counts.skipped, 0, "neither distinct name collapses onto the other" ); assert_eq!(counts.failed, 0); let _ = std::fs::remove_file(&archive); } #[test] fn export_sieve_blob_not_found_on_dedup_reuse_reuploads_and_retries() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let shared = b"require [\"fileinto\"];\nfileinto \"Archive\";\n"; { let conn = db::init::open(&archive).unwrap(); let blob = db::blobs::intern_blob(&conn, shared).unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (1,'dup-A',0,?1)", rusqlite::params![blob], ) .unwrap(); conn.execute( "INSERT INTO sieve_scripts (id,name,is_active,blob_id) VALUES (2,'dup-B',0,?1)", rusqlite::params![blob], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body( json!({"methodResponses":[["SieveScript/get", {"accountId":"w","list":[],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let up1 = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UP1"}).to_string()) .expect(1) .create(); let up2 = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UP2"}).to_string()) .expect(1) .create(); let create_a = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("dup-A".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "created":{"c1":{"id":"S1"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let create_b_stale = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("dup-B".into()), Matcher::Regex("UP1".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "notCreated":{"c2":{"type":"blobNotFound"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let create_b_fresh = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("SieveScript/set".into()), Matcher::Regex("dup-B".into()), Matcher::Regex("UP2".into()), ])) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w", "created":{"c2":{"id":"S2"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let _deactivate = server .mock("POST", api) .match_body(Matcher::Regex("onSuccessDeactivateScript".into())) .with_body( json!({"methodResponses":[["SieveScript/set",{"accountId":"w"},"a"]]}).to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("export"); up1.assert(); up2.assert(); create_a.assert(); create_b_stale.assert(); create_b_fresh.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts"); assert_eq!( counts.created, 2, "the dedup-reused blobId that came back blobNotFound self-heals via re-upload" ); assert_eq!( counts.failed, 0, "blobNotFound on a reused blob is not a failure" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_deeply_nested_mailbox_tree_orders_correctly() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); const DEPTH: usize = 10; let ids: Vec = (0..DEPTH).map(|i| format!("L{i}")).collect(); let mut servlist = Vec::new(); for (i, id) in ids.iter().enumerate() { let parent = if i == 0 { Value::Null } else { Value::String(ids[i - 1].clone()) }; servlist.push(json!({ "id": id, "name": format!("level{i}"), "parentId": parent, "role": null, "sortOrder": 0, "isSubscribed": true })); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let server_ids: Vec = ids.iter().rev().map(|s| Value::String(s.clone())).collect(); let _q = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids": server_ids},"q"]]}) .to_string(), ) .expect(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get", {"accountId":"w","list": servlist,"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("import"); assert!(!summary.any_failed(), "import had failures: {summary:?}"); let conn = rusqlite::Connection::open(&archive).unwrap(); let n: i64 = conn .query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0)) .unwrap(); assert_eq!(n as usize, DEPTH); for i in 1..DEPTH { let pname: String = conn .query_row( "SELECT p.name FROM mailboxes c JOIN mailboxes p ON c.parent_id = p.id WHERE c.name = ?1", rusqlite::params![format!("level{i}")], |r| r.get(0), ) .unwrap_or_else(|e| panic!("level{i} parent lookup failed: {e}")); assert_eq!(pname, format!("level{}", i - 1)); } let root: Option = conn .query_row( "SELECT parent_id FROM mailboxes WHERE name='level0'", [], |r| r.get(0), ) .unwrap(); assert_eq!(root, None, "root has no parent"); let _ = std::fs::remove_file(&archive); } #[test] fn export_dry_run_sends_no_mutating_calls() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (2,'Sent',NULL,NULL)", [], ) .unwrap(); let blob = db::blobs::intern_blob( &conn, b"From: a@x\r\nSubject: hi\r\nMessage-ID: \r\n\r\nbody", ) .unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[]')", rusqlite::params![blob], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let no_set = server .mock("POST", api) .match_body(Matcher::Regex(r"/(set|import)".into())) .expect(0) .create(); let no_upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .expect(0) .create(); let summary = sync::export::run( common_dry(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("dry-run export must succeed"); assert!( !summary.any_failed(), "dry-run summary should not record failures: {summary:?}" ); no_set.assert(); no_upload.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn import_dry_run_does_not_write_archive_or_download_blobs() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let _ = db::init::open(&archive).unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body(&base)) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["s1"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let no_set = server .mock("POST", api) .match_body(Matcher::Regex(r"/(set|import)".into())) .expect(0) .create(); let no_get = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .expect(0) .create(); let no_download = server .mock("GET", Matcher::Regex("/jmap/dl/".into())) .expect(0) .create(); let summary = sync::import_jmap::run( common_dry(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("dry-run import must succeed"); assert!( !summary.any_failed(), "dry-run import should not record failures: {summary:?}" ); no_set.assert(); no_get.assert(); no_download.assert(); let conn = rusqlite::Connection::open(&archive).unwrap(); let mailbox_rows: i64 = conn .query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0)) .unwrap(); assert_eq!(mailbox_rows, 0, "dry-run must not insert into the archive"); let source_rows: i64 = conn .query_row("SELECT count(*) FROM sources", [], |r| r.get(0)) .unwrap(); assert_eq!(source_rows, 0, "dry-run must not record the JMAP source"); let _ = std::fs::remove_file(&archive); } #[test] fn import_email_keyword_change_is_propagated_without_blob_refetch() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mbq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mbg1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[ {"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let _eq1 = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg1 = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body(json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[ {"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let _dl1 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into())) .with_body("From: a@x\r\nMessage-ID: <1@h>\r\n\r\nbody-one") .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("first import"); let _mbq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mbch2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/changes".into())) .with_body(json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"sm1","newState":"sm2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}).to_string()) .expect(1) .create(); let _eq2 = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let echanges = server .mock("POST", api) .match_body(Matcher::Regex("Email/changes".into())) .with_body(json!({"methodResponses":[["Email/changes",{"accountId":"w","oldState":"se1","newState":"se2","hasMoreChanges":false,"created":[],"updated":["E1"],"destroyed":[]},"c"]]}).to_string()) .expect(1) .create(); let _eg2 = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se2","list":[ {"id":"E1","mailboxIds":{"MX":true},"keywords":{"$seen":true,"$flagged":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let no_blob_refetch = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into())) .with_body("should-not-be-fetched") .expect(0) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("second import"); echanges.assert(); no_blob_refetch.assert(); let em2 = s2 .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!(em2.updated, 1, "the changed email is refreshed"); assert_eq!(em2.fetched, 0, "no new emails"); let conn = rusqlite::Connection::open(&archive).unwrap(); let kw: String = conn .query_row("SELECT keywords FROM emails LIMIT 1", [], |r| r.get(0)) .unwrap(); assert!( kw.contains("$seen") && kw.contains("$flagged"), "keyword change propagated into the archive: {kw}" ); let blobs: i64 = conn .query_row("SELECT count(*) FROM blobs", [], |r| r.get(0)) .unwrap(); assert_eq!( blobs, 1, "the immutable body blob is not re-downloaded on update" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_cannot_calculate_changes_falls_back_to_full_refresh() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _q1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ {"id":"A","name":"OriginalName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("first import"); let _q2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); let cannot = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/changes".into())) .with_body( json!({"methodResponses":[["error",{"type":"cannotCalculateChanges"},"c"]]}) .to_string(), ) .expect(1) .create(); let capture_state = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/get".into()), Matcher::Regex("\"ids\":\\[\\]".into()), ])) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let refresh_get = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/get".into()), Matcher::Regex("\"A\"".into()), ])) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[ {"id":"A","name":"RefreshedName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("second import"); cannot.assert(); capture_state.assert(); refresh_get.assert(); let mb2 = s2 .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!(mb2.updated, 1, "fallback refreshes the present object"); let conn = rusqlite::Connection::open(&archive).unwrap(); let name: String = conn .query_row("SELECT name FROM mailboxes WHERE id=1", [], |r| r.get(0)) .unwrap(); assert_eq!(name, "RefreshedName", "A-fallback propagated the change"); let cursor: String = conn .query_row( "SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'", [], |r| r.get(0), ) .unwrap(); assert_eq!( cursor, "s9", "fallback captured a fresh cursor for the next run" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_failed_update_holds_cursor_for_retry() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _emterm = anchor_terminator(&mut server, api, "Email"); let _mbq1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mbg1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"sm1","list":[ {"id":"MX","name":"Inbox","parentId":null,"role":"inbox","sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let _eq1 = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg1 = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body(json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se1","list":[ {"id":"E1","blobId":"BLB1","receivedAt":"2020-01-01T00:00:00Z","mailboxIds":{"MX":true},"keywords":{"$seen":true}} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let _dl1 = server .mock("GET", Matcher::Regex("/jmap/dl/w/BLB1/.*".into())) .with_body("From: a@x\r\nMessage-ID: <1@h>\r\n\r\nbody-one") .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("first import"); let _mbq2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["MX"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _mbch2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/changes".into())) .with_body(json!({"methodResponses":[["Mailbox/changes",{"accountId":"w","oldState":"sm1","newState":"sm2","hasMoreChanges":false,"created":[],"updated":[],"destroyed":[]},"c"]]}).to_string()) .expect(1) .create(); let _eq2 = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["E1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _ech2 = server .mock("POST", api) .match_body(Matcher::Regex("Email/changes".into())) .with_body(json!({"methodResponses":[["Email/changes",{"accountId":"w","oldState":"se1","newState":"se2","hasMoreChanges":false,"created":[],"updated":["E1"],"destroyed":[]},"c"]]}).to_string()) .expect(1) .create(); let bad_update = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","state":"se2","list":[ {"id":"E1","mailboxIds":{},"keywords":{"$seen":true,"$flagged":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("second import"); bad_update.assert(); let em2 = s2 .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts"); assert_eq!( em2.updated, 0, "the failed update is not counted as applied" ); assert!(em2.failed >= 1, "the unresolvable update is counted failed"); let conn = rusqlite::Connection::open(&archive).unwrap(); let cursor: String = conn .query_row( "SELECT state FROM sync_state_jmap WHERE type_name='Email'", [], |r| r.get(0), ) .unwrap(); assert_eq!( cursor, "se1", "cursor is held at the pre-change state so the failed update retries next run" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_unknown_method_changes_falls_back_to_full_refresh() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _q1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g1 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ {"id":"A","name":"OriginalName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("first import"); let _q2 = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); let unknown = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/changes".into())) .with_body(json!({"methodResponses":[["error",{"type":"unknownMethod"},"c"]]}).to_string()) .expect(1) .create(); let _capture_state = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/get".into()), Matcher::Regex("\"ids\":\\[\\]".into()), ])) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let refresh_get = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/get".into()), Matcher::Regex("\"A\"".into()), ])) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s9","list":[ {"id":"A","name":"RefreshedName","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("second import"); unknown.assert(); refresh_get.assert(); let mb2 = s2 .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!( mb2.updated, 1, "a server without Mailbox/changes degrades to a full refresh instead of aborting" ); let conn = rusqlite::Connection::open(&archive).unwrap(); let name: String = conn .query_row("SELECT name FROM mailboxes WHERE id=1", [], |r| r.get(0)) .unwrap(); assert_eq!(name, "RefreshedName"); let _ = std::fs::remove_file(&archive); } fn sieve_get_body(name: &str, blob: &str) -> String { json!({"methodResponses":[["SieveScript/get",{"accountId":"w","state":"x","list":[ {"id":"S1","name":name,"isActive":true,"blobId":blob} ],"notFound":[]},"g"]]}) .to_string() } #[test] fn import_sieve_script_reimport_unchanged_is_convergent() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "SieveScript"); let _dl = server .mock("GET", Matcher::Regex("/jmap/dl/w/B1/.*".into())) .with_body("keep;\n") .create(); let _q1 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/query".into())) .with_body( json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g1 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body(sieve_get_body("main", "B1")) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("first import"); let _q2 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/query".into())) .with_body( json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g2 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body(sieve_get_body("main", "B1")) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("second import"); let ss = s2 .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts"); assert_eq!(ss.created, 0, "no new scripts"); assert_eq!( ss.updated, 0, "an unchanged SieveScript must not be counted as updated on re-import (convergent)" ); let _ = std::fs::remove_file(&archive); } #[test] fn import_sieve_script_content_change_is_propagated() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "SieveScript"); let _dl1 = server .mock("GET", Matcher::Regex("/jmap/dl/w/B1/.*".into())) .with_body("keep;\n") .create(); let _dl2 = server .mock("GET", Matcher::Regex("/jmap/dl/w/B2/.*".into())) .with_body("discard;\n") .create(); let _q1 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/query".into())) .with_body( json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g1 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body(sieve_get_body("main", "B1")) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("first import"); let _q2 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/query".into())) .with_body( json!({"methodResponses":[["SieveScript/query",{"accountId":"w","ids":["S1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g2 = server .mock("POST", api) .match_body(Matcher::Regex("SieveScript/get".into())) .with_body(sieve_get_body("main", "B2")) .expect(1) .create(); let s2 = sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::SieveScript]), ) .expect("second import"); let ss = s2 .per_type .iter() .find(|(t, _)| *t == "SieveScript") .map(|(_, c)| c.clone()) .expect("sieve counts"); assert_eq!( ss.updated, 1, "the changed script content is re-fetched and updated" ); let conn = rusqlite::Connection::open(&archive).unwrap(); let body: Vec = conn .query_row( "SELECT b.data FROM blobs b JOIN sieve_scripts s ON s.blob_id = b.id LIMIT 1", [], |r| r.get(0), ) .unwrap(); assert_eq!( String::from_utf8(body).unwrap(), "discard;\n", "new script content propagated into the archive blob" ); let _ = std::fs::remove_file(&archive); } #[test] fn first_run_cursor_is_captured_up_front_not_from_the_fetch() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _q = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query",{"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect(1) .create(); // Up-front state snapshot (ids:[]) reports an EARLIER state than the new-fetch. let _state = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/get".into()), Matcher::Regex("\"ids\":\\[\\]".into()), ])) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"before","list":[],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); // The new-fetch reports a LATER state; if we (incorrectly) captured from here, the cursor // would be "after" and an object changed mid-run could be missed next run. let _g = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/get".into()), Matcher::Regex("\"A\"".into()), ])) .with_body(json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"after","list":[ {"id":"A","name":"Personal","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}).to_string()) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("import"); let cursor: String = rusqlite::Connection::open(&archive) .unwrap() .query_row( "SELECT state FROM sync_state_jmap WHERE type_name='Mailbox'", [], |r| r.get(0), ) .unwrap(); assert_eq!( cursor, "before", "cursor must be the pre-fetch snapshot (lower bound), not the post-fetch state" ); let _ = std::fs::remove_file(&archive); } #[test] fn export_duplicate_role_mailbox_created_as_plain_folder_keeping_subtree() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (2,'Sent',NULL,'sent')", [], ) .unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (3,'Éléments envoyés',NULL,'sent')", [], ) .unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (4,'Brouillons locaux',3,NULL)", [], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _mbterm = anchor_terminator(&mut server, api, "Mailbox"); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["TI","TS"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"TI","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}}, {"id":"TS","name":"Sent","role":"sent","parentId":null,"myRights":{"mayDelete":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let role_create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/set".into()), Matcher::Regex("envoy".into()), Matcher::Regex("\"role\"".into()), ])) .with_body( json!({"methodResponses":[["Mailbox/set",{"accountId":"w", "notCreated":{"c3":{"type":"invalidProperties","properties":["role"], "description":"A mailbox with role 'sent' already exists."}}},"s"]]}) .to_string(), ) .expect_at_most(1) .create(); let folder_create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/set".into()), Matcher::Regex("envoy".into()), ])) .with_body( json!({"methodResponses":[["Mailbox/set",{"accountId":"w", "created":{"c3":{"id":"M3"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let child_create = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Mailbox/set".into()), Matcher::Regex("Brouillons".into()), ])) .with_body( json!({"methodResponses":[["Mailbox/set",{"accountId":"w", "created":{"c4":{"id":"M4"}}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("export"); role_create.assert(); folder_create.assert(); child_create.assert(); let counts = summary .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("mailbox counts"); assert_eq!( counts.skipped, 2, "Inbox and Sent match existing role mailboxes" ); assert_eq!( counts.created, 2, "duplicate-role folder and its child are both created" ); assert_eq!(counts.failed, 0, "no cascade skip of the subtree"); let _ = std::fs::remove_file(&archive); } #[test] fn import_jmap_duplicate_role_is_deduplicated_to_single_mailbox() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _q = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["A","B","C"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _g = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ {"id":"A","name":"Sent","parentId":null,"role":"sent","sortOrder":0,"isSubscribed":true}, {"id":"B","name":"Éléments envoyés","parentId":null,"role":"sent","sortOrder":0,"isSubscribed":true}, {"id":"C","name":"Sent Items","parentId":null,"role":"sent","sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); sync::import_jmap::run( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox]), ) .expect("import"); let conn = rusqlite::Connection::open(&archive).unwrap(); let total: i64 = conn .query_row("SELECT count(*) FROM mailboxes", [], |r| r.get(0)) .unwrap(); assert_eq!(total, 3, "all three folders are imported"); let with_role: i64 = conn .query_row( "SELECT count(*) FROM mailboxes WHERE role = 'sent'", [], |r| r.get(0), ) .unwrap(); assert_eq!(with_role, 1, "exactly one mailbox keeps the sent role"); let null_roles: i64 = conn .query_row( "SELECT count(*) FROM mailboxes WHERE role IS NULL", [], |r| r.get(0), ) .unwrap(); assert_eq!(null_roles, 2, "the surplus duplicates become plain folders"); drop(conn); let _ = std::fs::remove_file(&archive); } #[test] fn export_aborts_with_exit_two_when_the_advertised_api_url_is_unreachable() { let mut session_host = mockito::Server::new(); let mut api_host = mockito::Server::new(); let session_base = session_host.url(); let api_base = api_host.url(); let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role,sort_order,is_subscribed) VALUES (1,'Inbox',NULL,'inbox',0,1)", [], ) .unwrap(); conn.execute( "INSERT INTO address_books (id,name,description,is_default) VALUES (1,'Personal',NULL,1)", [], ) .unwrap(); } let _root = session_host.mock("GET", "/").with_status(404).create(); let _wk = session_host .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&api_base)) .expect_at_least(1) .create(); let nginx = api_host .mock("POST", "/jmap/api") .with_status(404) .with_header("content-type", "text/html") .with_body("404 Not Foundnginx") .expect(1) .create(); let err = sync::export::run( CommonConfig { archive: archive.clone(), threads: 1, dry_run: false, max_retries: 0, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, export_cfg_objects( &session_base, vec![ObjectType::Mailbox, ObjectType::AddressBook], ), ) .expect_err("an unreachable apiUrl must abort the run"); assert!( matches!(err, inbuxa_migrate::error::Error::Connection(_)), "a 404 from the advertised apiUrl is a whole-run connection failure, got {err:?}" ); assert_eq!(err.exit_code(), 2, "must not report a partial failure"); nginx.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn aborting_import_still_reports_the_types_that_finished() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "Mailbox"); let _mq = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["A"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(); let _mg = server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ {"id":"A","name":"alpha","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _dead_endpoint = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_status(404) .with_header("content-type", "text/html") .with_body("404 Not Foundnginx") .expect_at_least(1) .create(); let outcome = sync::import_jmap::run_reporting( common(&archive), import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ); let err = outcome .error .as_ref() .expect("a 404 from the api endpoint aborts the run"); assert!( matches!(err, inbuxa_migrate::error::Error::Connection(_)), "the abort must keep its own exit code, got {err:?}" ); assert_eq!(err.exit_code(), 2); let mailbox = outcome .summary .per_type .iter() .find(|(t, _)| *t == "Mailbox") .map(|(_, c)| c.clone()) .expect("the type that finished before the abort must still be reported"); assert_eq!(mailbox.fetched, 1); assert!( !outcome.summary.per_type.iter().any(|(t, _)| *t == "Email"), "the type that aborted contributes no counts" ); let _ = std::fs::remove_file(&archive); } fn seed_single_email(archive: &Path) { let conn = db::init::open(archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", [], ) .unwrap(); let blob = db::blobs::intern_blob( &conn, b"From: a@x\r\nSubject: hi\r\nMessage-ID: \r\n\r\nbody", ) .unwrap(); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')", rusqlite::params![blob], ) .unwrap(); } fn mock_reconcile_for_single_email(server: &mut mockito::ServerGuard, base: &str, api: &str) { server.mock("GET", "/").with_status(404).create(); server .mock("GET", "/.well-known/jmap") .with_body(session_body(base)) .create(); server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["t1"]},"q"]]}) .to_string(), ) .expect(1) .create(); server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"t1","name":"Inbox","role":"inbox","parentId":null, "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query", {"accountId":"w","ids":[]},"q"]]}) .to_string(), ) .expect(1) .create(); } fn run_export(archive: &Path, base: &str, max_retries: u32) -> sync::Summary { sync::export::run( CommonConfig { archive: archive.to_path_buf(), threads: 1, dry_run: false, max_retries, allow_invalid_certs: false, logger: Logger::from_flags(true, 0), }, ExportConfig { connect: ConnectConfig { url: base.to_owned(), auth: Auth::Basic { user: "u".into(), password: "p".into(), }, account: AccountSelector::Id("w".into()), }, objects: None, prune: false, yes: true, }, ) .expect("export run") } fn email_counts(summary: &sync::Summary) -> sync::TypeCounts { summary .per_type .iter() .find(|(t, _)| *t == "Email") .map(|(_, c)| c.clone()) .expect("email counts") } fn method_error_body(error_type: &str) -> String { json!({"methodResponses":[["error", {"type": error_type, "description":"This server is temporarily unavailable. \ Attempting this same operation later may succeed."}, "i"]]}) .to_string() } #[test] fn export_email_server_unavailable_is_retried_not_dropped() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); seed_single_email(&archive); mock_reconcile_for_single_email(&mut server, &base, api); let up = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPLOADED"}).to_string()) .expect(1) .create(); let failing = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body(method_error_body("serverUnavailable")) .expect(2) .create(); let succeeding = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e1":{"id":"E1","blobId":"UPLOADED","threadId":"T1","size":48}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = run_export(&archive, &base, 3); let email = email_counts(&summary); failing.assert(); succeeding.assert(); up.assert(); assert_eq!( email.created, 1, "the message must survive a transient error" ); assert_eq!(email.failed, 0, "a retried message is not a failure"); assert!(!summary.any_failed()); let _ = std::fs::remove_file(&archive); } #[test] fn export_email_exhausted_method_retries_stay_per_message() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); seed_single_email(&archive); mock_reconcile_for_single_email(&mut server, &base, api); let _up = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPLOADED"}).to_string()) .expect(1) .create(); let failing = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body(method_error_body("serverUnavailable")) .expect(3) .create(); let summary = run_export(&archive, &base, 2); let email = email_counts(&summary); failing.assert(); assert_eq!(email.created, 0); assert_eq!( email.failed, 1, "exhausting the retries stays a per-message failure, not a run abort" ); assert!(summary.any_failed(), "the run must still exit 5"); let _ = std::fs::remove_file(&archive); } #[test] fn export_email_fatal_method_error_is_not_retried() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); seed_single_email(&archive); mock_reconcile_for_single_email(&mut server, &base, api); let _up = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"UPLOADED"}).to_string()) .expect(1) .create(); let failing = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body(method_error_body("invalidArguments")) .expect(1) .create(); let summary = run_export(&archive, &base, 5); let email = email_counts(&summary); failing.assert(); assert_eq!( email.failed, 1, "a permanent method error must not consume retries" ); let _ = std::fs::remove_file(&archive); } /// Archive with an Inbox (1) and an Archive folder (2), and a target whose /// Mailbox/get returns both as T1 and T2, so no folder is created. fn two_folder_archive_and_target( server: &mut mockito::Server, archive: &Path, ) -> Vec { let conn = db::init::open(archive).unwrap(); conn.execute( "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox'), (2,'Archive',NULL,'archive')", [], ) .unwrap(); let base = server.url(); let api = "/jmap/api"; vec![ server.mock("GET", "/").with_status(404).create(), server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(), anchor_terminator(server, api, "Mailbox"), anchor_terminator(server, api, "Email"), server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/query".into())) .with_body( json!({"methodResponses":[["Mailbox/query", {"accountId":"w","ids":["T1","T2"]},"q"]]}) .to_string(), ) .expect_at_least(1) .create(), server .mock("POST", api) .match_body(Matcher::Regex("Mailbox/get".into())) .with_body( json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ {"id":"T1","name":"Inbox","role":"inbox","parentId":null,"myRights":{"mayDelete":true}}, {"id":"T2","name":"Archive","role":"archive","parentId":null,"myRights":{"mayDelete":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(), ] } fn insert_email_copy(archive: &Path, raw: &[u8], mailbox: i64) { let conn = db::init::open(archive).unwrap(); let blob = db::blobs::intern_blob(&conn, raw).unwrap(); let mm = inbuxa_migrate::sync::keys::index_to_json( &inbuxa_migrate::sync::emailmeta::email_index_from_blob(raw), ); conn.execute( "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords,message_match) VALUES (?1,'2020-01-01T00:00:00Z',?2,'[]',?3)", rusqlite::params![blob, format!("[{mailbox}]"), mm], ) .unwrap(); } const ONE_MESSAGE: &[u8] = b"From: a@x\r\nSubject: both\r\nMessage-ID: \r\n\r\nbody"; #[test] fn export_message_in_two_folders_lands_once_in_both() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _setup = two_folder_archive_and_target(&mut server, &archive); insert_email_copy(&archive, ONE_MESSAGE, 1); insert_email_copy(&archive, ONE_MESSAGE, 2); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":[]},"q"]]}).to_string(), ) .expect(1) .create(); let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"BUP"}).to_string()) .expect(1) .create(); let import = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/import".into()), Matcher::Regex("\"T1\":true".into()), Matcher::Regex("\"T2\":true".into()), ])) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e1":{"id":"Y1","blobId":"BUP","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("export"); let email = email_counts(&summary); assert_eq!(email.created, 1, "one email, not one per folder"); assert_eq!(email.failed, 0); upload.assert(); import.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_resumed_adds_the_folders_a_matched_message_is_missing() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _setup = two_folder_archive_and_target(&mut server, &archive); insert_email_copy(&archive, ONE_MESSAGE, 1); insert_email_copy(&archive, ONE_MESSAGE, 2); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["X1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","list":[ {"id":"X1","messageId":["both@h"],"size":ONE_MESSAGE.len(),"mailboxIds":{"T1":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let no_upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .expect(0) .create(); let no_import = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .expect(0) .create(); let set = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/set".into()), Matcher::Regex("\"mailboxIds/T2\":true".into()), ])) .with_body( json!({"methodResponses":[["Email/set",{"accountId":"w","updated":{"X1":null}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("export"); let email = email_counts(&summary); assert_eq!(email.updated, 1, "the Archive membership is added"); assert_eq!(email.created, 0); assert_eq!(email.failed, 0); set.assert(); no_upload.assert(); no_import.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_different_messages_sharing_a_message_id_are_not_merged() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _setup = two_folder_archive_and_target(&mut server, &archive); let first: &[u8] = b"From: a@x\r\nSubject: one\r\nMessage-ID: \r\n\r\nfirst body"; let second: &[u8] = b"From: a@x\r\nSubject: one\r\nMessage-ID: \r\n\r\na different, longer second body"; insert_email_copy(&archive, first, 1); insert_email_copy(&archive, second, 1); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["X1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","list":[ {"id":"X1","messageId":["same@h"],"size":second.len(),"mailboxIds":{"T1":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let upload = server .mock("POST", Matcher::Regex("/jmap/upload/".into())) .with_body(json!({"blobId":"BUP"}).to_string()) .expect(1) .create(); let import = server .mock("POST", api) .match_body(Matcher::Regex("Email/import".into())) .with_body( json!({"methodResponses":[["Email/import",{"accountId":"w", "created":{"e1":{"id":"Y1","blobId":"BUP","threadId":"t","size":10}}},"i"]]}) .to_string(), ) .expect(1) .create(); let no_set = server .mock("POST", api) .match_body(Matcher::Regex("Email/set".into())) .expect(0) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("export"); let email = email_counts(&summary); assert_eq!( email.created, 1, "the first message is not on the target yet" ); assert_eq!(email.skipped, 1, "the second is, matched by size"); assert_eq!(email.failed, 0); upload.assert(); import.assert(); no_set.assert(); let _ = std::fs::remove_file(&archive); } #[test] fn export_rerun_carries_a_read_flag_set_at_the_source() { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); let _setup = two_folder_archive_and_target(&mut server, &archive); insert_email_copy(&archive, ONE_MESSAGE, 1); { let conn = db::init::open(&archive).unwrap(); conn.execute("UPDATE emails SET keywords='[\"$seen\"]'", []) .unwrap(); } let _eq = server .mock("POST", api) .match_body(Matcher::Regex("Email/query".into())) .with_body( json!({"methodResponses":[["Email/query",{"accountId":"w","ids":["X1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("Email/get".into())) .with_body( json!({"methodResponses":[["Email/get",{"accountId":"w","list":[ {"id":"X1","messageId":["both@h"],"size":ONE_MESSAGE.len(), "mailboxIds":{"T1":true},"keywords":{"$flagged":true}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let set = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("Email/set".into()), Matcher::Regex("\"keywords/\\$seen\":true".into()), Matcher::Regex("\"keywords/\\$flagged\":null".into()), ])) .with_body( json!({"methodResponses":[["Email/set",{"accountId":"w","updated":{"X1":null}},"s"]]}) .to_string(), ) .expect(1) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), ) .expect("export"); let email = email_counts(&summary); assert_eq!(email.updated, 1); assert_eq!(email.created, 0); assert_eq!(email.failed, 0); set.assert(); let _ = std::fs::remove_file(&archive); } fn contact_rerun(local_updated: &str, updates_sent: usize) -> inbuxa_migrate::sync::TypeCounts { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO address_books (id,name,description,is_default) VALUES (1,'Personal',NULL,1)", [], ) .unwrap(); let card = json!({"@type":"Card","version":"1.0","uid":"u1", "name":{"full":"Ann Brown"},"updated":local_updated}); conn.execute( "INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (1,'u1','[1]',?1)", rusqlite::params![card.to_string()], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _abg = server .mock("POST", api) .match_body(Matcher::Regex("AddressBook/get".into())) .with_body( json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[ {"id":"P","name":"Personal","isDefault":true,"myRights":{"mayDelete":false}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "ContactCard"); let _cq = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/query".into())) .with_body( json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":["C1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _cg = server .mock("POST", api) .match_body(Matcher::Regex("ContactCard/get".into())) .with_body( json!({"methodResponses":[["ContactCard/get",{"accountId":"w","list":[ {"id":"C1","@type":"Card","version":"1.0","uid":"u1", "name":{"full":"Ann"},"addressBookIds":{"P":true}, "updated":"2026-02-01T00:00:00Z"} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let update = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("ContactCard/set".into()), Matcher::Regex("\"update\"".into()), Matcher::Regex("Ann Brown".into()), ])) .with_body( json!({"methodResponses":[["ContactCard/set",{"accountId":"w","updated":{"C1":null}},"s"]]}) .to_string(), ) .expect(updates_sent) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects( &base, vec![ObjectType::AddressBook, ObjectType::ContactCard], ), ) .expect("export"); let counts = summary .per_type .iter() .find(|(t, _)| *t == "ContactCard") .map(|(_, c)| c.clone()) .expect("contact counts"); update.assert(); let _ = std::fs::remove_file(&archive); counts } #[test] fn export_rerun_updates_a_contact_edited_at_the_source() { let counts = contact_rerun("2026-03-01T00:00:00Z", 1); assert_eq!(counts.updated, 1, "the newer archive copy is written"); assert_eq!(counts.failed, 0); } #[test] fn export_rerun_leaves_a_contact_alone_when_the_target_is_newer() { let counts = contact_rerun("2026-01-01T00:00:00Z", 0); assert_eq!( counts.updated, 0, "the target's copy is newer, so nothing is sent" ); assert_eq!(counts.skipped, 1); } fn event_rerun(local_updated: &str, updates_sent: usize) -> inbuxa_migrate::sync::TypeCounts { let mut server = mockito::Server::new(); let base = server.url(); let api = "/jmap/api"; let archive = tmp(); { let conn = db::init::open(&archive).unwrap(); conn.execute( "INSERT INTO calendars (id,name,is_default) VALUES (1,'Work',1)", [], ) .unwrap(); let event = json!({"@type":"Event","uid":"ev1","title":"Planning, moved", "start":"2026-03-02T10:00:00","duration":"PT1H", "updated":local_updated}); conn.execute( "INSERT INTO calendar_events (id,calendar_ids,is_draft,use_default_alerts,data) VALUES (1,'[1]',0,0,?1)", rusqlite::params![event.to_string()], ) .unwrap(); } let _root = server.mock("GET", "/").with_status(404).create(); let _wk = server .mock("GET", "/.well-known/jmap") .with_body(session_body_full(&base)) .expect_at_least(1) .create(); let _calg = server .mock("POST", api) .match_body(Matcher::Regex("Calendar/get".into())) .with_body( json!({"methodResponses":[["Calendar/get",{"accountId":"w","list":[ {"id":"K","name":"Work","isDefault":true,"myRights":{"mayDelete":false}} ],"notFound":[]},"g"]]}) .to_string(), ) .expect_at_least(1) .create(); let _term = anchor_terminator(&mut server, api, "CalendarEvent"); let _eq = server .mock("POST", api) .match_body(Matcher::Regex("CalendarEvent/query".into())) .with_body( json!({"methodResponses":[["CalendarEvent/query",{"accountId":"w","ids":["E1"]},"q"]]}) .to_string(), ) .expect(1) .create(); let _eg = server .mock("POST", api) .match_body(Matcher::Regex("CalendarEvent/get".into())) .with_body( json!({"methodResponses":[["CalendarEvent/get",{"accountId":"w","list":[ {"id":"E1","@type":"Event","uid":"ev1","title":"Planning", "start":"2026-03-01T10:00:00","duration":"PT1H", "calendarIds":{"K":true},"isDraft":false,"useDefaultAlerts":false, "updated":"2026-02-01T00:00:00Z"} ],"notFound":[]},"g"]]}) .to_string(), ) .expect(1) .create(); let update = server .mock("POST", api) .match_body(Matcher::AllOf(vec![ Matcher::Regex("CalendarEvent/set".into()), Matcher::Regex("\"update\"".into()), Matcher::Regex("Planning, moved".into()), ])) .with_body( json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w","updated":{"E1":null}},"s"]]}) .to_string(), ) .expect(updates_sent) .create(); let summary = sync::export::run( common(&archive), export_cfg_objects(&base, vec![ObjectType::Calendar, ObjectType::CalendarEvent]), ) .expect("export"); let counts = summary .per_type .iter() .find(|(t, _)| *t == "CalendarEvent") .map(|(_, c)| c.clone()) .expect("event counts"); update.assert(); let _ = std::fs::remove_file(&archive); counts } #[test] fn export_rerun_updates_an_event_moved_at_the_source() { let counts = event_rerun("2026-03-01T00:00:00Z", 1); assert_eq!(counts.updated, 1, "the newer archive copy is written"); assert_eq!(counts.failed, 0); } #[test] fn export_rerun_leaves_an_event_alone_when_nothing_is_newer() { let counts = event_rerun("2026-02-01T01:00:00+01:00", 0); assert_eq!( counts.updated, 0, "same instant as the target's, written another way" ); assert_eq!(counts.skipped, 1); }