This commit is contained in:
Maurus Decimus
2026-08-22 18:46:54 +02:00
parent 1cc633f39c
commit 423b761617
34 changed files with 1936 additions and 123 deletions
+15
View File
@@ -282,6 +282,21 @@ impl Cyrus {
})
}
pub fn create_non_ascii_mailbox(
&self,
account: &Account,
wire_name: &str,
message: &[u8],
) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.create(wire_name)?;
client.subscribe(wire_name)?;
client.append_with_flags(wire_name, &[], message)?;
client.logout()?;
Ok(())
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
+15
View File
@@ -337,6 +337,21 @@ impl Dovecot {
})
}
pub fn create_non_ascii_mailbox(
&self,
account: &Account,
wire_name: &str,
message: &[u8],
) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.create(wire_name)?;
client.subscribe(wire_name)?;
client.append_with_flags(wire_name, &[], message)?;
client.logout()?;
Ok(())
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
+47
View File
@@ -318,3 +318,50 @@ fn cyrus_starts_seeds_and_imports() {
}
fn _unused(_: &AccountSeed) {}
#[test]
#[ignore = "requires Docker"]
fn cyrus_non_ascii_mailbox_names_round_trip() {
let c = Cyrus::start().expect("cyrus start");
let account = c.accounts.first().expect("account").clone();
let mutf7_name = "Envoy&AOk-s";
let utf8_name = "Envoyés";
let message = b"From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Accents\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Accented folder probe.\r\n";
c.create_non_ascii_mailbox(&account, mutf7_name, message)
.expect("create accented mailbox");
let archive = tmp_archive("cyrus-non-ascii");
let summary =
import_imap::run(common(&archive), imap_config(&account, &c.imap)).expect("imap import");
assert!(
!summary.any_failed(),
"an accented folder name must not fail the import: {summary:?}"
);
let conn = open_archive(&archive);
let names: Vec<String> = {
let mut stmt = conn
.prepare("SELECT name FROM mailboxes ORDER BY name")
.unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(Result::unwrap)
.collect()
};
let mailbox_id = mailbox_id_by_name(&conn, utf8_name).unwrap_or_else(|| {
panic!("no mailbox named {utf8_name}; names must not be mojibake, got {names:?}")
});
let emails = emails_in_mailbox(&conn, mailbox_id);
assert_eq!(
emails, 1,
"the message inside the accented folder must be imported"
);
cleanup(&archive);
}
+47
View File
@@ -514,3 +514,50 @@ fn assert_received_at_rfc3339_ish(conn: &Connection) {
}
fn _unused(_: &AccountSeed) {}
#[test]
#[ignore = "requires Docker"]
fn dovecot_non_ascii_mailbox_names_round_trip() {
let d = Dovecot::start().expect("dovecot start");
let account = d.accounts.first().expect("account").clone();
let mutf7_name = "Envoy&AOk-s";
let utf8_name = "Envoyés";
let message = b"From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Accents\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Accented folder probe.\r\n";
d.create_non_ascii_mailbox(&account, mutf7_name, message)
.expect("create accented mailbox");
let archive = tmp_archive("dovecot-non-ascii");
let summary =
import_imap::run(common(&archive), imap_config(&account, &d.imap)).expect("imap import");
assert!(
!summary.any_failed(),
"an accented folder name must not fail the import: {summary:?}"
);
let conn = open_archive(&archive);
let names: Vec<String> = {
let mut stmt = conn
.prepare("SELECT name FROM mailboxes ORDER BY name")
.unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(Result::unwrap)
.collect()
};
let mailbox_id = mailbox_id_by_name(&conn, utf8_name).unwrap_or_else(|| {
panic!("no mailbox named {utf8_name}; names must not be mojibake, got {names:?}")
});
let emails = emails_in_mailbox(&conn, mailbox_id);
assert_eq!(
emails, 1,
"the message inside the accented folder must be imported"
);
cleanup(&archive);
}
+63
View File
@@ -597,6 +597,69 @@ fn discovery_via_well_known_redirect_307() {
assert!(disc.home_set_url.ends_with("/dav/cal/u/"));
}
#[test]
fn discovery_survives_501_on_the_user_supplied_url() {
let mut server = mockito::Server::new();
let url = server.url();
let _root = server
.mock("PROPFIND", "/")
.with_status(501)
.with_body("")
.expect_at_least(1)
.create();
let principal_with_homeset = format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>{url}/dav/principals/u/</d:href>
<d:propstat>
<d:prop>
<d:current-user-principal><d:href>{url}/dav/principals/u/</d:href></d:current-user-principal>
<c:calendar-home-set><d:href>{url}/dav/cal/u/</d:href></c:calendar-home-set>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
);
let collections_body = format!(
r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>{url}/dav/cal/u/default/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<d:displayname>Default</d:displayname>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#
);
let _well_known = multistatus_response(
&mut server,
"PROPFIND",
"/.well-known/caldav",
&principal_with_homeset,
);
let _principal = multistatus_response(
&mut server,
"PROPFIND",
"/dav/principals/u/",
&principal_with_homeset,
);
let _collections =
multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &collections_body);
let c = client(0);
let disc = discover(&c, DavKind::Caldav, &url).expect("discovery must not abort on 501");
assert_eq!(disc.collections.len(), 1);
assert!(disc.home_set_url.ends_with("/dav/cal/u/"));
}
#[test]
fn discovery_when_server_omits_current_user_principal() {
let mut server = mockito::Server::new();
+72
View File
@@ -374,6 +374,78 @@ fn calendar_item_master_inlines_modified_and_deleted_occurrences() {
assert_eq!(item.deleted_occurrences.len(), 1);
}
#[test]
fn organizer_and_attendee_addresses_survive_an_ex_routing_type() {
let body = "<vandelay-inner xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
<t:CalendarItem>\
<t:ItemId Id=\"M3\" ChangeKey=\"K1\"/>\
<t:Subject>Review</t:Subject>\
<t:UID>uid-3</t:UID>\
<t:Start>2025-06-15T14:00:00Z</t:Start>\
<t:End>2025-06-15T15:00:00Z</t:End>\
<t:Organizer><t:Mailbox><t:Name>Alice</t:Name>\
<t:EmailAddress>[email protected]</t:EmailAddress>\
<t:RoutingType>EX</t:RoutingType><t:MailboxType>Mailbox</t:MailboxType></t:Mailbox></t:Organizer>\
<t:RequiredAttendees><t:Attendee><t:Mailbox><t:Name>Kristina Morgental</t:Name>\
<t:EmailAddress>/o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina</t:EmailAddress>\
<t:RoutingType>EX</t:RoutingType></t:Mailbox><t:ResponseType>Unknown</t:ResponseType></t:Attendee></t:RequiredAttendees>\
</t:CalendarItem></vandelay-inner>";
let item = vandelay::exchange_ews::parse::parse_calendar_item(body).unwrap();
let event = vandelay::exchange_ews::calendar_map::to_jscalendar(&item);
assert_eq!(
event.data["organizerCalendarAddress"], "mailto:[email protected]",
"a usable address must be kept even when RoutingType says EX"
);
let participants = event.data["participants"].as_object().unwrap();
let organizer = participants
.values()
.find(|p| p["calendarAddress"] == "mailto:[email protected]")
.expect("organizer participant");
assert_eq!(organizer["email"], "[email protected]");
let attendee = participants
.values()
.find(|p| p["name"] == "Kristina Morgental")
.expect("attendee participant");
assert!(
attendee["calendarAddress"]
.as_str()
.unwrap()
.starts_with("urn:x-vandelay:attendee:"),
"a legacy directory reference cannot be resolved and stays synthetic"
);
assert!(
attendee.get("email").is_none(),
"no email is invented for a directory reference"
);
}
#[test]
fn recurrence_end_date_with_a_timezone_offset_yields_a_bounded_series() {
let body = "<vandelay-inner xmlns:t=\"http://schemas.microsoft.com/exchange/services/2006/types\">\
<t:CalendarItem>\
<t:ItemId Id=\"M2\" ChangeKey=\"K1\"/>\
<t:Subject>Biweekly</t:Subject>\
<t:UID>uid-2</t:UID>\
<t:Start>2021-08-04T21:15:00Z</t:Start>\
<t:End>2021-08-04T22:15:00Z</t:End>\
<t:CalendarItemType>RecurringMaster</t:CalendarItemType>\
<t:Recurrence>\
<t:WeeklyRecurrence><t:Interval>2</t:Interval><t:DaysOfWeek>Wednesday</t:DaysOfWeek></t:WeeklyRecurrence>\
<t:EndDateRecurrence><t:StartDate>2021-08-04-06:00</t:StartDate><t:EndDate>2021-09-30-06:00</t:EndDate></t:EndDateRecurrence>\
</t:Recurrence>\
</t:CalendarItem></vandelay-inner>";
let item = vandelay::exchange_ews::parse::parse_calendar_item(body).unwrap();
let event = vandelay::exchange_ews::calendar_map::to_jscalendar(&item);
let rule = &event.data["recurrenceRule"];
assert_eq!(rule["frequency"], "weekly");
assert_eq!(
rule["until"], "2021-09-30T23:59:59",
"an EndDate carrying a UTC offset must still produce a valid LocalDateTime"
);
}
#[test]
fn sync_folder_items_creates_updates_deletes_round_trip() {
let body = envelope(&format!(
+178
View File
@@ -1751,3 +1751,181 @@ fn coordinator_authenticationfailed_yields_exit2_connection_error() {
assert!(matches!(err, vandelay::error::Error::Connection(_)));
assert_eq!(err.exit_code(), 2);
}
fn read_select_mailbox(conn: &mut MockConn) -> std::io::Result<(String, String)> {
let (tag, cmd) = conn.read_command()?;
let arg = cmd
.strip_prefix("SELECT ")
.unwrap_or_else(|| panic!("expected SELECT, got {cmd}"));
if arg.starts_with('{') {
let mut line = String::new();
conn.reader.read_line(&mut line)?;
Ok((tag, line.trim_end_matches(['\r', '\n']).to_owned()))
} else {
Ok((tag, arg.trim_matches('"').to_owned()))
}
}
const TURKISH_SENT: &str = "Gönderilmiş Postalar";
const FRENCH_SENT_UTF8: &str = "Envoyés";
const FRENCH_SENT_MUTF7: &str = "Envoy&AOk-s";
#[test]
fn utf8_accept_server_gets_the_folder_name_back_as_utf8() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 ENABLE UTF8=ACCEPT LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line(&format!("* LIST () \"/\" \"{TURKISH_SENT}\""))?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, _) = conn.read_command()?;
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, TURKISH_SENT,
"an ENABLEd UTF8=ACCEPT server must be sent the name as UTF-8"
);
write_select(conn, &tag, 900, 2, 1)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
conn.write_line("* SEARCH 1")?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
drain_until_close(conn);
Ok(())
});
let worker: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 ENABLE UTF8=ACCEPT LITERAL+ AUTH=PLAIN")?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, TURKISH_SENT,
"the fetch worker must agree with the coordinator"
);
write_select(conn, &tag, 900, 2, 1)?;
let (tag, _) = conn.read_command()?;
write_fetch_message(conn, 1, 1, MSG_BODY)?;
conn.write_line(&format!("{tag} OK FETCH done"))?;
drain_until_close(conn);
Ok(())
});
let server = MockImap::start_scripts(vec![control, worker]);
let archive = tempfile("utf8_accept_name");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.failed, 0, "summary={summary:?}");
assert_eq!(email.1.created, 1);
let conn = Connection::open(&archive).unwrap();
assert_eq!(
conn.query_row::<String, _, _>("SELECT name FROM mailboxes", [], |r| r.get(0))
.unwrap(),
TURKISH_SENT,
"the folder name is stored as UTF-8, not as Latin-1 mojibake"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn mutf7_server_gets_the_folder_name_back_as_mutf7() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line(&format!("* LIST () \"/\" \"{FRENCH_SENT_MUTF7}\""))?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, _) = conn.read_command()?;
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, FRENCH_SENT_MUTF7,
"a server without UTF8=ACCEPT must be sent modified UTF-7"
);
write_select(conn, &tag, 901, 1, 0)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
conn.write_line("* SEARCH")?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
drain_until_close(conn);
Ok(())
});
let server = MockImap::start_scripts(vec![control, worker_idle_script("IMAP4rev2 LITERAL+")]);
let archive = tempfile("mutf7_name");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.failed, 0, "summary={summary:?}");
let conn = Connection::open(&archive).unwrap();
assert_eq!(
conn.query_row::<String, _, _>("SELECT name FROM mailboxes", [], |r| r.get(0))
.unwrap(),
FRENCH_SENT_UTF8
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn utf8_name_from_a_server_that_never_enabled_utf8_falls_back_on_select() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line(&format!("* LIST () \"/\" \"{FRENCH_SENT_UTF8}\""))?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, _) = conn.read_command()?;
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(name, FRENCH_SENT_MUTF7);
conn.write_line(&format!("{tag} NO [NONEXISTENT] Mailbox does not exist."))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, FRENCH_SENT_UTF8,
"a refused modified UTF-7 name must be retried as UTF-8"
);
write_select(conn, &tag, 902, 2, 1)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
conn.write_line("* SEARCH 1")?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
drain_until_close(conn);
Ok(())
});
let worker: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(name, FRENCH_SENT_MUTF7);
conn.write_line(&format!("{tag} NO [NONEXISTENT] Mailbox does not exist."))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(name, FRENCH_SENT_UTF8);
write_select(conn, &tag, 902, 2, 1)?;
let (tag, _) = conn.read_command()?;
write_fetch_message(conn, 1, 1, MSG_BODY)?;
conn.write_line(&format!("{tag} OK FETCH done"))?;
drain_until_close(conn);
Ok(())
});
let server = MockImap::start_scripts(vec![control, worker]);
let archive = tempfile("utf8_no_enable");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.failed, 0, "summary={summary:?}");
assert_eq!(email.1.created, 1);
let conn = Connection::open(&archive).unwrap();
assert_eq!(
conn.query_row::<String, _, _>("SELECT name FROM mailboxes", [], |r| r.get(0))
.unwrap(),
FRENCH_SENT_UTF8
);
let _ = std::fs::remove_file(&archive);
}
+54
View File
@@ -752,3 +752,57 @@ fn malformed_message_yields_zero_message_match_but_imports() {
assert_eq!(inbox.len(), 2);
let _ = fs::remove_file(&archive);
}
#[test]
fn received_at_comes_from_the_filename_not_a_clobbered_mtime() {
let td = TempDir::new().unwrap();
let root = td.path();
ensure_maildir(root);
let with_stamp = write_message(
root,
"cur",
"1763065233.M1P1.host,S=120:2,S",
&rfc5322("stamped", "stamped", "body"),
);
let without_stamp = write_message(
root,
"cur",
"M2P2.host:2,S",
&rfc5322("unstamped", "unstamped", "body"),
);
let restored =
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000);
for p in [&with_stamp, &without_stamp] {
let f = fs::File::options().write(true).open(p).unwrap();
f.set_modified(restored).unwrap();
}
let archive = tmp_archive("received_at");
let summary = run(common(&archive), base_cfg(root)).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mut stmt = conn
.prepare(
"SELECT e.received_at FROM emails e
JOIN blobs b ON b.id = e.blob_id
WHERE CAST(b.data AS TEXT) LIKE ?1",
)
.unwrap();
let stamped: String = stmt
.query_row(rusqlite::params!["%<[email protected]>%"], |r| r.get(0))
.unwrap();
assert_eq!(
stamped, "2025-11-13T20:20:33Z",
"the maildir filename timestamp wins over a restored mtime"
);
let unstamped: String = stmt
.query_row(rusqlite::params!["%<[email protected]>%"], |r| r.get(0))
.unwrap();
assert_eq!(
unstamped, "2025-05-12T10:00:00Z",
"without a filename timestamp the message Date header is used"
);
drop(stmt);
let _ = fs::remove_file(&archive);
}
+386 -1
View File
@@ -1270,6 +1270,197 @@ fn import_removes_vanished_email_and_drops_cross_ref() {
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();
@@ -1620,6 +1811,197 @@ fn export_email_blake3_fallback_matches_when_no_message_id() {
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();
@@ -3997,7 +4379,10 @@ fn export_email_server_unavailable_is_retried_not_dropped() {
failing.assert();
succeeding.assert();
up.assert();
assert_eq!(email.created, 1, "the message must survive a transient error");
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());