v1.0.9
This commit is contained in:
@@ -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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user