This commit is contained in:
Maurus Decimus
2026-08-22 18:46:54 +02:00
parent 1cc633f39c
commit 423b761617
34 changed files with 1936 additions and 123 deletions
+15
View File
@@ -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/). 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 ## [1.0.8] - 2026-08-15
### Added ### Added
Generated
+53 -55
View File
@@ -95,12 +95,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@@ -270,11 +264,10 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.8.6" version = "1.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
dependencies = [ dependencies = [
"arrayref",
"arrayvec", "arrayvec",
"cc", "cc",
"cfg-if", "cfg-if",
@@ -284,9 +277,9 @@ dependencies = [
[[package]] [[package]]
name = "bollard" name = "bollard"
version = "0.21.0" version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"base64 0.22.1", "base64 0.22.1",
@@ -329,15 +322,14 @@ dependencies = [
[[package]] [[package]]
name = "bollard-buildkit-proto" name = "bollard-buildkit-proto"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06961b3a889aadf51ad938dfcd07a29e49bd77c6e25ed8c1021764ea888ec7bd" checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3"
dependencies = [ dependencies = [
"prost", "prost",
"prost-types", "prost-types",
"tonic", "tonic",
"tonic-prost", "tonic-prost",
"ureq",
] ]
[[package]] [[package]]
@@ -379,9 +371,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "calcard" name = "calcard"
version = "0.3.9" version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acf9612bef43974ff02c1813a7abc49e99ea55b369e6f57efa84d0c579c17e3c" checksum = "f908fcb612ff8729e4302e071562bfbb0f7cdeb1b791e06024e7f11eedb83d9c"
dependencies = [ dependencies = [
"ahash", "ahash",
"chrono", "chrono",
@@ -397,9 +389,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.3" version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -725,9 +717,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "either" name = "either"
version = "1.17.0" version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
@@ -952,9 +944,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.15" version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1279,9 +1271,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]] [[package]]
name = "icu_provider" name = "icu_provider"
version = "2.3.0" version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"icu_locale_core", "icu_locale_core",
@@ -1418,9 +1410,9 @@ dependencies = [
[[package]] [[package]]
name = "jmap-tools" name = "jmap-tools"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "896b3268cc668e6129bb7cf6f70e62f5454ca426a1b3b0f98ba23e9b58881eb7" checksum = "5bdcd368dcd58a5efaac10c142fb8cddb4d2949af1297b624df3ff0db4cbff54"
dependencies = [ dependencies = [
"hashify", "hashify",
"serde", "serde",
@@ -1543,21 +1535,21 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.33" version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "mail-builder" name = "mail-builder"
version = "0.4.4" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3"
[[package]] [[package]]
name = "mail-parser" name = "mail-parser"
version = "0.11.6" version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4084ec5c2f90b341d0c70990e92a23b128f75ca14fc1dd5edd8fd5c9b417da4d" checksum = "0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5"
dependencies = [ dependencies = [
"hashify", "hashify",
] ]
@@ -1916,6 +1908,21 @@ dependencies = [
"prost", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -2003,18 +2010,18 @@ dependencies = [
[[package]] [[package]]
name = "ref-cast" name = "ref-cast"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [ dependencies = [
"ref-cast-impl", "ref-cast-impl",
] ]
[[package]] [[package]]
name = "ref-cast-impl" name = "ref-cast-impl"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2183,9 +2190,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.14" version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"ring", "ring",
@@ -2871,7 +2878,6 @@ dependencies = [
"serde_json", "serde_json",
"ureq-proto", "ureq-proto",
"utf8-zero", "utf8-zero",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -2919,16 +2925,16 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.24.1" version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc"
dependencies = [ dependencies = [
"sha1_smol", "sha1_smol",
] ]
[[package]] [[package]]
name = "vandelay" name = "vandelay"
version = "1.0.8" version = "1.0.9"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"blake3", "blake3",
@@ -2942,6 +2948,7 @@ dependencies = [
"mail-parser", "mail-parser",
"mockito", "mockito",
"num_cpus", "num_cpus",
"psl",
"quick-xml", "quick-xml",
"regex", "regex",
"rusqlite", "rusqlite",
@@ -3067,15 +3074,6 @@ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -3353,9 +3351,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec" name = "zerovec"
version = "0.11.7" version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [ dependencies = [
"yoke", "yoke",
"zerofrom", "zerofrom",
@@ -3364,9 +3362,9 @@ dependencies = [
[[package]] [[package]]
name = "zerovec-derive" name = "zerovec-derive"
version = "0.11.4" version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+2 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "vandelay" name = "vandelay"
description = "JMAP account migration utility" description = "JMAP account migration utility"
version = "1.0.8" version = "1.0.9"
authors = ["Stalwart Labs LLC <[email protected]>"] authors = ["Stalwart Labs LLC <[email protected]>"]
license = "Apache-2.0 OR MIT" license = "Apache-2.0 OR MIT"
repository = "https://github.com/stalwartlabs/vandelay" repository = "https://github.com/stalwartlabs/vandelay"
@@ -46,6 +46,7 @@ flate2 = "1.1.9"
quick-xml = "0.41" quick-xml = "0.41"
chrono = { version = "0.4", default-features = false, features = ["std"] } chrono = { version = "0.4", default-features = false, features = ["std"] }
chrono-tz = { version = "0.10", default-features = false } chrono-tz = { version = "0.10", default-features = false }
psl = "2.1.226"
[dev-dependencies] [dev-dependencies]
mockito = "1.7.2" mockito = "1.7.2"
+1 -1
View File
@@ -127,7 +127,7 @@ fn try_treat_url_as_home_or_collection(
let collections = match propfind_collections(client, kind, url, &body) { let collections = match propfind_collections(client, kind, url, &body) {
Ok(c) => c, Ok(c) => c,
Err(DiscoveryError::Transport(JmapError::HttpStatus { status, .. })) Err(DiscoveryError::Transport(JmapError::HttpStatus { status, .. }))
if (400..500).contains(&status) => if (400..600).contains(&status) =>
{ {
return Ok(None); return Ok(None);
} }
+112
View File
@@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<i64>,
) -> rusqlite::Result<bool> {
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());
}
}
+1
View File
@@ -6,6 +6,7 @@
pub mod blobs; pub mod blobs;
pub mod dav_ids; pub mod dav_ids;
pub mod defaults;
pub mod exchange_ews_ids; pub mod exchange_ews_ids;
pub mod exchange_graph_ids; pub mod exchange_graph_ids;
pub mod ids; pub mod ids;
+200
View File
@@ -0,0 +1,200 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<String> {
let candidate = normalise(raw)?;
is_addr_spec(&candidate).then_some(candidate)
}
pub fn extract_smtp_address(raw: &str) -> Option<String> {
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<String> {
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("[email protected]").as_deref(),
Some("[email protected]")
);
assert_eq!(
as_smtp_address(" [email protected] ").as_deref(),
Some("[email protected]")
);
}
#[test]
fn internal_only_tlds_are_kept_when_the_server_says_smtp() {
assert_eq!(
as_smtp_address("[email protected]").as_deref(),
Some("[email protected]")
);
assert!(
extract_smtp_address("[email protected]").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:[email protected]").as_deref(),
Some("[email protected]")
);
assert_eq!(
as_smtp_address("mailto:[email protected]").as_deref(),
Some("[email protected]")
);
assert_eq!(
as_smtp_address("Jane Doe <[email protected]>").as_deref(),
Some("[email protected]")
);
assert_eq!(
extract_smtp_address("SMTP:[email protected]").as_deref(),
Some("[email protected]")
);
}
#[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 [email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"ü[email protected]",
] {
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("[email protected]").is_some());
assert!(extract_smtp_address("[email protected]").is_some());
assert!(extract_smtp_address("[email protected]").is_none());
assert!(as_smtp_address("[email protected]").is_some());
}
}
+130
View File
@@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
const END_OF_DAY: &str = "23:59:59";
pub fn recurrence_until(raw: &str) -> Option<String> {
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<String> {
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<String> {
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);
}
}
+2
View File
@@ -4,5 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 OR MIT * SPDX-License-Identifier: Apache-2.0 OR MIT
*/ */
pub mod address;
pub mod date;
pub mod jscalendar; pub mod jscalendar;
pub mod tz; pub mod tz;
+85 -2
View File
@@ -6,6 +6,7 @@
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use crate::exchange::address::{as_smtp_address, extract_smtp_address};
use crate::exchange::jscalendar::{ use crate::exchange::jscalendar::{
drop_calendar_address_dependents, is_override_ignored, synthetic_attendee_address, drop_calendar_address_dependents, is_override_ignored, synthetic_attendee_address,
}; };
@@ -245,8 +246,13 @@ fn resolve_calendar_address(
name: Option<&str>, name: Option<&str>,
) -> Option<(String, Option<String>)> { ) -> Option<(String, Option<String>)> {
if let Some(addr) = address.filter(|a| !a.trim().is_empty()) { if let Some(addr) = address.filter(|a| !a.trim().is_empty()) {
if is_smtp_routing(routing_type) { let smtp = if is_smtp_routing(routing_type) {
return Some((format!("mailto:{addr}"), Some(addr.to_owned()))); 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)); return Some((synthetic_attendee_address(addr), None));
} }
@@ -868,6 +874,83 @@ mod tests {
assert_eq!(v["showWithoutTime"], true); 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("[email protected]".to_owned()),
organizer_routing_type: Some("EX".to_owned()),
required_attendees: vec![crate::exchange_ews::parse::RawAttendee {
email: Some("SMTP:[email protected]".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:[email protected]",
"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:[email protected]"),
"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] #[test]
fn attendees_get_required_or_optional_role() { fn attendees_get_required_or_optional_role() {
let raw = CalendarItemRaw { let raw = CalendarItemRaw {
+32 -6
View File
@@ -6,6 +6,7 @@
use serde_json::{Map, Value, json}; use serde_json::{Map, Value, json};
use crate::exchange::date::recurrence_until;
use crate::exchange_ews::parse::{RawRecurrence, RecurrencePattern, RecurrenceRange}; use crate::exchange_ews::parse::{RawRecurrence, RecurrencePattern, RecurrenceRange};
pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> { pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
@@ -92,14 +93,10 @@ pub fn to_jscalendar_rule(raw: &RawRecurrence) -> Option<Value> {
match raw.range.as_ref() { match raw.range.as_ref() {
Some(RecurrenceRange::NoEnd { .. }) | None => {} Some(RecurrenceRange::NoEnd { .. }) | None => {}
Some(RecurrenceRange::EndDate { end_date, .. }) => { Some(RecurrenceRange::EndDate { end_date, .. }) => {
let trimmed = end_date.trim().trim_end_matches('Z'); if let Some(local) = recurrence_until(end_date) {
let local = if trimmed.contains('T') {
trimmed.to_owned()
} else {
format!("{trimmed}T23:59:59")
};
rule.insert("until".to_owned(), Value::String(local)); rule.insert("until".to_owned(), Value::String(local));
} }
}
Some(RecurrenceRange::Numbered { Some(RecurrenceRange::Numbered {
number_of_occurrences, number_of_occurrences,
.. ..
@@ -287,6 +284,35 @@ mod tests {
assert_eq!(rule["byMonthDay"][0], 1); 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] #[test]
fn no_end_range_emits_no_until_or_count() { fn no_end_range_emits_no_until_or_count() {
let raw = RawRecurrence { let raw = RawRecurrence {
+63 -2
View File
@@ -6,6 +6,7 @@
use serde_json::{Map, Value, json}; 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::jscalendar::{drop_calendar_address_dependents, synthetic_attendee_address};
use crate::exchange::tz::resolve_to_iana; use crate::exchange::tz::resolve_to_iana;
use crate::exchange_graph::error::GraphError; use crate::exchange_graph::error::GraphError;
@@ -356,8 +357,10 @@ fn non_empty(email: &Value, key: &str) -> Option<String> {
fn resolve_calendar_address(email: &Value) -> Option<(String, Option<String>)> { fn resolve_calendar_address(email: &Value) -> Option<(String, Option<String>)> {
if let Some(addr) = non_empty(email, "address") { if let Some(addr) = non_empty(email, "address") {
let cal_addr = format!("mailto:{addr}"); if let Some(smtp) = as_smtp_address(&addr).or_else(|| extract_smtp_address(&addr)) {
return Some((cal_addr, Some(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)) 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); 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:[email protected]"},
"type": "required",
"status": {"response": "none"}
},
{
"emailAddress": {"name": "Eve", "address": "Eve <[email protected]>"},
"type": "required",
"status": {"response": "none"}
}
]);
let conv = convert_event(&v, None).unwrap();
assert_eq!(
conv.data["participants"]["att-1"]["calendarAddress"],
"mailto:[email protected]"
);
assert_eq!(
conv.data["participants"]["att-1"]["email"],
"[email protected]"
);
assert_eq!(
conv.data["participants"]["att-2"]["calendarAddress"],
"mailto:[email protected]"
);
}
#[test] #[test]
fn a_name_only_attendee_gets_a_stable_synthetic_calendar_address() { fn a_name_only_attendee_gets_a_stable_synthetic_calendar_address() {
let mut v = sample(); let mut v = sample();
+6 -6
View File
@@ -6,6 +6,7 @@
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::exchange::date::recurrence_until;
use crate::exchange_graph::error::GraphError; use crate::exchange_graph::error::GraphError;
pub fn convert_patterned_recurrence(pr: &Value) -> Result<Value, GraphError> { pub fn convert_patterned_recurrence(pr: &Value) -> Result<Value, GraphError> {
@@ -80,12 +81,11 @@ pub fn convert_patterned_recurrence_rule(
let range_type = range.get("type").and_then(Value::as_str).unwrap_or("noEnd"); let range_type = range.get("type").and_then(Value::as_str).unwrap_or("noEnd");
match range_type { match range_type {
"endDate" => { "endDate" => {
if let Some(end) = range.get("endDate").and_then(Value::as_str) { if let Some(local) = range
let local = if end.contains('T') { .get("endDate")
end.to_owned() .and_then(Value::as_str)
} else { .and_then(recurrence_until)
format!("{end}T23:59:59") {
};
rule.insert("until".to_owned(), Value::from(local)); rule.insert("until".to_owned(), Value::from(local));
} }
} }
+46 -1
View File
@@ -33,8 +33,11 @@ pub fn decode_mailbox_name_with(input: &str, utf8_accept: bool) -> Result<String
let mut i = 0; let mut i = 0;
while i < bytes.len() { while i < bytes.len() {
if bytes[i] != b'&' { if bytes[i] != b'&' {
out.push(bytes[i] as char); let start = i;
while i < bytes.len() && bytes[i] != b'&' {
i += 1; i += 1;
}
out.push_str(&input[start..i]);
continue; continue;
} }
if i + 1 < bytes.len() && bytes[i + 1] == b'-' { if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
@@ -65,6 +68,16 @@ pub fn decode_mailbox_name_with(input: &str, utf8_accept: bool) -> Result<String
Ok(out) Ok(out)
} }
pub fn alternate_mailbox_name(input: &str, utf8_accept: bool) -> Option<String> {
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 { pub fn encode_mailbox_name(input: &str) -> String {
encode_mailbox_name_with(input, false) encode_mailbox_name_with(input, false)
} }
@@ -221,6 +234,38 @@ mod tests {
assert_eq!(canonicalise_inbox("Inbox/Sub"), "Inbox/Sub"); 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] #[test]
fn utf8_accept_skips_mutf7_decode_so_ampersand_passes_through() { fn utf8_accept_skips_mutf7_decode_so_ampersand_passes_through() {
assert_eq!(decode_mailbox_name_with("R&D", true).unwrap(), "R&D"); assert_eq!(decode_mailbox_name_with("R&D", true).unwrap(), "R&D");
+42 -4
View File
@@ -533,23 +533,26 @@ impl<'r, R: BufRead> Parser<'r, R> {
fn parse_quoted(&mut self) -> Result<Value, ImapError> { fn parse_quoted(&mut self) -> Result<Value, ImapError> {
self.expect(b'"')?; self.expect(b'"')?;
let mut s = String::new(); let mut raw: Vec<u8> = Vec::new();
while let Some(b) = self.peek() { while let Some(b) = self.peek() {
match b { match b {
b'"' => { b'"' => {
self.bump(); 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'\\' => { b'\\' => {
self.bump(); self.bump();
let next = self let next = self
.peek() .peek()
.ok_or_else(|| ImapError::Parse("trailing \\ in quoted".into()))?; .ok_or_else(|| ImapError::Parse("trailing \\ in quoted".into()))?;
s.push(next as char); raw.push(next);
self.bump(); self.bump();
} }
_ => { _ => {
s.push(b as char); raw.push(b);
self.bump(); 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<u8> = 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] #[test]
fn untagged_list_with_special_use() { fn untagged_list_with_special_use() {
let r = parse(b"* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\r\n"); let r = parse(b"* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\r\n");
+49
View File
@@ -39,9 +39,58 @@ where
Ok(Option::<bool>::deserialize(d)?.unwrap_or(true)) Ok(Option::<bool>::deserialize(d)?.unwrap_or(true))
} }
pub fn lenient_utc_date<'de, D>(d: D) -> Result<Option<UtcDate>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(d)?
.as_deref()
.and_then(parse_utc_date))
}
pub fn parse_utc_date(raw: &str) -> Option<UtcDate> {
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EmailAddress { pub struct EmailAddress {
pub name: Option<String>, pub name: Option<String>,
pub email: String, 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());
}
}
+8 -2
View File
@@ -7,6 +7,7 @@
use indexmap::IndexMap; use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::common::lenient_utc_date;
use super::{JmapId, UtcDate}; use super::{JmapId, UtcDate};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -17,8 +18,13 @@ pub struct Email {
pub blob_id: JmapId, pub blob_id: JmapId,
#[serde(with = "time::serde::rfc3339")] #[serde(
pub received_at: UtcDate, default,
deserialize_with = "lenient_utc_date",
serialize_with = "time::serde::rfc3339::option::serialize",
skip_serializing_if = "Option::is_none"
)]
pub received_at: Option<UtcDate>,
pub mailbox_ids: IndexMap<JmapId, bool>, pub mailbox_ids: IndexMap<JmapId, bool>,
+14 -3
View File
@@ -4,10 +4,9 @@
* SPDX-License-Identifier: Apache-2.0 OR MIT * SPDX-License-Identifier: Apache-2.0 OR MIT
*/ */
use serde::{Deserialize, Serialize};
use super::common::bool_or_true; use super::common::bool_or_true;
use super::{JmapId, UtcDate}; use super::{JmapId, UtcDate};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -18,7 +17,8 @@ pub struct FileNode {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<JmapId>, pub parent_id: Option<JmapId>,
pub node_type: NodeType, #[serde(default, skip_serializing_if = "Option::is_none")]
pub node_type: Option<NodeType>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_id: Option<JmapId>, pub blob_id: Option<JmapId>,
@@ -48,6 +48,17 @@ pub struct FileNode {
pub role: Option<String>, pub role: Option<String>,
} }
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum NodeType { pub enum NodeType {
+15 -1
View File
@@ -48,6 +48,9 @@ pub fn reconcile(
}; };
let targets = target_get_all(net, ty).map_err(Error::from)?; 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 tmatched = std::collections::HashSet::new();
let mut to_create: Vec<(i64, bool)> = Vec::new(); let mut to_create: Vec<(i64, bool)> = Vec::new();
@@ -99,18 +102,29 @@ pub fn reconcile(
maps.insert(ty, local, JmapId(id.clone())); maps.insert(ty, local, JmapId(id.clone()));
counts.created += 1; counts.created += 1;
if to_create.iter().any(|(l, d)| *l == local && *d) { if to_create.iter().any(|(l, d)| *l == local && *d) {
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(); let mut req = crate::jmap::request::Request::new();
req.call( req.call(
format!("{}/set", ty.jmap_name()), format!("{}/set", ty.jmap_name()),
json!({ "accountId": net.account, "onSuccessSetIsDefault": id }), json!({ "accountId": net.account, "onSuccessSetIsDefault": id }),
"d", "d",
); );
if let Err(e) = req.send(&net.client, &net.api) { match req.send(&net.client, &net.api) {
Ok(_) => default_claimed = true,
Err(e) => {
logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name())); logger.warn(&format!("{} isDefault not set: {e}", ty.jmap_name()));
} }
} }
} }
} }
}
}
for (cid, err) in &outcome.not_created { for (cid, err) in &outcome.not_created {
logger.warn(&format!("{} {cid} not created: {err}", ty.jmap_name())); logger.warn(&format!("{} {cid} not created: {err}", ty.jmap_name()));
counts.failed += 1; counts.failed += 1;
+1 -1
View File
@@ -442,7 +442,7 @@ fn build_create(
.map_err(Error::from)?; .map_err(Error::from)?;
let mut wire = fnrow.wire; let mut wire = fnrow.wire;
if let (crate::jmap::wire::file_node::NodeType::File, Some(blob_local)) = 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 let ct = wire
.media_type .media_type
+30 -5
View File
@@ -20,7 +20,7 @@ use crate::error::Error;
use crate::imap::client::{ConnectMode, ImapClient}; use crate::imap::client::{ConnectMode, ImapClient};
use crate::imap::command; use crate::imap::command;
use crate::imap::error::ImapError; 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::response::{NamespaceEntry, Untagged};
use crate::imap::retry::{ use crate::imap::retry::{
BackoffState, Disposition, RetryPolicy, classify, is_negotiation_failure, 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<crate::imap::CollectedResponse, Error> {
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<F, T>( pub(super) fn call_with_retry<F, T>(
client: &mut ImapClient, client: &mut ImapClient,
ctx: &ControlCtx, ctx: &ControlCtx,
@@ -713,8 +740,7 @@ fn reconcile_folder(
} }
} }
let wire_name = encode_mailbox_name_with(&folder.name, client.utf8_accept()); let resp = select_folder(client, control_ctx, &folder.name)?;
let resp = control_run_collect(client, control_ctx, &command::select(&wire_name))?;
let mut uidvalidity: u32 = 0; let mut uidvalidity: u32 = 0;
let mut uidnext: u32 = 0; let mut uidnext: u32 = 0;
for u in &resp.untagged { for u in &resp.untagged {
@@ -1123,8 +1149,7 @@ fn dry_run_summary(
} }
for folder in folders { for folder in folders {
let wire = encode_mailbox_name_with(&folder.name, client.utf8_accept()); let select_resp = match select_folder(client, control_ctx, &folder.name) {
let select_resp = match control_run_collect(client, control_ctx, &command::select(&wire)) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
log_at( log_at(
+11 -3
View File
@@ -13,7 +13,7 @@ use crossbeam_channel::{Receiver, Sender, unbounded};
use crate::imap::client::{ConnectMode, ImapClient}; use crate::imap::client::{ConnectMode, ImapClient};
use crate::imap::command; use crate::imap::command;
use crate::imap::error::ImapError; 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::response::Untagged;
use crate::imap::retry::{BackoffState, Disposition, RetryPolicy, classify}; use crate::imap::retry::{BackoffState, Disposition, RetryPolicy, classify};
use crate::imap::transport::Connector; use crate::imap::transport::Connector;
@@ -231,8 +231,16 @@ fn run_one_job(
event_tx: &Sender<FetchEvent>, event_tx: &Sender<FetchEvent>,
) -> Result<(), ImapError> { ) -> Result<(), ImapError> {
if current_folder.as_deref() != Some(job.folder.as_str()) { if current_folder.as_deref() != Some(job.folder.as_str()) {
let wire = encode_mailbox_name_with(&job.folder, client.utf8_accept()); let utf8_accept = client.utf8_accept();
client.run_collect(&command::select(&wire))?; 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()); *current_folder = Some(job.folder.clone());
} }
let set = command::format_uid_set(&job.uids, true); let set = command::format_uid_set(&job.uids, true);
+23 -7
View File
@@ -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::request::{get_all, get_changes, get_objects, get_state, query_all_ids};
use crate::jmap::session::{Limits, Session}; use crate::jmap::session::{Limits, Session};
use crate::jmap::wire::JmapId; use crate::jmap::wire::JmapId;
use crate::jmap::wire::common::parse_utc_date;
use crate::jmap::wire::email::Email; use crate::jmap::wire::email::Email;
use crate::jmap::wire::file_node::{FileNode, NodeType}; use crate::jmap::wire::file_node::{FileNode, NodeType};
use crate::jmap::wire::sieve_script::SieveScript; use crate::jmap::wire::sieve_script::SieveScript;
@@ -478,7 +479,7 @@ fn insert_objects(
continue; continue;
} }
}; };
match insert_one(&tx, ty, source_id, obj, &blobs) { match insert_one(&tx, ty, source_id, obj, &blobs, logger) {
Ok(local_id) => { Ok(local_id) => {
db::ids::insert(&tx, source_id, ty, &jmap_id, local_id) db::ids::insert(&tx, source_id, ty, &jmap_id, local_id)
.map_err(|e| Error::Partial(e.to_string()))?; .map_err(|e| Error::Partial(e.to_string()))?;
@@ -649,6 +650,7 @@ fn insert_one(
source_id: i64, source_id: i64,
obj: &Value, obj: &Value,
blobs: &HashMap<String, Vec<u8>>, blobs: &HashMap<String, Vec<u8>>,
logger: &Logger,
) -> Result<i64, JmapError> { ) -> Result<i64, JmapError> {
let resolver = DbResolver { conn, source_id }; let resolver = DbResolver { conn, source_id };
match ty { match ty {
@@ -673,14 +675,28 @@ fn insert_one(
mapping::insert_participant_identity(conn, &w) mapping::insert_participant_identity(conn, &w)
} }
ObjectType::Email => { ObjectType::Email => {
let w: Email = serde_json::from_value(obj.clone())?; let mut w: Email = serde_json::from_value(obj.clone())?;
let data = blobs let data = blobs
.get(&w.blob_id.0) .get(&w.blob_id.0)
.ok_or_else(|| JmapError::malformed("email blob missing"))?; .ok_or_else(|| JmapError::malformed("email blob missing"))?;
let blob_local = db::blobs::intern_blob(conn, data)?; let blob_local = db::blobs::intern_blob(conn, data)?;
let mm = crate::sync::keys::index_to_json( let (index, date_header) = crate::sync::emailmeta::email_meta_from_blob(data);
&crate::sync::emailmeta::email_index_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("<missing>");
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) mapping::insert_email(conn, &w, blob_local, &mm, &resolver)
} }
ObjectType::SieveScript => { ObjectType::SieveScript => {
@@ -693,7 +709,7 @@ fn insert_one(
} }
ObjectType::FileNode => { ObjectType::FileNode => {
let w: FileNode = serde_json::from_value(obj.clone())?; 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)) => { (NodeType::File, Some(b)) => {
let data = blobs let data = blobs
.get(&b.0) .get(&b.0)
@@ -934,7 +950,7 @@ fn update_one(
} }
ObjectType::FileNode => { ObjectType::FileNode => {
let w: FileNode = serde_json::from_value(obj.clone())?; 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)) => { (NodeType::File, Some(b)) => {
let data = blobs let data = blobs
.get(&b.0) .get(&b.0)
+29 -10
View File
@@ -8,6 +8,7 @@ use indexmap::IndexMap;
use rusqlite::{Connection, Row, params}; use rusqlite::{Connection, Row, params};
use serde_json::{Map, Value}; 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::blob::{BlobWalkError, InlineShape, import_blob_ids, inline_blob_data_uris};
use crate::jmap::error::JmapError; use crate::jmap::error::JmapError;
use crate::jmap::wire::JmapId; use crate::jmap::wire::JmapId;
@@ -168,7 +169,7 @@ pub fn insert_email(
VALUES (?1, ?2, ?3, ?4, ?5)", VALUES (?1, ?2, ?3, ?4, ?5)",
params![ params![
blob_local_id, blob_local_id,
format_utc(&wire.received_at)?, format_utc(&wire.received_at.unwrap_or(time::OffsetDateTime::UNIX_EPOCH))?,
id_array_json(&mailbox_locals), id_array_json(&mailbox_locals),
Value::Array(keywords.iter().map(|k| Value::from(k.as_str())).collect()).to_string(), Value::Array(keywords.iter().map(|k| Value::from(k.as_str())).collect()).to_string(),
message_match 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 const SIEVE_SELECT: &str = "SELECT id, name, is_active, blob_id FROM sieve_scripts";
pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result<i64, JmapError> { pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result<i64, JmapError> {
let is_default = unique_default(conn, ObjectType::AddressBook, wire.is_default, None)?;
conn.execute( conn.execute(
"INSERT INTO address_books (name, description, sort_order, is_default, is_subscribed) "INSERT INTO address_books (name, description, sort_order, is_default, is_subscribed)
VALUES (?1, ?2, ?3, ?4, ?5)", VALUES (?1, ?2, ?3, ?4, ?5)",
@@ -255,7 +257,7 @@ pub fn insert_address_book(conn: &Connection, wire: &AddressBook) -> Result<i64,
wire.name, wire.name,
wire.description, wire.description,
wire.sort_order, wire.sort_order,
wire.is_default as i64, is_default as i64,
wire.is_subscribed as i64 wire.is_subscribed as i64
], ],
)?; )?;
@@ -266,6 +268,7 @@ pub const ADDRESS_BOOK_SELECT: &str =
"SELECT id, name, description, sort_order, is_default, is_subscribed FROM address_books"; "SELECT id, name, description, sort_order, is_default, is_subscribed FROM address_books";
pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result<i64, JmapError> { pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result<i64, JmapError> {
let is_default = unique_default(conn, ObjectType::Calendar, wire.is_default, None)?;
conn.execute( conn.execute(
"INSERT INTO calendars (name, description, color, sort_order, is_subscribed, is_visible, "INSERT INTO calendars (name, description, color, sort_order, is_subscribed, is_visible,
is_default, include_in_availability, default_alerts_with_time, is_default, include_in_availability, default_alerts_with_time,
@@ -278,7 +281,7 @@ pub fn insert_calendar(conn: &Connection, wire: &Calendar) -> Result<i64, JmapEr
wire.sort_order, wire.sort_order,
wire.is_subscribed as i64, wire.is_subscribed as i64,
wire.is_visible as i64, wire.is_visible as i64,
wire.is_default as i64, is_default as i64,
wire.include_in_availability, wire.include_in_availability,
opt_json(&wire.default_alerts_with_time)?, opt_json(&wire.default_alerts_with_time)?,
opt_json(&wire.default_alerts_without_time)?, opt_json(&wire.default_alerts_without_time)?,
@@ -296,10 +299,11 @@ pub fn insert_participant_identity(
conn: &Connection, conn: &Connection,
wire: &ParticipantIdentity, wire: &ParticipantIdentity,
) -> Result<i64, JmapError> { ) -> Result<i64, JmapError> {
let is_default = unique_default(conn, ObjectType::ParticipantIdentity, wire.is_default, None)?;
conn.execute( conn.execute(
"INSERT INTO participant_identities (name, calendar_address, is_default) "INSERT INTO participant_identities (name, calendar_address, is_default)
VALUES (?1, ?2, ?3)", 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()) Ok(conn.last_insert_rowid())
} }
@@ -314,7 +318,7 @@ pub fn insert_file_node(
resolver: &impl LocalResolver, resolver: &impl LocalResolver,
) -> Result<i64, JmapError> { ) -> Result<i64, JmapError> {
let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id); 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() .as_str()
.unwrap_or("file") .unwrap_or("file")
.to_owned(); .to_owned();
@@ -495,6 +499,12 @@ pub fn update_address_book(
local_id: i64, local_id: i64,
wire: &AddressBook, wire: &AddressBook,
) -> Result<bool, JmapError> { ) -> Result<bool, JmapError> {
let is_default = unique_default(
conn,
ObjectType::AddressBook,
wire.is_default,
Some(local_id),
)?;
let n = conn.execute( let n = conn.execute(
"UPDATE address_books SET name = ?1, description = ?2, sort_order = ?3, is_default = ?4, "UPDATE address_books SET name = ?1, description = ?2, sort_order = ?3, is_default = ?4,
is_subscribed = ?5 is_subscribed = ?5
@@ -504,7 +514,7 @@ pub fn update_address_book(
wire.name, wire.name,
wire.description, wire.description,
wire.sort_order, wire.sort_order,
wire.is_default as i64, is_default as i64,
wire.is_subscribed as i64, wire.is_subscribed as i64,
local_id local_id
], ],
@@ -517,6 +527,7 @@ pub fn update_calendar(
local_id: i64, local_id: i64,
wire: &Calendar, wire: &Calendar,
) -> Result<bool, JmapError> { ) -> Result<bool, JmapError> {
let is_default = unique_default(conn, ObjectType::Calendar, wire.is_default, Some(local_id))?;
let n = conn.execute( let n = conn.execute(
"UPDATE calendars SET name = ?1, description = ?2, color = ?3, sort_order = ?4, "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, 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.sort_order,
wire.is_subscribed as i64, wire.is_subscribed as i64,
wire.is_visible as i64, wire.is_visible as i64,
wire.is_default as i64, is_default as i64,
wire.include_in_availability, wire.include_in_availability,
opt_json(&wire.default_alerts_with_time)?, opt_json(&wire.default_alerts_with_time)?,
opt_json(&wire.default_alerts_without_time)?, opt_json(&wire.default_alerts_without_time)?,
@@ -549,13 +560,19 @@ pub fn update_participant_identity(
local_id: i64, local_id: i64,
wire: &ParticipantIdentity, wire: &ParticipantIdentity,
) -> Result<bool, JmapError> { ) -> Result<bool, JmapError> {
let is_default = unique_default(
conn,
ObjectType::ParticipantIdentity,
wire.is_default,
Some(local_id),
)?;
let n = conn.execute( let n = conn.execute(
"UPDATE participant_identities SET name = ?1, calendar_address = ?2, is_default = ?3 "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)", WHERE id = ?4 AND (name IS NOT ?1 OR calendar_address IS NOT ?2 OR is_default IS NOT ?3)",
params![ params![
wire.name, wire.name,
wire.calendar_address, wire.calendar_address,
wire.is_default as i64, is_default as i64,
local_id local_id
], ],
)?; )?;
@@ -570,7 +587,7 @@ pub fn update_file_node(
resolver: &impl LocalResolver, resolver: &impl LocalResolver,
) -> Result<bool, JmapError> { ) -> Result<bool, JmapError> {
let parent = opt_parent(resolver, ObjectType::FileNode, &wire.parent_id); 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() .as_str()
.unwrap_or("file") .unwrap_or("file")
.to_owned(); .to_owned();
@@ -828,7 +845,9 @@ pub fn row_to_file_node(
), ),
None => None, 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<Vec<String>> = from_opt_json(row.get::<_, Option<String>>(4)?)?; let target: Option<Vec<String>> = from_opt_json(row.get::<_, Option<String>>(4)?)?;
let blob_local_id: Option<i64> = row.get(3)?; let blob_local_id: Option<i64> = row.get(3)?;
Ok(FileNodeRow { Ok(FileNodeRow {
+80 -3
View File
@@ -16,7 +16,7 @@ use time::format_description::well_known::Rfc3339;
use crate::db; use crate::db;
use crate::sync::TypeCounts; 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 crate::sync::keys::index_to_json;
use super::keywords::{Translation, flags_from_filename, translate_flags, unique_id_from_filename}; 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 { if translation.has_trashed_flag && !ctx.include_deleted {
return Ok(None); 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 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 mailbox_ids = Value::Array(vec![Value::from(ctx.mailbox_local)]);
let keywords = keywords_json(&translation); let keywords = keywords_json(&translation);
tx.execute( tx.execute(
@@ -170,6 +171,37 @@ pub fn insert_new(
Ok(Some(local_id)) Ok(Some(local_id))
} }
pub fn delivery_time_from_filename(filename: &str) -> Option<u64> {
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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PresentOutcome { pub enum PresentOutcome {
Unchanged, Unchanged,
@@ -721,4 +753,49 @@ mod tests {
); );
tx.commit().unwrap(); 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)
);
}
} }
+15
View File
@@ -282,6 +282,21 @@ impl Cyrus {
}) })
} }
pub fn create_non_ascii_mailbox(
&self,
account: &Account,
wire_name: &str,
message: &[u8],
) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.create(wire_name)?;
client.subscribe(wire_name)?;
client.append_with_flags(wire_name, &[], message)?;
client.logout()?;
Ok(())
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> { pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?; let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?; client.login(&account.username, &account.password)?;
+15
View File
@@ -337,6 +337,21 @@ impl Dovecot {
}) })
} }
pub fn create_non_ascii_mailbox(
&self,
account: &Account,
wire_name: &str,
message: &[u8],
) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?;
client.create(wire_name)?;
client.subscribe(wire_name)?;
client.append_with_flags(wire_name, &[], message)?;
client.logout()?;
Ok(())
}
pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> { pub fn delete_first_inbox_message(&self, account: &Account) -> ContainerResult<()> {
let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?; let mut client = ImapSeed::connect(&self.imap.host, self.imap.port)?;
client.login(&account.username, &account.password)?; client.login(&account.username, &account.password)?;
+47
View File
@@ -318,3 +318,50 @@ fn cyrus_starts_seeds_and_imports() {
} }
fn _unused(_: &AccountSeed) {} fn _unused(_: &AccountSeed) {}
#[test]
#[ignore = "requires Docker"]
fn cyrus_non_ascii_mailbox_names_round_trip() {
let c = Cyrus::start().expect("cyrus start");
let account = c.accounts.first().expect("account").clone();
let mutf7_name = "Envoy&AOk-s";
let utf8_name = "Envoyés";
let message = b"From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Accents\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Accented folder probe.\r\n";
c.create_non_ascii_mailbox(&account, mutf7_name, message)
.expect("create accented mailbox");
let archive = tmp_archive("cyrus-non-ascii");
let summary =
import_imap::run(common(&archive), imap_config(&account, &c.imap)).expect("imap import");
assert!(
!summary.any_failed(),
"an accented folder name must not fail the import: {summary:?}"
);
let conn = open_archive(&archive);
let names: Vec<String> = {
let mut stmt = conn
.prepare("SELECT name FROM mailboxes ORDER BY name")
.unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(Result::unwrap)
.collect()
};
let mailbox_id = mailbox_id_by_name(&conn, utf8_name).unwrap_or_else(|| {
panic!("no mailbox named {utf8_name}; names must not be mojibake, got {names:?}")
});
let emails = emails_in_mailbox(&conn, mailbox_id);
assert_eq!(
emails, 1,
"the message inside the accented folder must be imported"
);
cleanup(&archive);
}
+47
View File
@@ -514,3 +514,50 @@ fn assert_received_at_rfc3339_ish(conn: &Connection) {
} }
fn _unused(_: &AccountSeed) {} fn _unused(_: &AccountSeed) {}
#[test]
#[ignore = "requires Docker"]
fn dovecot_non_ascii_mailbox_names_round_trip() {
let d = Dovecot::start().expect("dovecot start");
let account = d.accounts.first().expect("account").clone();
let mutf7_name = "Envoy&AOk-s";
let utf8_name = "Envoyés";
let message = b"From: [email protected]\r\n\
To: [email protected]\r\n\
Subject: Accents\r\n\
Message-ID: <[email protected]>\r\n\
Date: Wed, 01 Jan 2025 12:00:00 +0000\r\n\
\r\n\
Accented folder probe.\r\n";
d.create_non_ascii_mailbox(&account, mutf7_name, message)
.expect("create accented mailbox");
let archive = tmp_archive("dovecot-non-ascii");
let summary =
import_imap::run(common(&archive), imap_config(&account, &d.imap)).expect("imap import");
assert!(
!summary.any_failed(),
"an accented folder name must not fail the import: {summary:?}"
);
let conn = open_archive(&archive);
let names: Vec<String> = {
let mut stmt = conn
.prepare("SELECT name FROM mailboxes ORDER BY name")
.unwrap();
stmt.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(Result::unwrap)
.collect()
};
let mailbox_id = mailbox_id_by_name(&conn, utf8_name).unwrap_or_else(|| {
panic!("no mailbox named {utf8_name}; names must not be mojibake, got {names:?}")
});
let emails = emails_in_mailbox(&conn, mailbox_id);
assert_eq!(
emails, 1,
"the message inside the accented folder must be imported"
);
cleanup(&archive);
}
+63
View File
@@ -597,6 +597,69 @@ fn discovery_via_well_known_redirect_307() {
assert!(disc.home_set_url.ends_with("/dav/cal/u/")); 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] #[test]
fn discovery_when_server_omits_current_user_principal() { fn discovery_when_server_omits_current_user_principal() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
+72
View File
@@ -374,6 +374,78 @@ fn calendar_item_master_inlines_modified_and_deleted_occurrences() {
assert_eq!(item.deleted_occurrences.len(), 1); 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] #[test]
fn sync_folder_items_creates_updates_deletes_round_trip() { fn sync_folder_items_creates_updates_deletes_round_trip() {
let body = envelope(&format!( let body = envelope(&format!(
+178
View File
@@ -1751,3 +1751,181 @@ fn coordinator_authenticationfailed_yields_exit2_connection_error() {
assert!(matches!(err, vandelay::error::Error::Connection(_))); assert!(matches!(err, vandelay::error::Error::Connection(_)));
assert_eq!(err.exit_code(), 2); assert_eq!(err.exit_code(), 2);
} }
fn read_select_mailbox(conn: &mut MockConn) -> std::io::Result<(String, String)> {
let (tag, cmd) = conn.read_command()?;
let arg = cmd
.strip_prefix("SELECT ")
.unwrap_or_else(|| panic!("expected SELECT, got {cmd}"));
if arg.starts_with('{') {
let mut line = String::new();
conn.reader.read_line(&mut line)?;
Ok((tag, line.trim_end_matches(['\r', '\n']).to_owned()))
} else {
Ok((tag, arg.trim_matches('"').to_owned()))
}
}
const TURKISH_SENT: &str = "Gönderilmiş Postalar";
const FRENCH_SENT_UTF8: &str = "Envoyés";
const FRENCH_SENT_MUTF7: &str = "Envoy&AOk-s";
#[test]
fn utf8_accept_server_gets_the_folder_name_back_as_utf8() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 ENABLE UTF8=ACCEPT LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line(&format!("* LIST () \"/\" \"{TURKISH_SENT}\""))?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, _) = conn.read_command()?;
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, TURKISH_SENT,
"an ENABLEd UTF8=ACCEPT server must be sent the name as UTF-8"
);
write_select(conn, &tag, 900, 2, 1)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
conn.write_line("* SEARCH 1")?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
drain_until_close(conn);
Ok(())
});
let worker: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 ENABLE UTF8=ACCEPT LITERAL+ AUTH=PLAIN")?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, TURKISH_SENT,
"the fetch worker must agree with the coordinator"
);
write_select(conn, &tag, 900, 2, 1)?;
let (tag, _) = conn.read_command()?;
write_fetch_message(conn, 1, 1, MSG_BODY)?;
conn.write_line(&format!("{tag} OK FETCH done"))?;
drain_until_close(conn);
Ok(())
});
let server = MockImap::start_scripts(vec![control, worker]);
let archive = tempfile("utf8_accept_name");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.failed, 0, "summary={summary:?}");
assert_eq!(email.1.created, 1);
let conn = Connection::open(&archive).unwrap();
assert_eq!(
conn.query_row::<String, _, _>("SELECT name FROM mailboxes", [], |r| r.get(0))
.unwrap(),
TURKISH_SENT,
"the folder name is stored as UTF-8, not as Latin-1 mojibake"
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn mutf7_server_gets_the_folder_name_back_as_mutf7() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line(&format!("* LIST () \"/\" \"{FRENCH_SENT_MUTF7}\""))?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, _) = conn.read_command()?;
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, FRENCH_SENT_MUTF7,
"a server without UTF8=ACCEPT must be sent modified UTF-7"
);
write_select(conn, &tag, 901, 1, 0)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
conn.write_line("* SEARCH")?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
drain_until_close(conn);
Ok(())
});
let server = MockImap::start_scripts(vec![control, worker_idle_script("IMAP4rev2 LITERAL+")]);
let archive = tempfile("mutf7_name");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.failed, 0, "summary={summary:?}");
let conn = Connection::open(&archive).unwrap();
assert_eq!(
conn.query_row::<String, _, _>("SELECT name FROM mailboxes", [], |r| r.get(0))
.unwrap(),
FRENCH_SENT_UTF8
);
let _ = std::fs::remove_file(&archive);
}
#[test]
fn utf8_name_from_a_server_that_never_enabled_utf8_falls_back_on_select() {
let control: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "LIST \"\" \"*\"");
conn.write_line(&format!("* LIST () \"/\" \"{FRENCH_SENT_UTF8}\""))?;
conn.write_line(&format!("{tag} OK LIST done"))?;
let (tag, _) = conn.read_command()?;
conn.write_line(&format!("{tag} OK LSUB done"))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(name, FRENCH_SENT_MUTF7);
conn.write_line(&format!("{tag} NO [NONEXISTENT] Mailbox does not exist."))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(
name, FRENCH_SENT_UTF8,
"a refused modified UTF-7 name must be retried as UTF-8"
);
write_select(conn, &tag, 902, 2, 1)?;
let (tag, cmd) = conn.read_command()?;
assert_eq!(cmd, "UID SEARCH ALL");
conn.write_line("* SEARCH 1")?;
conn.write_line(&format!("{tag} OK SEARCH done"))?;
drain_until_close(conn);
Ok(())
});
let worker: Script = Box::new(|conn: &mut MockConn| -> std::io::Result<()> {
auth_preamble(conn, "IMAP4rev2 LITERAL+ AUTH=PLAIN")?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(name, FRENCH_SENT_MUTF7);
conn.write_line(&format!("{tag} NO [NONEXISTENT] Mailbox does not exist."))?;
let (tag, name) = read_select_mailbox(conn)?;
assert_eq!(name, FRENCH_SENT_UTF8);
write_select(conn, &tag, 902, 2, 1)?;
let (tag, _) = conn.read_command()?;
write_fetch_message(conn, 1, 1, MSG_BODY)?;
conn.write_line(&format!("{tag} OK FETCH done"))?;
drain_until_close(conn);
Ok(())
});
let server = MockImap::start_scripts(vec![control, worker]);
let archive = tempfile("utf8_no_enable");
let summary = run_import(&server, "alice", archive.clone(), |_| {}).expect("import");
let email = summary
.per_type
.iter()
.find(|(k, _)| *k == "email")
.unwrap();
assert_eq!(email.1.failed, 0, "summary={summary:?}");
assert_eq!(email.1.created, 1);
let conn = Connection::open(&archive).unwrap();
assert_eq!(
conn.query_row::<String, _, _>("SELECT name FROM mailboxes", [], |r| r.get(0))
.unwrap(),
FRENCH_SENT_UTF8
);
let _ = std::fs::remove_file(&archive);
}
+54
View File
@@ -752,3 +752,57 @@ fn malformed_message_yields_zero_message_match_but_imports() {
assert_eq!(inbox.len(), 2); assert_eq!(inbox.len(), 2);
let _ = fs::remove_file(&archive); let _ = fs::remove_file(&archive);
} }
#[test]
fn received_at_comes_from_the_filename_not_a_clobbered_mtime() {
let td = TempDir::new().unwrap();
let root = td.path();
ensure_maildir(root);
let with_stamp = write_message(
root,
"cur",
"1763065233.M1P1.host,S=120:2,S",
&rfc5322("stamped", "stamped", "body"),
);
let without_stamp = write_message(
root,
"cur",
"M2P2.host:2,S",
&rfc5322("unstamped", "unstamped", "body"),
);
let restored =
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000);
for p in [&with_stamp, &without_stamp] {
let f = fs::File::options().write(true).open(p).unwrap();
f.set_modified(restored).unwrap();
}
let archive = tmp_archive("received_at");
let summary = run(common(&archive), base_cfg(root)).expect("import");
assert!(!summary.any_failed());
let conn = Connection::open(&archive).unwrap();
let mut stmt = conn
.prepare(
"SELECT e.received_at FROM emails e
JOIN blobs b ON b.id = e.blob_id
WHERE CAST(b.data AS TEXT) LIKE ?1",
)
.unwrap();
let stamped: String = stmt
.query_row(rusqlite::params!["%<[email protected]>%"], |r| r.get(0))
.unwrap();
assert_eq!(
stamped, "2025-11-13T20:20:33Z",
"the maildir filename timestamp wins over a restored mtime"
);
let unstamped: String = stmt
.query_row(rusqlite::params!["%<[email protected]>%"], |r| r.get(0))
.unwrap();
assert_eq!(
unstamped, "2025-05-12T10:00:00Z",
"without a filename timestamp the message Date header is used"
);
drop(stmt);
let _ = fs::remove_file(&archive);
}
+386 -1
View File
@@ -1270,6 +1270,197 @@ fn import_removes_vanished_email_and_drops_cross_ref() {
let _ = std::fs::remove_file(&archive); 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] #[test]
fn import_missing_email_blob_is_skipped_and_counted_once() { fn import_missing_email_blob_is_skipped_and_counted_once() {
let mut server = mockito::Server::new(); 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); 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] #[test]
fn export_address_book_creates_only_missing() { fn export_address_book_creates_only_missing() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
@@ -3997,7 +4379,10 @@ fn export_email_server_unavailable_is_retried_not_dropped() {
failing.assert(); failing.assert();
succeeding.assert(); succeeding.assert();
up.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_eq!(email.failed, 0, "a retried message is not a failure");
assert!(!summary.any_failed()); assert!(!summary.any_failed());