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