inbuxa accepts vnd.inbuxa.while and vnd.inbuxa.expressions, and names its
environment items vnd.inbuxa.*, with no alias for Stalwart's vnd.stalwart.*
names. A script carried over unchanged failed to compile on the target, and
the account was left with no filtering behind a single warning.
When the target lists vnd.inbuxa extensions in its sieveExtensions, export
now renames Stalwart's names in the three places they are names: the strings
of a require list, the item name of an environment test, and
${env.vnd.stalwart.*} references inside strings. A small tokenizer skips
comments and reads quoted strings and text: blocks whole, so other text that
happens to contain the name is copied unchanged. Each rename is printed.
Against a target without the inbuxa names nothing changes.
The activation call is now checked. A method error, or an active script
that could not be created, is logged as an error naming the script and
counted as a failure, so export exits non-zero instead of leaving
filtering off with one warning line.
4661 lines
151 KiB
Rust
4661 lines
151 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
*/
|
|
|
|
use std::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: <m-1@h>\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: <m-2@h>\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: <m-3@h>\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: <m-{n}@h>\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: dup\r\nMessage-ID: <dup-1@h>\r\n\r\nbody";
|
|
let blob = db::blobs::intern_blob(&conn, raw).unwrap();
|
|
for _ in 0..2 {
|
|
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_e1 = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("e1".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e1":{"id":"x1","blobId":"UP1","threadId":"t","size":10}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let imp_e2_stale = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("e2".into()),
|
|
Matcher::Regex("UP1".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"notCreated":{"e2":{"type":"blobNotFound"}}},"i"]]})
|
|
.to_string(),
|
|
)
|
|
.expect(1)
|
|
.create();
|
|
let imp_e2_fresh = server
|
|
.mock("POST", api)
|
|
.match_body(Matcher::AllOf(vec![
|
|
Matcher::Regex("Email/import".into()),
|
|
Matcher::Regex("e2".into()),
|
|
Matcher::Regex("UP2".into()),
|
|
]))
|
|
.with_body(
|
|
json!({"methodResponses":[["Email/import",{"accountId":"w",
|
|
"created":{"e2":{"id":"x2","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, 2, "both emails end up 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_e1.assert();
|
|
imp_e2_stale.assert();
|
|
imp_e2_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<ObjectType>) -> 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<ObjectType>) -> 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<String> = 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::<i64, _, _>("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<String> = 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::<i64, _, _>("SELECT count(*) FROM emails", [], |r| r.get(0))
|
|
.unwrap(),
|
|
1,
|
|
"the skipped email leaves no row"
|
|
);
|
|
assert_eq!(
|
|
conn.query_row::<i64, _, _>(
|
|
"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: <m-{n}@h>\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_matches_by_name_not_content() {
|
|
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 no_download = server
|
|
.mock("GET", Matcher::Regex("/jmap/dl/w/BSRV/.*".into()))
|
|
.with_body(b"unused".as_slice())
|
|
.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();
|
|
no_download.assert();
|
|
let counts = summary
|
|
.per_type
|
|
.iter()
|
|
.find(|(t, _)| *t == "SieveScript")
|
|
.map(|(_, c)| c.clone())
|
|
.expect("sieve counts");
|
|
assert_eq!(
|
|
counts.skipped, 1,
|
|
"name-matched script is skipped even though its content differs from the target"
|
|
);
|
|
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<mockito::Mock>) {
|
|
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<String> = (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<Value> = 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<i64> = 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: <m-1@h>\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<u8> = 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("<html><head><title>404 Not Found</title></head><body>nginx</body></html>")
|
|
.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("<html><head><title>404 Not Found</title></head><body>nginx</body></html>")
|
|
.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: <m-9@h>\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);
|
|
}
|