diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d022d1..de8b810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [1.0.9] - 2026-08-22 + +### Added + +### Changed + +### Fixed +- IMAP mailbox names were decoded as Latin-1 instead of UTF-8, so non-ASCII folders failed to SELECT (#32 #37). +- CalDAV discovery aborted when the given URL answered PROPFIND with 501 instead of trying the next discovery step (#35). +- An unparsable `receivedAt` dropped the whole email, and a `FileNode` without `nodeType` was skipped (#36). +- Maildir import took `received_at` from the file mtime, which does not survive a copy or restore (#38). +- An EWS recurrence `EndDate` carrying a UTC offset produced a malformed `until` and an unbounded series (#33). +- Export took the default calendar or address book away from a target account that already had one, and an archive could hold more than one default per type. +- Exchange participant addresses were replaced by a synthetic URN whenever `RoutingType` was not SMTP, and a Graph X500 reference was emitted as a malformed `mailto:` (#33). + ## [1.0.8] - 2026-08-15 ### Added diff --git a/Cargo.lock b/Cargo.lock index 849ba10..f7c3158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,12 +95,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.8" @@ -270,11 +264,10 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" -version = "1.8.6" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", @@ -284,9 +277,9 @@ dependencies = [ [[package]] name = "bollard" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" dependencies = [ "async-stream", "base64 0.22.1", @@ -329,15 +322,14 @@ dependencies = [ [[package]] name = "bollard-buildkit-proto" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06961b3a889aadf51ad938dfcd07a29e49bd77c6e25ed8c1021764ea888ec7bd" +checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3" dependencies = [ "prost", "prost-types", "tonic", "tonic-prost", - "ureq", ] [[package]] @@ -379,9 +371,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calcard" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf9612bef43974ff02c1813a7abc49e99ea55b369e6f57efa84d0c579c17e3c" +checksum = "f908fcb612ff8729e4302e071562bfbb0f7cdeb1b791e06024e7f11eedb83d9c" dependencies = [ "ahash", "chrono", @@ -397,9 +389,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -725,9 +717,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "equivalent" @@ -952,9 +944,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -1279,9 +1271,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1418,9 +1410,9 @@ dependencies = [ [[package]] name = "jmap-tools" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896b3268cc668e6129bb7cf6f70e62f5454ca426a1b3b0f98ba23e9b58881eb7" +checksum = "5bdcd368dcd58a5efaac10c142fb8cddb4d2949af1297b624df3ff0db4cbff54" dependencies = [ "hashify", "serde", @@ -1543,21 +1535,21 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mail-builder" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" +checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3" [[package]] name = "mail-parser" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4084ec5c2f90b341d0c70990e92a23b128f75ca14fc1dd5edd8fd5c9b417da4d" +checksum = "0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5" dependencies = [ "hashify", ] @@ -1916,6 +1908,21 @@ dependencies = [ "prost", ] +[[package]] +name = "psl" +version = "2.1.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc88482eea924ca3a2f56a547454169af58deef35567965eb4fc2392a834841" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "quick-xml" version = "0.41.0" @@ -2003,18 +2010,18 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", @@ -2183,9 +2190,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2871,7 +2878,6 @@ dependencies = [ "serde_json", "ureq-proto", "utf8-zero", - "webpki-roots", ] [[package]] @@ -2919,16 +2925,16 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" dependencies = [ "sha1_smol", ] [[package]] name = "vandelay" -version = "1.0.8" +version = "1.0.9" dependencies = [ "base64 0.23.1", "blake3", @@ -2942,6 +2948,7 @@ dependencies = [ "mail-parser", "mockito", "num_cpus", + "psl", "quick-xml", "regex", "rusqlite", @@ -3067,15 +3074,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -3353,9 +3351,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3364,9 +3362,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 340b9b4..5cc9143 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "vandelay" description = "JMAP account migration utility" -version = "1.0.8" +version = "1.0.9" authors = ["Stalwart Labs LLC "] license = "Apache-2.0 OR MIT" repository = "https://github.com/stalwartlabs/vandelay" @@ -46,6 +46,7 @@ flate2 = "1.1.9" quick-xml = "0.41" chrono = { version = "0.4", default-features = false, features = ["std"] } chrono-tz = { version = "0.10", default-features = false } +psl = "2.1.226" [dev-dependencies] mockito = "1.7.2" diff --git a/src/dav/discover.rs b/src/dav/discover.rs index 5425e2d..bbc4721 100644 --- a/src/dav/discover.rs +++ b/src/dav/discover.rs @@ -127,7 +127,7 @@ fn try_treat_url_as_home_or_collection( let collections = match propfind_collections(client, kind, url, &body) { Ok(c) => c, Err(DiscoveryError::Transport(JmapError::HttpStatus { status, .. })) - if (400..500).contains(&status) => + if (400..600).contains(&status) => { return Ok(None); } diff --git a/src/db/defaults.rs b/src/db/defaults.rs new file mode 100644 index 0000000..25c7b0e --- /dev/null +++ b/src/db/defaults.rs @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use crate::types::ObjectType; +use rusqlite::{Connection, params}; + +pub fn unique_default( + conn: &Connection, + ty: ObjectType, + is_default: bool, + exclude_id: Option, +) -> rusqlite::Result { + if !is_default { + return Ok(false); + } + let Some(table) = table_for(ty) else { + return Ok(false); + }; + let taken: bool = conn.query_row( + &format!( + "SELECT EXISTS(SELECT 1 FROM {table} \ + WHERE is_default = 1 AND (?1 IS NULL OR id != ?1))" + ), + params![exclude_id], + |row| row.get(0), + )?; + Ok(!taken) +} + +fn table_for(ty: ObjectType) -> Option<&'static str> { + match ty { + ObjectType::AddressBook => Some("address_books"), + ObjectType::Calendar => Some("calendars"), + ObjectType::ParticipantIdentity => Some("participant_identities"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::init; + + fn mem() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + init::apply_schema(&conn).unwrap(); + conn + } + + fn insert_book(conn: &Connection, name: &str, is_default: bool) -> i64 { + conn.execute( + "INSERT INTO address_books (name, sort_order, is_default, is_subscribed) + VALUES (?1, 0, ?2, 1)", + params![name, is_default as i64], + ) + .unwrap(); + conn.last_insert_rowid() + } + + #[test] + fn first_claimant_keeps_the_default_second_is_dropped() { + let conn = mem(); + let first = unique_default(&conn, ObjectType::AddressBook, true, None).unwrap(); + assert!(first); + insert_book(&conn, "Contacts", first); + + assert!( + !unique_default(&conn, ObjectType::AddressBook, true, None).unwrap(), + "an archive must never hold two default address books" + ); + } + + #[test] + fn non_default_stays_non_default() { + let conn = mem(); + assert!(!unique_default(&conn, ObjectType::AddressBook, false, None).unwrap()); + } + + #[test] + fn each_type_has_its_own_default() { + let conn = mem(); + insert_book(&conn, "Contacts", true); + assert!( + unique_default(&conn, ObjectType::Calendar, true, None).unwrap(), + "a default address book must not block a default calendar" + ); + } + + #[test] + fn update_excludes_its_own_row() { + let conn = mem(); + let id = insert_book(&conn, "Contacts", true); + assert!(unique_default(&conn, ObjectType::AddressBook, true, Some(id)).unwrap()); + } + + #[test] + fn update_detects_another_holder() { + let conn = mem(); + insert_book(&conn, "Contacts", true); + let other = insert_book(&conn, "GAL", false); + assert!(!unique_default(&conn, ObjectType::AddressBook, true, Some(other)).unwrap()); + } + + #[test] + fn types_without_a_default_column_are_never_default() { + let conn = mem(); + assert!(!unique_default(&conn, ObjectType::Mailbox, true, None).unwrap()); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 26c401a..c638fc3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -6,6 +6,7 @@ pub mod blobs; pub mod dav_ids; +pub mod defaults; pub mod exchange_ews_ids; pub mod exchange_graph_ids; pub mod ids; diff --git a/src/exchange/address.rs b/src/exchange/address.rs new file mode 100644 index 0000000..c12d43d --- /dev/null +++ b/src/exchange/address.rs @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +const ATEXT_SYMBOLS: &[u8] = b"!#$%&'*+-/=?^_`{|}~"; +const MAX_LOCAL_PART: usize = 64; +const MAX_DOMAIN: usize = 255; +const MAX_LABEL: usize = 63; + +pub fn as_smtp_address(raw: &str) -> Option { + let candidate = normalise(raw)?; + is_addr_spec(&candidate).then_some(candidate) +} + +pub fn extract_smtp_address(raw: &str) -> Option { + let candidate = normalise(raw)?; + if !is_addr_spec(&candidate) { + return None; + } + let domain = candidate.split('@').next_back()?; + psl::suffix(domain.as_bytes()) + .is_some_and(|s| s.is_known()) + .then_some(candidate) +} + +fn normalise(raw: &str) -> Option { + let mut value = raw.trim(); + if let Some(open) = value.rfind('<') + && let Some(close) = value.rfind('>') + && open < close + { + value = value[open + 1..close].trim(); + } + for prefix in ["mailto:", "smtp:"] { + if value.len() > prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) { + value = value[prefix.len()..].trim(); + } + } + (!value.is_empty()).then(|| value.to_owned()) +} + +fn is_addr_spec(value: &str) -> bool { + if !value.is_ascii() || value.starts_with('/') { + return false; + } + let Some((local, domain)) = value.split_once('@') else { + return false; + }; + !domain.contains('@') && is_local_part(local) && is_domain(domain) +} + +fn is_local_part(local: &str) -> bool { + if local.is_empty() + || local.len() > MAX_LOCAL_PART + || local.starts_with('.') + || local.ends_with('.') + || local.contains("..") + { + return false; + } + local + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || ATEXT_SYMBOLS.contains(&b)) +} + +fn is_domain(domain: &str) -> bool { + if domain.is_empty() || domain.len() > MAX_DOMAIN { + return false; + } + domain.split('.').all(|label| { + !label.is_empty() + && label.len() <= MAX_LABEL + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXCHANGE_DN: &str = "/o=ExchangeLabs/ou=Exchange Administrative Group \ + (FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina"; + + #[test] + fn plain_addresses_are_accepted() { + assert_eq!( + as_smtp_address("user@example.com").as_deref(), + Some("user@example.com") + ); + assert_eq!( + as_smtp_address(" First.Last@sub.example.co.uk ").as_deref(), + Some("First.Last@sub.example.co.uk") + ); + } + + #[test] + fn internal_only_tlds_are_kept_when_the_server_says_smtp() { + assert_eq!( + as_smtp_address("user@contoso.local").as_deref(), + Some("user@contoso.local") + ); + assert!( + extract_smtp_address("user@contoso.local").is_none(), + "an unknown suffix is not enough to override the routing type" + ); + } + + #[test] + fn routing_prefixes_and_display_names_are_stripped() { + assert_eq!( + as_smtp_address("SMTP:user@example.com").as_deref(), + Some("user@example.com") + ); + assert_eq!( + as_smtp_address("mailto:user@example.com").as_deref(), + Some("user@example.com") + ); + assert_eq!( + as_smtp_address("Jane Doe ").as_deref(), + Some("jane@example.com") + ); + assert_eq!( + extract_smtp_address("SMTP:jane@example.com").as_deref(), + Some("jane@example.com") + ); + } + + #[test] + fn entra_guest_upns_are_accepted() { + let guest = "bob_contoso.com#EXT#@fabrikam.onmicrosoft.com"; + assert_eq!(as_smtp_address(guest).as_deref(), Some(guest)); + assert_eq!(extract_smtp_address(guest).as_deref(), Some(guest)); + } + + #[test] + fn legacy_exchange_distinguished_names_are_rejected() { + assert!(as_smtp_address(EXCHANGE_DN).is_none()); + assert!(extract_smtp_address(EXCHANGE_DN).is_none()); + assert!( + as_smtp_address( + "/O=HOSTING/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=abc" + ) + .is_none() + ); + } + + #[test] + fn malformed_values_are_rejected() { + for bad in [ + "", + " ", + "no-at-sign", + "@example.com", + "user@", + "user@@example.com", + "two words@example.com", + "user@-example.com", + "user@example-.com", + ".user@example.com", + "user.@example.com", + "us..er@example.com", + "üser@example.com", + ] { + assert!(as_smtp_address(bad).is_none(), "must reject {bad:?}"); + assert!(extract_smtp_address(bad).is_none(), "must reject {bad:?}"); + } + } + + #[test] + fn over_long_parts_are_rejected() { + let long_local = format!("{}@example.com", "a".repeat(65)); + assert!(as_smtp_address(&long_local).is_none()); + let long_label = format!("user@{}.com", "a".repeat(64)); + assert!(as_smtp_address(&long_label).is_none()); + } + + #[test] + fn dotless_domains_are_kept_when_the_server_says_smtp() { + assert_eq!( + as_smtp_address("postmaster@localhost").as_deref(), + Some("postmaster@localhost"), + "RFC 5321 allows a bare domain and old on-prem setups use them" + ); + assert!(extract_smtp_address("postmaster@localhost").is_none()); + } + + #[test] + fn public_suffix_is_required_only_by_the_strict_extractor() { + assert!(extract_smtp_address("user@example.com").is_some()); + assert!(extract_smtp_address("user@example.co.uk").is_some()); + assert!(extract_smtp_address("user@example.invalidtld").is_none()); + assert!(as_smtp_address("user@example.invalidtld").is_some()); + } +} diff --git a/src/exchange/date.rs b/src/exchange/date.rs new file mode 100644 index 0000000..d0a9812 --- /dev/null +++ b/src/exchange/date.rs @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +const END_OF_DAY: &str = "23:59:59"; + +pub fn recurrence_until(raw: &str) -> Option { + let trimmed = raw.trim(); + let date = trimmed.get(..10).filter(|d| is_calendar_date(d))?; + let time = match trimmed.get(10..) { + Some(rest) if rest.starts_with('T') || rest.starts_with('t') => { + clock_time(&rest[1..]).unwrap_or_else(|| END_OF_DAY.to_owned()) + } + _ => END_OF_DAY.to_owned(), + }; + Some(format!("{date}T{time}")) +} + +fn is_calendar_date(value: &str) -> bool { + let b = value.as_bytes(); + b.len() == 10 + && b[..4].iter().all(u8::is_ascii_digit) + && b[4] == b'-' + && b[5..7].iter().all(u8::is_ascii_digit) + && b[7] == b'-' + && b[8..10].iter().all(u8::is_ascii_digit) +} + +fn clock_time(rest: &str) -> Option { + let end = rest + .find(['Z', 'z', '+']) + .or_else(|| rest.find('-')) + .unwrap_or(rest.len()); + let raw = &rest[..end]; + let mut parts = raw.split(':'); + let hour = two_digits(parts.next()?)?; + let minute = two_digits(parts.next().unwrap_or("00"))?; + let second = match parts.next() { + Some(s) => two_digits(s.split('.').next().unwrap_or("00"))?, + None => "00".to_owned(), + }; + Some(format!("{hour}:{minute}:{second}")) +} + +fn two_digits(value: &str) -> Option { + let v = value.trim(); + if v.len() == 2 && v.bytes().all(|b| b.is_ascii_digit()) { + Some(v.to_owned()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_date_gets_end_of_day() { + assert_eq!( + recurrence_until("2026-08-01").as_deref(), + Some("2026-08-01T23:59:59") + ); + } + + #[test] + fn utc_designator_is_dropped() { + assert_eq!( + recurrence_until("2026-08-01Z").as_deref(), + Some("2026-08-01T23:59:59") + ); + } + + #[test] + fn numeric_offset_is_dropped_not_embedded() { + assert_eq!( + recurrence_until("2021-09-30-06:00").as_deref(), + Some("2021-09-30T23:59:59") + ); + assert_eq!( + recurrence_until("2021-09-30+02:00").as_deref(), + Some("2021-09-30T23:59:59") + ); + } + + #[test] + fn date_time_keeps_its_clock_time() { + assert_eq!( + recurrence_until("2021-09-30T15:15:00").as_deref(), + Some("2021-09-30T15:15:00") + ); + assert_eq!( + recurrence_until("2021-09-30T15:15:00Z").as_deref(), + Some("2021-09-30T15:15:00") + ); + assert_eq!( + recurrence_until("2021-09-30T15:15:00-06:00").as_deref(), + Some("2021-09-30T15:15:00") + ); + assert_eq!( + recurrence_until("2021-09-30T15:15:00.123Z").as_deref(), + Some("2021-09-30T15:15:00") + ); + } + + #[test] + fn short_clock_time_is_padded_to_seconds() { + assert_eq!( + recurrence_until("2021-09-30T15:15").as_deref(), + Some("2021-09-30T15:15:00") + ); + } + + #[test] + fn surrounding_whitespace_is_ignored() { + assert_eq!( + recurrence_until(" 2026-08-01Z \n").as_deref(), + Some("2026-08-01T23:59:59") + ); + } + + #[test] + fn unusable_input_yields_none() { + assert_eq!(recurrence_until(""), None); + assert_eq!(recurrence_until("not-a-date"), None); + assert_eq!(recurrence_until("2026-8-1"), None); + } +} diff --git a/src/exchange/mod.rs b/src/exchange/mod.rs index 9d7fe9c..74610f8 100644 --- a/src/exchange/mod.rs +++ b/src/exchange/mod.rs @@ -4,5 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 OR MIT */ +pub mod address; +pub mod date; pub mod jscalendar; pub mod tz; diff --git a/src/exchange_ews/calendar_map.rs b/src/exchange_ews/calendar_map.rs index 08821c7..0c07345 100644 --- a/src/exchange_ews/calendar_map.rs +++ b/src/exchange_ews/calendar_map.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value, json}; +use crate::exchange::address::{as_smtp_address, extract_smtp_address}; use crate::exchange::jscalendar::{ drop_calendar_address_dependents, is_override_ignored, synthetic_attendee_address, }; @@ -245,8 +246,13 @@ fn resolve_calendar_address( name: Option<&str>, ) -> Option<(String, Option)> { if let Some(addr) = address.filter(|a| !a.trim().is_empty()) { - if is_smtp_routing(routing_type) { - return Some((format!("mailto:{addr}"), Some(addr.to_owned()))); + let smtp = if is_smtp_routing(routing_type) { + as_smtp_address(addr).or_else(|| extract_smtp_address(addr)) + } else { + extract_smtp_address(addr) + }; + if let Some(smtp) = smtp { + return Some((format!("mailto:{smtp}"), Some(smtp))); } return Some((synthetic_attendee_address(addr), None)); } @@ -868,6 +874,83 @@ mod tests { assert_eq!(v["showWithoutTime"], true); } + #[test] + fn ex_routing_with_a_real_address_keeps_the_address() { + let raw = CalendarItemRaw { + id: crate::exchange_ews::types::ItemId::new("M", ""), + uid: Some("uid-ex".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some("alice@example.com".to_owned()), + organizer_routing_type: Some("EX".to_owned()), + required_attendees: vec![crate::exchange_ews::parse::RawAttendee { + email: Some("SMTP:bob@example.com".to_owned()), + routing_type: Some("EX".to_owned()), + name: None, + response_type: Some("Accept".to_owned()), + }], + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + assert_eq!( + v["organizerCalendarAddress"], "mailto:alice@example.com", + "a usable address must not be discarded because RoutingType says EX" + ); + let participants = v["participants"].as_object().unwrap(); + assert!( + participants + .values() + .any(|p| p["calendarAddress"] == "mailto:bob@example.com"), + "the SMTP: prefix must be stripped, got {participants:?}" + ); + } + + #[test] + fn legacy_distinguished_name_still_becomes_a_synthetic_address() { + let dn = "/o=ExchangeLabs/ou=Exchange Administrative Group \ + (FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina"; + let raw = CalendarItemRaw { + id: crate::exchange_ews::types::ItemId::new("M", ""), + uid: Some("uid-dn".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some(dn.to_owned()), + organizer_routing_type: Some("EX".to_owned()), + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + let addr = v["organizerCalendarAddress"].as_str().unwrap(); + assert!( + addr.starts_with("urn:x-vandelay:attendee:"), + "an unresolvable directory reference stays synthetic, got {addr}" + ); + let participants = v["participants"].as_object().unwrap(); + assert!( + participants.values().all(|p| p.get("email").is_none()), + "no email property is invented for a directory reference" + ); + } + + #[test] + fn smtp_routing_with_an_unusable_address_does_not_produce_a_broken_mailto() { + let raw = CalendarItemRaw { + id: crate::exchange_ews::types::ItemId::new("M", ""), + uid: Some("uid-bad".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some("Kristina Morgental".to_owned()), + organizer_routing_type: Some("SMTP".to_owned()), + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + assert!( + v["organizerCalendarAddress"] + .as_str() + .unwrap() + .starts_with("urn:x-vandelay:attendee:") + ); + } + #[test] fn attendees_get_required_or_optional_role() { let raw = CalendarItemRaw { diff --git a/src/exchange_ews/recurrence.rs b/src/exchange_ews/recurrence.rs index a76929f..8a365dc 100644 --- a/src/exchange_ews/recurrence.rs +++ b/src/exchange_ews/recurrence.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value, json}; +use crate::exchange::date::recurrence_until; use crate::exchange_ews::parse::{RawRecurrence, RecurrencePattern, RecurrenceRange}; pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option { @@ -92,13 +93,9 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option { match raw.range.as_ref() { Some(RecurrenceRange::NoEnd { .. }) | None => {} Some(RecurrenceRange::EndDate { end_date, .. }) => { - let trimmed = end_date.trim().trim_end_matches('Z'); - let local = if trimmed.contains('T') { - trimmed.to_owned() - } else { - format!("{trimmed}T23:59:59") - }; - rule.insert("until".to_owned(), Value::String(local)); + if let Some(local) = recurrence_until(end_date) { + rule.insert("until".to_owned(), Value::String(local)); + } } Some(RecurrenceRange::Numbered { number_of_occurrences, @@ -287,6 +284,35 @@ mod tests { assert_eq!(rule["byMonthDay"][0], 1); } + #[test] + fn end_date_with_numeric_offset_yields_valid_local_until() { + let raw = RawRecurrence { + pattern: Some(RecurrencePattern::Weekly { + interval: 2, + days_of_week: vec!["Wednesday".to_owned()], + }), + range: Some(RecurrenceRange::EndDate { + start_date: "2021-08-04-06:00".to_owned(), + end_date: "2021-09-30-06:00".to_owned(), + }), + }; + let rule = to_jscalendar_rule(&raw).expect("rule"); + assert_eq!(rule["until"], "2021-09-30T23:59:59"); + } + + #[test] + fn unusable_end_date_omits_until_rather_than_emitting_garbage() { + let raw = RawRecurrence { + pattern: Some(RecurrencePattern::Daily { interval: 1 }), + range: Some(RecurrenceRange::EndDate { + start_date: String::new(), + end_date: "not-a-date".to_owned(), + }), + }; + let rule = to_jscalendar_rule(&raw).expect("rule"); + assert!(rule.get("until").is_none()); + } + #[test] fn no_end_range_emits_no_until_or_count() { let raw = RawRecurrence { diff --git a/src/exchange_graph/calendar_map.rs b/src/exchange_graph/calendar_map.rs index 3d9b9db..690dc2f 100644 --- a/src/exchange_graph/calendar_map.rs +++ b/src/exchange_graph/calendar_map.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value, json}; +use crate::exchange::address::{as_smtp_address, extract_smtp_address}; use crate::exchange::jscalendar::{drop_calendar_address_dependents, synthetic_attendee_address}; use crate::exchange::tz::resolve_to_iana; use crate::exchange_graph::error::GraphError; @@ -356,8 +357,10 @@ fn non_empty(email: &Value, key: &str) -> Option { fn resolve_calendar_address(email: &Value) -> Option<(String, Option)> { if let Some(addr) = non_empty(email, "address") { - let cal_addr = format!("mailto:{addr}"); - return Some((cal_addr, Some(addr))); + if let Some(smtp) = as_smtp_address(&addr).or_else(|| extract_smtp_address(&addr)) { + return Some((format!("mailto:{smtp}"), Some(smtp))); + } + return Some((synthetic_attendee_address(&addr), None)); } non_empty(email, "name").map(|name| (synthetic_attendee_address(&name), None)) } @@ -663,6 +666,64 @@ mod tests { assert_eq!(participants["att-1"]["roles"]["required"], true); } + #[test] + fn a_hybrid_x500_address_is_not_emitted_as_a_mailto() { + let mut v = sample(); + v["attendees"] = json!([ + { + "emailAddress": { + "name": "Kristina Morgental", + "address": "/o=ExchangeLabs/ou=Exchange Administrative Group \ + (FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina" + }, + "type": "required", + "status": {"response": "none"} + } + ]); + let conv = convert_event(&v, None).unwrap(); + let p = &conv.data["participants"]["att-1"]; + let addr = p["calendarAddress"].as_str().unwrap(); + assert!( + addr.starts_with("urn:x-vandelay:attendee:"), + "a hybrid X500 reference must not become a malformed mailto, got {addr}" + ); + assert!( + p.get("email").is_none(), + "an X500 reference is not an email address" + ); + assert_eq!(p["name"], "Kristina Morgental", "the display name is kept"); + } + + #[test] + fn a_prefixed_or_bracketed_address_is_normalised() { + let mut v = sample(); + v["attendees"] = json!([ + { + "emailAddress": {"name": "Bob", "address": "SMTP:bob@example.com"}, + "type": "required", + "status": {"response": "none"} + }, + { + "emailAddress": {"name": "Eve", "address": "Eve "}, + "type": "required", + "status": {"response": "none"} + } + ]); + let conv = convert_event(&v, None).unwrap(); + assert_eq!( + conv.data["participants"]["att-1"]["calendarAddress"], + "mailto:bob@example.com" + ); + assert_eq!( + conv.data["participants"]["att-1"]["email"], + "bob@example.com" + ); + assert_eq!( + conv.data["participants"]["att-2"]["calendarAddress"], + "mailto:eve@example.com" + ); + } + #[test] fn a_name_only_attendee_gets_a_stable_synthetic_calendar_address() { let mut v = sample(); diff --git a/src/exchange_graph/recurrence.rs b/src/exchange_graph/recurrence.rs index 2c44dd7..72692ea 100644 --- a/src/exchange_graph/recurrence.rs +++ b/src/exchange_graph/recurrence.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; +use crate::exchange::date::recurrence_until; use crate::exchange_graph::error::GraphError; pub fn convert_patterned_recurrence(pr: &Value) -> Result { @@ -80,12 +81,11 @@ pub fn convert_patterned_recurrence_rule( let range_type = range.get("type").and_then(Value::as_str).unwrap_or("noEnd"); match range_type { "endDate" => { - if let Some(end) = range.get("endDate").and_then(Value::as_str) { - let local = if end.contains('T') { - end.to_owned() - } else { - format!("{end}T23:59:59") - }; + if let Some(local) = range + .get("endDate") + .and_then(Value::as_str) + .and_then(recurrence_until) + { rule.insert("until".to_owned(), Value::from(local)); } } diff --git a/src/imap/name.rs b/src/imap/name.rs index a4a3ad9..cea7e82 100644 --- a/src/imap/name.rs +++ b/src/imap/name.rs @@ -33,8 +33,11 @@ pub fn decode_mailbox_name_with(input: &str, utf8_accept: bool) -> Result Result Option { + let primary = encode_mailbox_name_with(input, utf8_accept); + let alternate = encode_mailbox_name_with(input, !utf8_accept); + if alternate == primary { + None + } else { + Some(alternate) + } +} + pub fn encode_mailbox_name(input: &str) -> String { encode_mailbox_name_with(input, false) } @@ -221,6 +234,38 @@ mod tests { assert_eq!(canonicalise_inbox("Inbox/Sub"), "Inbox/Sub"); } + #[test] + fn raw_utf8_name_survives_mutf7_decode_untouched() { + assert_eq!(decode_mailbox_name("Envoyés").unwrap(), "Envoyés"); + assert_eq!( + decode_mailbox_name("L/Le Vent Se Lève").unwrap(), + "L/Le Vent Se Lève" + ); + assert_eq!(decode_mailbox_name("R&-D é").unwrap(), "R&D é"); + } + + #[test] + fn raw_utf8_name_round_trips_through_encode() { + for s in ["Envoyés", "Gönderilmiş Postalar", "Çöp kutusu"] { + assert_eq!(decode_mailbox_name(&encode_mailbox_name(s)).unwrap(), s); + assert_eq!(decode_mailbox_name(s).unwrap(), s); + } + } + + #[test] + fn alternate_encoding_offered_only_for_non_ascii_names() { + assert_eq!( + alternate_mailbox_name("Envoyés", false).as_deref(), + Some("Envoyés") + ); + assert_eq!( + alternate_mailbox_name("Envoyés", true).as_deref(), + Some("Envoy&AOk-s") + ); + assert_eq!(alternate_mailbox_name("INBOX", false), None); + assert_eq!(alternate_mailbox_name("Sent Items", true), None); + } + #[test] fn utf8_accept_skips_mutf7_decode_so_ampersand_passes_through() { assert_eq!(decode_mailbox_name_with("R&D", true).unwrap(), "R&D"); diff --git a/src/imap/response.rs b/src/imap/response.rs index 24a3f07..898f553 100644 --- a/src/imap/response.rs +++ b/src/imap/response.rs @@ -533,23 +533,26 @@ impl<'r, R: BufRead> Parser<'r, R> { fn parse_quoted(&mut self) -> Result { self.expect(b'"')?; - let mut s = String::new(); + let mut raw: Vec = Vec::new(); while let Some(b) = self.peek() { match b { b'"' => { self.bump(); - return Ok(Value::Str(s)); + return Ok(match String::from_utf8(raw) { + Ok(s) => Value::Str(s), + Err(e) => Value::Bytes(e.into_bytes()), + }); } b'\\' => { self.bump(); let next = self .peek() .ok_or_else(|| ImapError::Parse("trailing \\ in quoted".into()))?; - s.push(next as char); + raw.push(next); self.bump(); } _ => { - s.push(b as char); + raw.push(b); self.bump(); } } @@ -830,6 +833,41 @@ mod tests { } } + #[test] + fn quoted_mailbox_name_with_utf8_bytes_is_decoded_as_utf8() { + let r = parse("* LIST (\\HasNoChildren) \"/\" \"Envoy\u{e9}s\"\r\n".as_bytes()); + match r { + Response::Untagged(Untagged::List { name, .. }) => assert_eq!(name, "Envoyés"), + other => panic!("expected List, got {other:?}"), + } + } + + #[test] + fn quoted_mailbox_name_with_modified_utf7_is_left_untouched() { + let r = + parse(b"* LIST (\\HasNoChildren) \"/\" \"[Gmail]/G&APY-nderilmi&AV8- Postalar\"\r\n"); + match r { + Response::Untagged(Untagged::List { name, .. }) => { + assert_eq!(name, "[Gmail]/G&APY-nderilmi&AV8- Postalar"); + } + other => panic!("expected List, got {other:?}"), + } + } + + #[test] + fn quoted_mailbox_name_with_invalid_utf8_falls_back_to_lossy() { + let mut line: Vec = b"* LIST (\\HasNoChildren) \"/\" \"bad".to_vec(); + line.push(0xff); + line.extend_from_slice(b"name\"\r\n"); + let r = parse(&line); + match r { + Response::Untagged(Untagged::List { name, .. }) => { + assert!(name.starts_with("bad") && name.ends_with("name")); + } + other => panic!("expected List, got {other:?}"), + } + } + #[test] fn untagged_list_with_special_use() { let r = parse(b"* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\r\n"); diff --git a/src/jmap/wire/common.rs b/src/jmap/wire/common.rs index c8ab03a..41e56af 100644 --- a/src/jmap/wire/common.rs +++ b/src/jmap/wire/common.rs @@ -39,9 +39,58 @@ where Ok(Option::::deserialize(d)?.unwrap_or(true)) } +pub fn lenient_utc_date<'de, D>(d: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(d)? + .as_deref() + .and_then(parse_utc_date)) +} + +pub fn parse_utc_date(raw: &str) -> Option { + use time::format_description::well_known::Rfc3339; + let trimmed = raw.trim(); + if let Ok(dt) = UtcDate::parse(trimmed, &Rfc3339) { + return Some(dt); + } + let separated = trimmed.replacen(' ', "T", 1); + if let Ok(dt) = UtcDate::parse(&separated, &Rfc3339) { + return Some(dt); + } + UtcDate::parse(&format!("{separated}Z"), &Rfc3339).ok() +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmailAddress { pub name: Option, pub email: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strict_rfc3339_is_accepted() { + let dt = parse_utc_date("2026-08-22T09:42:52Z").expect("parsed"); + assert_eq!(dt.year(), 2026); + assert!(parse_utc_date("2026-08-22T09:42:52.123+02:00").is_some()); + } + + #[test] + fn space_separator_and_missing_zone_are_salvaged() { + assert!(parse_utc_date("2026-08-22 09:42:52Z").is_some()); + assert!(parse_utc_date("2026-08-22T09:42:52").is_some()); + assert!(parse_utc_date(" 2026-08-22T09:42:52Z ").is_some()); + } + + #[test] + fn unrepresentable_dates_yield_none_instead_of_an_error() { + assert!(parse_utc_date("30828-09-14T02:48:05Z").is_none()); + assert!(parse_utc_date("10000-01-01T00:00:00Z").is_none()); + assert!(parse_utc_date("").is_none()); + assert!(parse_utc_date("not a date").is_none()); + } +} diff --git a/src/jmap/wire/email.rs b/src/jmap/wire/email.rs index 90b8af6..e84f795 100644 --- a/src/jmap/wire/email.rs +++ b/src/jmap/wire/email.rs @@ -7,6 +7,7 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; +use super::common::lenient_utc_date; use super::{JmapId, UtcDate}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17,8 +18,13 @@ pub struct Email { pub blob_id: JmapId, - #[serde(with = "time::serde::rfc3339")] - pub received_at: UtcDate, + #[serde( + default, + deserialize_with = "lenient_utc_date", + serialize_with = "time::serde::rfc3339::option::serialize", + skip_serializing_if = "Option::is_none" + )] + pub received_at: Option, pub mailbox_ids: IndexMap, diff --git a/src/jmap/wire/file_node.rs b/src/jmap/wire/file_node.rs index 8505e3b..d70acdf 100644 --- a/src/jmap/wire/file_node.rs +++ b/src/jmap/wire/file_node.rs @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 OR MIT */ -use serde::{Deserialize, Serialize}; - use super::common::bool_or_true; use super::{JmapId, UtcDate}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -18,7 +17,8 @@ pub struct FileNode { #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_id: Option, - pub node_type: NodeType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub blob_id: Option, @@ -48,6 +48,17 @@ pub struct FileNode { pub role: Option, } +impl FileNode { + pub fn effective_node_type(&self) -> NodeType { + match self.node_type { + Some(t) => t, + None if self.blob_id.is_some() => NodeType::File, + None if self.target.is_some() => NodeType::Symlink, + None => NodeType::Directory, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum NodeType { diff --git a/src/sync/export/flat.rs b/src/sync/export/flat.rs index 0723fc9..673fe83 100644 --- a/src/sync/export/flat.rs +++ b/src/sync/export/flat.rs @@ -48,6 +48,9 @@ pub fn reconcile( }; let targets = target_get_all(net, ty).map_err(Error::from)?; + let mut default_claimed = targets + .iter() + .any(|t| t.get("isDefault").and_then(Value::as_bool).unwrap_or(false)); let mut tmatched = std::collections::HashSet::new(); let mut to_create: Vec<(i64, bool)> = Vec::new(); @@ -99,14 +102,25 @@ pub fn reconcile( maps.insert(ty, local, JmapId(id.clone())); counts.created += 1; if to_create.iter().any(|(l, d)| *l == local && *d) { - let mut req = crate::jmap::request::Request::new(); - req.call( - format!("{}/set", ty.jmap_name()), - json!({ "accountId": net.account, "onSuccessSetIsDefault": id }), - "d", - ); - if let Err(e) = req.send(&net.client, &net.api) { - logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name())); + if default_claimed { + logger.warn(&format!( + "{} {}: the target account already has a default, leaving it alone", + ty.jmap_name(), + id + )); + } else { + let mut req = crate::jmap::request::Request::new(); + req.call( + format!("{}/set", ty.jmap_name()), + json!({ "accountId": net.account, "onSuccessSetIsDefault": id }), + "d", + ); + match req.send(&net.client, &net.api) { + Ok(_) => default_claimed = true, + Err(e) => { + logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name())); + } + } } } } diff --git a/src/sync/export/tree.rs b/src/sync/export/tree.rs index 272de1c..188f55e 100644 --- a/src/sync/export/tree.rs +++ b/src/sync/export/tree.rs @@ -442,7 +442,7 @@ fn build_create( .map_err(Error::from)?; let mut wire = fnrow.wire; if let (crate::jmap::wire::file_node::NodeType::File, Some(blob_local)) = - (&wire.node_type, fnrow.blob_local_id) + (wire.effective_node_type(), fnrow.blob_local_id) { let ct = wire .media_type diff --git a/src/sync/import_imap/coordinator.rs b/src/sync/import_imap/coordinator.rs index 08367be..70f6a61 100644 --- a/src/sync/import_imap/coordinator.rs +++ b/src/sync/import_imap/coordinator.rs @@ -20,7 +20,7 @@ use crate::error::Error; use crate::imap::client::{ConnectMode, ImapClient}; use crate::imap::command; use crate::imap::error::ImapError; -use crate::imap::name::encode_mailbox_name_with; +use crate::imap::name::{alternate_mailbox_name, encode_mailbox_name_with}; use crate::imap::response::{NamespaceEntry, Untagged}; use crate::imap::retry::{ BackoffState, Disposition, RetryPolicy, classify, is_negotiation_failure, @@ -111,6 +111,33 @@ pub(super) fn control_run_collect( } } +pub(super) fn select_folder( + client: &mut ImapClient, + ctx: &ControlCtx, + folder: &str, +) -> Result { + let utf8_accept = client.utf8_accept(); + let wire_name = encode_mailbox_name_with(folder, utf8_accept); + let first = control_run_collect(client, ctx, &command::select(&wire_name)); + let Err(Error::Partial(_)) = &first else { + return first; + }; + let Some(alternate) = alternate_mailbox_name(folder, utf8_accept) else { + return first; + }; + match control_run_collect(client, ctx, &command::select(&alternate)) { + Ok(r) => { + log_at( + ctx.logger, + LEVEL_DEFAULT, + &format!("folder {folder:?}: selected with the alternate mailbox-name encoding"), + ); + Ok(r) + } + Err(_) => first, + } +} + pub(super) fn call_with_retry( client: &mut ImapClient, ctx: &ControlCtx, @@ -713,8 +740,7 @@ fn reconcile_folder( } } - let wire_name = encode_mailbox_name_with(&folder.name, client.utf8_accept()); - let resp = control_run_collect(client, control_ctx, &command::select(&wire_name))?; + let resp = select_folder(client, control_ctx, &folder.name)?; let mut uidvalidity: u32 = 0; let mut uidnext: u32 = 0; for u in &resp.untagged { @@ -1123,8 +1149,7 @@ fn dry_run_summary( } for folder in folders { - let wire = encode_mailbox_name_with(&folder.name, client.utf8_accept()); - let select_resp = match control_run_collect(client, control_ctx, &command::select(&wire)) { + let select_resp = match select_folder(client, control_ctx, &folder.name) { Ok(r) => r, Err(e) => { log_at( diff --git a/src/sync/import_imap/pool.rs b/src/sync/import_imap/pool.rs index 690ca43..9f00107 100644 --- a/src/sync/import_imap/pool.rs +++ b/src/sync/import_imap/pool.rs @@ -13,7 +13,7 @@ use crossbeam_channel::{Receiver, Sender, unbounded}; use crate::imap::client::{ConnectMode, ImapClient}; use crate::imap::command; use crate::imap::error::ImapError; -use crate::imap::name::encode_mailbox_name_with; +use crate::imap::name::{alternate_mailbox_name, encode_mailbox_name_with}; use crate::imap::response::Untagged; use crate::imap::retry::{BackoffState, Disposition, RetryPolicy, classify}; use crate::imap::transport::Connector; @@ -231,8 +231,16 @@ fn run_one_job( event_tx: &Sender, ) -> Result<(), ImapError> { if current_folder.as_deref() != Some(job.folder.as_str()) { - let wire = encode_mailbox_name_with(&job.folder, client.utf8_accept()); - client.run_collect(&command::select(&wire))?; + let utf8_accept = client.utf8_accept(); + let wire = encode_mailbox_name_with(&job.folder, utf8_accept); + if let Err(e) = client.run_collect(&command::select(&wire)) { + match alternate_mailbox_name(&job.folder, utf8_accept) { + Some(alternate) if matches!(e, ImapError::No(_)) => { + client.run_collect(&command::select(&alternate))?; + } + _ => return Err(e), + } + } *current_folder = Some(job.folder.clone()); } let set = command::format_uid_set(&job.uids, true); diff --git a/src/sync/import_jmap.rs b/src/sync/import_jmap.rs index 2fb1cd3..d9e96a1 100644 --- a/src/sync/import_jmap.rs +++ b/src/sync/import_jmap.rs @@ -29,6 +29,7 @@ use crate::jmap::http::{Auth, HttpClient}; use crate::jmap::request::{get_all, get_changes, get_objects, get_state, query_all_ids}; use crate::jmap::session::{Limits, Session}; use crate::jmap::wire::JmapId; +use crate::jmap::wire::common::parse_utc_date; use crate::jmap::wire::email::Email; use crate::jmap::wire::file_node::{FileNode, NodeType}; use crate::jmap::wire::sieve_script::SieveScript; @@ -478,7 +479,7 @@ fn insert_objects( continue; } }; - match insert_one(&tx, ty, source_id, obj, &blobs) { + match insert_one(&tx, ty, source_id, obj, &blobs, logger) { Ok(local_id) => { db::ids::insert(&tx, source_id, ty, &jmap_id, local_id) .map_err(|e| Error::Partial(e.to_string()))?; @@ -649,6 +650,7 @@ fn insert_one( source_id: i64, obj: &Value, blobs: &HashMap>, + logger: &Logger, ) -> Result { let resolver = DbResolver { conn, source_id }; match ty { @@ -673,14 +675,28 @@ fn insert_one( mapping::insert_participant_identity(conn, &w) } ObjectType::Email => { - let w: Email = serde_json::from_value(obj.clone())?; + let mut w: Email = serde_json::from_value(obj.clone())?; let data = blobs .get(&w.blob_id.0) .ok_or_else(|| JmapError::malformed("email blob missing"))?; let blob_local = db::blobs::intern_blob(conn, data)?; - let mm = crate::sync::keys::index_to_json( - &crate::sync::emailmeta::email_index_from_blob(data), - ); + let (index, date_header) = crate::sync::emailmeta::email_meta_from_blob(data); + let mm = crate::sync::keys::index_to_json(&index); + if w.received_at.is_none() { + w.received_at = date_header.as_deref().and_then(parse_utc_date); + let id = obj.get("id").and_then(Value::as_str).unwrap_or("?"); + let raw = obj + .get("receivedAt") + .and_then(Value::as_str) + .unwrap_or(""); + logger.warn(&format!( + "Email {id}: unusable receivedAt {raw:?}; falling back to {}", + match &w.received_at { + Some(_) => "the Date header", + None => "1970-01-01T00:00:00Z", + } + )); + } mapping::insert_email(conn, &w, blob_local, &mm, &resolver) } ObjectType::SieveScript => { @@ -693,7 +709,7 @@ fn insert_one( } ObjectType::FileNode => { let w: FileNode = serde_json::from_value(obj.clone())?; - let blob_local = match (&w.node_type, &w.blob_id) { + let blob_local = match (w.effective_node_type(), &w.blob_id) { (NodeType::File, Some(b)) => { let data = blobs .get(&b.0) @@ -934,7 +950,7 @@ fn update_one( } ObjectType::FileNode => { let w: FileNode = serde_json::from_value(obj.clone())?; - let blob_local = match (&w.node_type, &w.blob_id) { + let blob_local = match (w.effective_node_type(), &w.blob_id) { (NodeType::File, Some(b)) => { let data = blobs .get(&b.0) diff --git a/src/sync/import_jmap/mapping.rs b/src/sync/import_jmap/mapping.rs index e203407..982f5a2 100644 --- a/src/sync/import_jmap/mapping.rs +++ b/src/sync/import_jmap/mapping.rs @@ -8,6 +8,7 @@ use indexmap::IndexMap; use rusqlite::{Connection, Row, params}; use serde_json::{Map, Value}; +use crate::db::defaults::unique_default; use crate::jmap::blob::{BlobWalkError, InlineShape, import_blob_ids, inline_blob_data_uris}; use crate::jmap::error::JmapError; use crate::jmap::wire::JmapId; @@ -168,7 +169,7 @@ pub fn insert_email( VALUES (?1, ?2, ?3, ?4, ?5)", params![ blob_local_id, - format_utc(&wire.received_at)?, + format_utc(&wire.received_at.unwrap_or(time::OffsetDateTime::UNIX_EPOCH))?, id_array_json(&mailbox_locals), Value::Array(keywords.iter().map(|k| Value::from(k.as_str())).collect()).to_string(), message_match @@ -248,6 +249,7 @@ pub fn insert_sieve_script( pub const SIEVE_SELECT: &str = "SELECT id, name, is_active, blob_id FROM sieve_scripts"; pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result { + let is_default = unique_default(conn, ObjectType::AddressBook, wire.is_default, None)?; conn.execute( "INSERT INTO address_books (name, description, sort_order, is_default, is_subscribed) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -255,7 +257,7 @@ pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result Result { + let is_default = unique_default(conn, ObjectType::Calendar, wire.is_default, None)?; conn.execute( "INSERT INTO calendars (name, description, color, sort_order, is_subscribed, is_visible, is_default, include_in_availability, default_alerts_with_time, @@ -278,7 +281,7 @@ pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result Result { + let is_default = unique_default(conn, ObjectType::ParticipantIdentity, wire.is_default, None)?; conn.execute( "INSERT INTO participant_identities (name, calendar_address, is_default) VALUES (?1, ?2, ?3)", - params![wire.name, wire.calendar_address, wire.is_default as i64], + params![wire.name, wire.calendar_address, is_default as i64], )?; Ok(conn.last_insert_rowid()) } @@ -314,7 +318,7 @@ pub fn insert_file_node( resolver: &impl LocalResolver, ) -> Result { let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id); - let node_type = serde_json::to_value(wire.node_type)? + let node_type = serde_json::to_value(wire.effective_node_type())? .as_str() .unwrap_or("file") .to_owned(); @@ -495,6 +499,12 @@ pub fn update_address_book( local_id: i64, wire: &AddressBook, ) -> Result { + let is_default = unique_default( + conn, + ObjectType::AddressBook, + wire.is_default, + Some(local_id), + )?; let n = conn.execute( "UPDATE address_books SET name = ?1, description = ?2, sort_order = ?3, is_default = ?4, is_subscribed = ?5 @@ -504,7 +514,7 @@ pub fn update_address_book( wire.name, wire.description, wire.sort_order, - wire.is_default as i64, + is_default as i64, wire.is_subscribed as i64, local_id ], @@ -517,6 +527,7 @@ pub fn update_calendar( local_id: i64, wire: &Calendar, ) -> Result { + let is_default = unique_default(conn, ObjectType::Calendar, wire.is_default, Some(local_id))?; let n = conn.execute( "UPDATE calendars SET name = ?1, description = ?2, color = ?3, sort_order = ?4, is_subscribed = ?5, is_visible = ?6, is_default = ?7, include_in_availability = ?8, @@ -533,7 +544,7 @@ pub fn update_calendar( wire.sort_order, wire.is_subscribed as i64, wire.is_visible as i64, - wire.is_default as i64, + is_default as i64, wire.include_in_availability, opt_json(&wire.default_alerts_with_time)?, opt_json(&wire.default_alerts_without_time)?, @@ -549,13 +560,19 @@ pub fn update_participant_identity( local_id: i64, wire: &ParticipantIdentity, ) -> Result { + let is_default = unique_default( + conn, + ObjectType::ParticipantIdentity, + wire.is_default, + Some(local_id), + )?; let n = conn.execute( "UPDATE participant_identities SET name = ?1, calendar_address = ?2, is_default = ?3 WHERE id = ?4 AND (name IS NOT ?1 OR calendar_address IS NOT ?2 OR is_default IS NOT ?3)", params![ wire.name, wire.calendar_address, - wire.is_default as i64, + is_default as i64, local_id ], )?; @@ -570,7 +587,7 @@ pub fn update_file_node( resolver: &impl LocalResolver, ) -> Result { let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id); - let node_type = serde_json::to_value(wire.node_type)? + let node_type = serde_json::to_value(wire.effective_node_type())? .as_str() .unwrap_or("file") .to_owned(); @@ -828,7 +845,9 @@ pub fn row_to_file_node( ), None => None, }; - let node_type = serde_json::from_value(Value::from(row.get::<_, String>(2)?))?; + let node_type = Some(serde_json::from_value(Value::from( + row.get::<_, String>(2)?, + ))?); let target: Option> = from_opt_json(row.get::<_, Option>(4)?)?; let blob_local_id: Option = row.get(3)?; Ok(FileNodeRow { diff --git a/src/sync/import_maildir/messages.rs b/src/sync/import_maildir/messages.rs index fe17f5d..c223b0a 100644 --- a/src/sync/import_maildir/messages.rs +++ b/src/sync/import_maildir/messages.rs @@ -16,7 +16,7 @@ use time::format_description::well_known::Rfc3339; use crate::db; use crate::sync::TypeCounts; -use crate::sync::emailmeta::email_index_from_blob; +use crate::sync::emailmeta::email_meta_from_blob; use crate::sync::keys::index_to_json; use super::keywords::{Translation, flags_from_filename, translate_flags, unique_id_from_filename}; @@ -149,9 +149,10 @@ pub fn insert_new( if translation.has_trashed_flag && !ctx.include_deleted { return Ok(None); } - let received_at = format_unix_rfc3339(entry.mtime_unix); + let (index, date_header) = email_meta_from_blob(&bytes); + let received_at = pick_received_at(&entry.filename, entry.mtime_unix, date_header.as_deref()); let blob_id = db::blobs::intern_blob(tx, &bytes)?; - let message_match = index_to_json(&email_index_from_blob(&bytes)); + let message_match = index_to_json(&index); let mailbox_ids = Value::Array(vec![Value::from(ctx.mailbox_local)]); let keywords = keywords_json(&translation); tx.execute( @@ -170,6 +171,37 @@ pub fn insert_new( Ok(Some(local_id)) } +pub fn delivery_time_from_filename(filename: &str) -> Option { + let digits: String = filename.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() || digits.len() > 12 { + return None; + } + let secs: u64 = digits.parse().ok()?; + if secs == 0 { + return None; + } + let now = OffsetDateTime::now_utc().unix_timestamp().max(0) as u64; + if secs > now.saturating_add(86_400) { + return None; + } + Some(secs) +} + +pub fn pick_received_at(filename: &str, mtime_unix: u64, date_header: Option<&str>) -> String { + if let Some(secs) = delivery_time_from_filename(filename) { + return format_unix_rfc3339(secs); + } + if let Some(d) = date_header { + return d.to_owned(); + } + if mtime_unix > 0 { + return format_unix_rfc3339(mtime_unix); + } + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PresentOutcome { Unchanged, @@ -721,4 +753,49 @@ mod tests { ); tx.commit().unwrap(); } + + #[test] + fn filename_delivery_time_wins_over_mtime() { + let got = pick_received_at( + "1763065233.M12345P678.host,S=1234:2,S", + 99, + Some("2025-05-12T10:00:00+02:00"), + ); + assert_eq!(got, "2025-11-13T20:20:33Z"); + } + + #[test] + fn date_header_used_when_the_filename_carries_no_timestamp() { + let got = pick_received_at( + "M12345P678.host:2,S", + 1763065233, + Some("2025-05-12T10:00:00+02:00"), + ); + assert_eq!(got, "2025-05-12T10:00:00+02:00"); + } + + #[test] + fn mtime_is_the_last_resort_before_now() { + let got = pick_received_at("M12345P678.host:2,S", 1763065233, None); + assert_eq!(got, "2025-11-13T20:20:33Z"); + } + + #[test] + fn implausible_filename_timestamps_are_rejected() { + assert_eq!(delivery_time_from_filename("0.M1P1.host"), None); + assert_eq!(delivery_time_from_filename("M1P1.host"), None); + assert_eq!( + delivery_time_from_filename("99999999999999.M1P1.host"), + None + ); + let far_future = (OffsetDateTime::now_utc().unix_timestamp() as u64) + 400_000; + assert_eq!( + delivery_time_from_filename(&format!("{far_future}.M1P1.host")), + None + ); + assert_eq!( + delivery_time_from_filename("1763065233.M12345P678.host"), + Some(1763065233) + ); + } } diff --git a/tests/integration/cyrus.rs b/tests/integration/cyrus.rs index 6564408..29d9afa 100644 --- a/tests/integration/cyrus.rs +++ b/tests/integration/cyrus.rs @@ -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)?; diff --git a/tests/integration/dovecot.rs b/tests/integration/dovecot.rs index 6ab5568..7ce33f3 100644 --- a/tests/integration/dovecot.rs +++ b/tests/integration/dovecot.rs @@ -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)?; diff --git a/tests/integration_cyrus.rs b/tests/integration_cyrus.rs index 4c68542..6475d0d 100644 --- a/tests/integration_cyrus.rs +++ b/tests/integration_cyrus.rs @@ -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: accents@vandelay.test\r\n\ + To: user1@vandelay.test\r\n\ + Subject: Accents\r\n\ + Message-ID: \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 = { + 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); +} diff --git a/tests/integration_dovecot.rs b/tests/integration_dovecot.rs index d0ae69a..77bdcf6 100644 --- a/tests/integration_dovecot.rs +++ b/tests/integration_dovecot.rs @@ -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: accents@vandelay.test\r\n\ + To: user1@vandelay.test\r\n\ + Subject: Accents\r\n\ + Message-ID: \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 = { + 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); +} diff --git a/tests/mock_dav.rs b/tests/mock_dav.rs index 3d7fa83..a264097 100644 --- a/tests/mock_dav.rs +++ b/tests/mock_dav.rs @@ -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#" + + + {url}/dav/principals/u/ + + + {url}/dav/principals/u/ + {url}/dav/cal/u/ + + HTTP/1.1 200 OK + + +"# + ); + let collections_body = format!( + r#" + + + {url}/dav/cal/u/default/ + + + + Default + + HTTP/1.1 200 OK + + +"# + ); + 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(); diff --git a/tests/mock_exchange_ews.rs b/tests/mock_exchange_ews.rs index 23e72ff..3c291b6 100644 --- a/tests/mock_exchange_ews.rs +++ b/tests/mock_exchange_ews.rs @@ -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 = "\ + \ + \ + Review\ + uid-3\ + 2025-06-15T14:00:00Z\ + 2025-06-15T15:00:00Z\ + Alice\ + alice@example.com\ + EXMailbox\ + Kristina Morgental\ + /o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=bdc77b18152647a29d28ce1188376dc9-kristina\ + EXUnknown\ + "; + 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:alice@example.com", + "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:alice@example.com") + .expect("organizer participant"); + assert_eq!(organizer["email"], "alice@example.com"); + + 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 = "\ + \ + \ + Biweekly\ + uid-2\ + 2021-08-04T21:15:00Z\ + 2021-08-04T22:15:00Z\ + RecurringMaster\ + \ + 2Wednesday\ + 2021-08-04-06:002021-09-30-06:00\ + \ + "; + 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!( diff --git a/tests/mock_imap.rs b/tests/mock_imap.rs index 1bcaf67..7d540fa 100644 --- a/tests/mock_imap.rs +++ b/tests/mock_imap.rs @@ -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::("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::("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::("SELECT name FROM mailboxes", [], |r| r.get(0)) + .unwrap(), + FRENCH_SENT_UTF8 + ); + let _ = std::fs::remove_file(&archive); +} diff --git a/tests/mock_maildir.rs b/tests/mock_maildir.rs index 4b89d70..5697b3d 100644 --- a/tests/mock_maildir.rs +++ b/tests/mock_maildir.rs @@ -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!["%%"], |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!["%%"], |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); +} diff --git a/tests/mock_sync.rs b/tests/mock_sync.rs index 5e87812..6396a74 100644 --- a/tests/mock_sync.rs +++ b/tests/mock_sync.rs @@ -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 = 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());