diff --git a/.gitignore b/.gitignore index eac15d5..b52a849 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ target *.md !README.md !CHANGELOG.md -specs/ +.* +!.github diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f84fb..0d022d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [1.0.8] - 2026-08-15 + +### Added +- A warning when the session advertises `apiUrl`, `uploadUrl` or `downloadUrl` on a different origin than the one connected to, naming both origins (#29). +- The per-type report is printed when a run aborts. + +### Changed + +### Fixed +- A transient `serverUnavailable` from `Email/import` permanently dropped that message from the migration instead of retrying it. +- The Exchange Graph importer stored a whole converted event as a `recurrenceOverrides` value (#31). +- Exporting a ContactCard carrying a photo blob failed against Stalwart, and a CalendarEvent enclosure sent as a `blobId` was silently dropped by the server; the bytes of both are now inlined as a base64 `data:` URI (#30). +- A connection failure against the session-advertised `apiUrl` was reported as a per-type partial failure (exit 5, "consistent and resumable") instead of aborting (#29). +- Session `apiUrl`, `uploadUrl` and `downloadUrl` given as relative references were used unresolved (#29). + ## [1.0.7] - 2026-07-26 ### Added diff --git a/Cargo.lock b/Cargo.lock index 7104a07..849ba10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,18 +23,18 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -157,9 +157,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -180,9 +180,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -190,9 +190,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -250,6 +250,18 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -258,9 +270,9 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ "arrayref", "arrayvec", @@ -272,13 +284,13 @@ dependencies = [ [[package]] name = "bollard" -version = "0.20.2" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" dependencies = [ "async-stream", - "base64", - "bitflags", + "base64 0.22.1", + "bitflags 2.13.1", "bollard-buildkit-proto", "bollard-stubs", "bytes", @@ -296,7 +308,7 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.5", + "rand 0.10.2", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -304,7 +316,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror", "time", "tokio", "tokio-stream", @@ -317,9 +329,9 @@ dependencies = [ [[package]] name = "bollard-buildkit-proto" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +checksum = "06961b3a889aadf51ad938dfcd07a29e49bd77c6e25ed8c1021764ea888ec7bd" dependencies = [ "prost", "prost-types", @@ -330,11 +342,11 @@ dependencies = [ [[package]] name = "bollard-stubs" -version = "1.52.1-rc.29.1.3" +version = "1.53.1-rc.29.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" dependencies = [ - "base64", + "base64 0.22.1", "bollard-buildkit-proto", "bytes", "prost", @@ -367,9 +379,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calcard" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88c4c9063026b3a2361859739f0a4dde1f62ea7bf860f70b76a30924fff0a88" +checksum = "acf9612bef43974ff02c1813a7abc49e99ea55b369e6f57efa84d0c579c17e3c" dependencies = [ "ahash", "chrono", @@ -385,9 +397,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -395,12 +407,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -444,9 +450,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -454,9 +460,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -524,9 +530,9 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -634,6 +640,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "deranged" version = "0.5.8" @@ -645,13 +682,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -660,7 +697,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", ] @@ -749,9 +786,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -792,9 +829,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -807,9 +844,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -817,15 +854,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -834,38 +871,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1006,9 +1043,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1026,9 +1063,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1174,9 +1211,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1188,9 +1225,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1201,9 +1238,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1215,16 +1252,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1235,15 +1273,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1326,32 +1364,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "jmap-tools" -version = "0.1.6" +name = "jiff" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb13aa98c80fe11bd7fb170bb8485d5844a30a41398894a3adb5c1216d3c357" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jmap-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896b3268cc668e6129bb7cf6f70e62f5454ca426a1b3b0f98ba23e9b58881eb7" dependencies = [ "hashify", "serde", "serde_json", ] -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -1361,10 +1436,10 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys 0.4.1", + "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror", "walkdir", "windows-link", ] @@ -1382,15 +1457,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - [[package]] name = "jni-sys" version = "0.4.1" @@ -1422,9 +1488,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1439,9 +1505,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -1456,9 +1522,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1489,9 +1555,9 @@ checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" [[package]] name = "mail-parser" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47785d444be4d32c1709171c6219a90f667c0ad0ffe68b4b179e794f31f4f9e8" +checksum = "4084ec5c2f90b341d0c70990e92a23b128f75ca14fc1dd5edd8fd5c9b417da4d" dependencies = [ "hashify", ] @@ -1601,9 +1667,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1691,9 +1757,9 @@ dependencies = [ [[package]] name = "parse-display" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +checksum = "e78deb158fb1d73b29efb4b7e9b9860b78059c670de06bd28df8d0b458ded0eb" dependencies = [ "parse-display-derive", "regex", @@ -1702,9 +1768,9 @@ dependencies = [ [[package]] name = "parse-display-derive" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +checksum = "8e95a50d1084dab562913062c4c34bb204b68fc6ec38a1395909ff5aaaf4f10a" dependencies = [ "proc-macro2", "quote", @@ -1766,21 +1832,30 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1923,7 +1998,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -1960,9 +2035,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1996,16 +2071,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror", ] [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2035,7 +2110,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -2044,9 +2119,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -2079,27 +2154,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni 0.21.1", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - [[package]] name = "rustls-platform-verifier" version = "0.7.0" @@ -2108,7 +2162,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", - "jni 0.22.4", + "jni", "log", "once_cell", "rustls", @@ -2129,9 +2183,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -2183,9 +2237,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -2205,7 +2259,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -2296,18 +2350,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -2316,9 +2371,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", @@ -2511,9 +2566,9 @@ dependencies = [ [[package]] name = "testcontainers" -version = "0.27.3" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +checksum = "6e2bbe381afaaa58ea610c5fc3ffb2184063a32b3e358a179f0b4865dd59934a" dependencies = [ "astral-tokio-tar", "async-trait", @@ -2533,7 +2588,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.19", + "thiserror", "tokio", "tokio-stream", "tokio-util", @@ -2542,38 +2597,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -2582,9 +2617,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -2612,9 +2647,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2654,13 +2689,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2706,7 +2741,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -2820,31 +2855,32 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "cookie_store", "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", - "rustls-platform-verifier 0.6.2", + "rustls-platform-verifier", "serde", "serde_json", "ureq-proto", "utf8-zero", + "webpki-roots", ] [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", @@ -2883,18 +2919,18 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "sha1_smol", ] [[package]] name = "vandelay" -version = "1.0.7" +version = "1.0.8" dependencies = [ - "base64", + "base64 0.23.1", "blake3", "calcard", "chrono", @@ -2910,12 +2946,12 @@ dependencies = [ "regex", "rusqlite", "rustls", - "rustls-platform-verifier 0.7.0", + "rustls-platform-verifier", "serde", "serde_json", "tempfile", "testcontainers", - "thiserror 2.0.19", + "thiserror", "time", "ureq", "url", @@ -2969,9 +3005,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2982,9 +3018,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2992,9 +3028,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -3005,9 +3041,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -3031,6 +3067,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3121,22 +3166,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -3148,67 +3184,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3221,48 +3224,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3277,9 +3256,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xattr" @@ -3316,18 +3295,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -3363,9 +3342,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3374,9 +3353,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -3385,13 +3364,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 53654df..340b9b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "vandelay" description = "JMAP account migration utility" -version = "1.0.7" +version = "1.0.8" authors = ["Stalwart Labs LLC "] license = "Apache-2.0 OR MIT" repository = "https://github.com/stalwartlabs/vandelay" @@ -36,7 +36,7 @@ ureq = { version = "3.3.0", default-features = false, features = ["json", "gzip" clap = { version = "4.6.1", features = ["derive"] } crossbeam-channel = "0.5.15" num_cpus = "1.17.0" -base64 = "0.22.1" +base64 = "0.23" mail-parser = "0.11.3" calcard = "0.3" rustls = { version = "0.23.40", default-features = false, features = ["aws-lc-rs", "std", "tls12", "logging"] } @@ -50,7 +50,7 @@ chrono-tz = { version = "0.10", default-features = false } [dev-dependencies] mockito = "1.7.2" tempfile = "3.13" -testcontainers = { version = "0.27", features = ["blocking"] } +testcontainers = { version = "0.28", features = ["blocking"] } [profile.dist] inherits = "release" diff --git a/src/cli.rs b/src/cli.rs index 457da5c..422d4a5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,7 +12,7 @@ use regex::Regex; use crate::error::Error; use crate::exchange_ews::oauth::OAuthFlow; use crate::exchange_ews::types::MailboxKind; -use crate::exchange_graph::types::{EventBodyFormat, MailboxKind as GraphMailboxKind}; +use crate::exchange_graph::types::{EventBodyFormat, MailboxKind as GraphMailboxKind, Surfaces}; use crate::inspect::InspectConfig; use crate::jmap::account::AccountSelector; use crate::jmap::http::Auth; @@ -1132,7 +1132,7 @@ pub struct ExchangeGraphImportArgs { #[arg( long, value_name = "LIST", - help = "Comma-separated surface list (mail,calendar,contacts; default all)" + help = "Comma-separated surface list: mail | calendar | contacts (default: all three)" )] objects: Option, @@ -1203,11 +1203,12 @@ fn resolve_exchange_graph_import(args: ExchangeGraphImportArgs) -> Result Result Result { + let mut argv = vec![ + "vandelay", + "import", + "exchange-graph", + "--access-token", + "tok", + ]; + argv.extend_from_slice(objects); + argv.push("archive.sqlite"); + let cli = Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("clap rejected args: {e}")); + match cli.resolve()? { + Action::ImportExchangeGraph(_, config) => Ok(config), + _ => panic!("expected ImportExchangeGraph"), + } + } + + #[test] + fn graph_objects_contacts_selects_only_contacts() { + let _g = lock(); + let config = graph_config(&["--objects", "contacts"]).unwrap(); + assert_eq!( + config.surfaces, + Surfaces { + mail: false, + calendar: false, + contacts: true + } + ); + } + + #[test] + fn graph_objects_absent_selects_every_surface() { + let _g = lock(); + let config = graph_config(&[]).unwrap(); + assert_eq!(config.surfaces, Surfaces::ALL); + } + + #[test] + fn graph_objects_rejects_jmap_type_names() { + let _g = lock(); + match graph_config(&["--objects", "contactcard"]) { + Err(Error::Usage(m)) => { + assert!(m.contains("unknown surface: contactcard"), "msg was: {m}"); + assert!( + m.contains("valid: mail, calendar, contacts"), + "msg was: {m}" + ); + } + other => panic!("expected Usage error, got {:?}", other.map(|_| ())), + } + } } diff --git a/src/error.rs b/src/error.rs index f87af6f..d45e1f0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -43,6 +43,20 @@ impl From for Error { } impl Error { + pub fn aborts_run(&self) -> bool { + match self { + Error::Partial(_) => false, + Error::Usage(_) + | Error::Connection(_) + | Error::Account(_) + | Error::SourceChange(_) + | Error::PruneAborted + | Error::Unimplemented(_) + | Error::Db(_) + | Error::Io(_) => true, + } + } + pub fn exit_code(&self) -> i32 { match self { Error::Usage(_) => 1, @@ -74,4 +88,37 @@ mod tests { let io = Error::Io(std::io::Error::other("disk")); assert_eq!(io.exit_code(), 7); } + + #[test] + fn only_partial_is_a_per_unit_failure() { + assert!(!Error::Partial("one object".into()).aborts_run()); + } + + #[test] + fn whole_run_conditions_abort() { + assert!(Error::Connection("http status 404".into()).aborts_run()); + assert!(Error::Account("ambiguous".into()).aborts_run()); + assert!(Error::SourceChange("other account".into()).aborts_run()); + assert!(Error::Usage("bad flag".into()).aborts_run()); + assert!(Error::Unimplemented("x").aborts_run()); + assert!(Error::PruneAborted.aborts_run()); + assert!(Error::Db(OpenError::Sqlite(rusqlite::Error::QueryReturnedNoRows)).aborts_run()); + assert!(Error::Io(std::io::Error::other("disk")).aborts_run()); + } + + #[test] + fn aborting_errors_do_not_map_to_the_partial_exit_code() { + for e in [ + Error::Connection("x".into()), + Error::Account("x".into()), + Error::SourceChange("x".into()), + Error::Db(OpenError::Sqlite(rusqlite::Error::QueryReturnedNoRows)), + Error::Io(std::io::Error::other("disk")), + ] { + assert!(e.aborts_run(), "{e} must abort the run"); + assert_ne!(e.exit_code(), 5, "{e} must not report a partial failure"); + } + assert_eq!(Error::Connection("x".into()).exit_code(), 2); + assert_eq!(Error::Partial("x".into()).exit_code(), 5); + } } diff --git a/src/exchange/jscalendar.rs b/src/exchange/jscalendar.rs new file mode 100644 index 0000000..0dbf2bf --- /dev/null +++ b/src/exchange/jscalendar.rs @@ -0,0 +1,210 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: Apache-2.0 OR MIT + */ + +use serde_json::{Map, Value}; + +const IGNORED_EXACT: &[&str] = &[ + "@type", + "method", + "organizerCalendarAddress", + "privacy", + "prodId", + "recurrenceId", + "recurrenceIdTimeZone", + "sentBy", + "uid", +]; + +const IGNORED_FIRST_TOKEN: &[&str] = &["recurrenceOverrides", "recurrenceRule", "relatedTo"]; + +const CALENDAR_ADDRESS_DEPENDENTS: &[&str] = &[ + "calendarAddress", + "delegatedFrom", + "delegatedTo", + "email", + "expectReply", + "kind", + "memberOf", + "participationStatus", + "progress", + "roles", + "sentBy", +]; + +pub fn synthetic_attendee_address(identifier: &str) -> String { + format!( + "urn:x-vandelay:attendee:{}", + blake3::hash(identifier.as_bytes()).to_hex() + ) +} + +pub fn drop_calendar_address_dependents(participants: &mut Map) { + for participant in participants.values_mut() { + let Some(object) = participant.as_object_mut() else { + continue; + }; + for key in CALENDAR_ADDRESS_DEPENDENTS { + object.remove(*key); + } + } +} + +pub fn is_override_ignored(pointer: &str) -> bool { + if IGNORED_EXACT.contains(&pointer) || is_participant_calendar_address(pointer) { + return true; + } + let first = pointer.split('/').next().unwrap_or(pointer); + IGNORED_FIRST_TOKEN.contains(&first) +} + +fn is_participant_calendar_address(pointer: &str) -> bool { + let mut tokens = pointer.split('/'); + tokens.next() == Some("participants") + && tokens.next().is_some() + && tokens.next() == Some("calendarAddress") + && tokens.next().is_none() +} + +pub fn override_patch_from_event(event: &Value) -> Value { + let Some(object) = event.as_object() else { + return event.clone(); + }; + let patch: Map = object + .iter() + .filter(|(key, _)| !is_override_ignored(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + Value::Object(patch) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn exact_pointers_are_ignored() { + for pointer in [ + "@type", + "method", + "organizerCalendarAddress", + "privacy", + "prodId", + "recurrenceId", + "recurrenceIdTimeZone", + "sentBy", + "uid", + ] { + assert!(is_override_ignored(pointer), "{pointer} must be ignored"); + } + } + + #[test] + fn first_token_pointers_are_ignored_at_any_depth() { + assert!(is_override_ignored("recurrenceRule")); + assert!(is_override_ignored("recurrenceRule/frequency")); + assert!(is_override_ignored( + "recurrenceOverrides/2026-01-01T09:00:00" + )); + assert!(is_override_ignored("relatedTo/uid-1/relation")); + } + + #[test] + fn participant_calendar_address_is_ignored_only_at_its_own_depth() { + assert!(is_override_ignored("participants/att-1/calendarAddress")); + assert!(!is_override_ignored("participants")); + assert!(!is_override_ignored("participants/att-1")); + assert!(!is_override_ignored( + "participants/att-1/participationStatus" + )); + assert!(!is_override_ignored( + "participants/att-1/calendarAddress/extra" + )); + } + + #[test] + fn patchable_pointers_survive() { + for pointer in [ + "start", + "duration", + "title", + "excluded", + "participants", + "created", + "updated", + "status", + ] { + assert!(!is_override_ignored(pointer), "{pointer} must survive"); + } + } + + #[test] + fn an_event_is_reduced_to_a_patch() { + let event = json!({ + "@type": "Event", + "uid": "uid-1", + "prodId": "vandelay", + "privacy": "public", + "organizerCalendarAddress": "mailto:alice@example.com", + "recurrenceRule": {"frequency": "daily"}, + "start": "2026-03-04T09:00:00", + "duration": "PT1H", + "title": "Moved" + }); + let patch = override_patch_from_event(&event); + let patch = patch.as_object().expect("patch object"); + assert_eq!(patch.len(), 3); + assert_eq!(patch["start"], "2026-03-04T09:00:00"); + assert_eq!(patch["duration"], "PT1H"); + assert_eq!(patch["title"], "Moved"); + } + + #[test] + fn an_exclusion_patch_is_preserved() { + let patch = override_patch_from_event(&json!({"excluded": true})); + assert_eq!(patch, json!({"excluded": true})); + } + + #[test] + fn a_synthetic_address_is_stable_per_identifier_and_never_a_mailto() { + let addr = synthetic_attendee_address("Jane Doe"); + assert_eq!(addr, synthetic_attendee_address("Jane Doe")); + assert_ne!(addr, synthetic_attendee_address("John Doe")); + assert!(addr.starts_with("urn:x-vandelay:attendee:")); + assert!( + !addr.starts_with("mailto:"), + "export must never invite a fabricated address" + ); + } + + #[test] + fn calendar_address_dependents_are_dropped_and_identity_is_kept() { + let mut participants = json!({ + "1": { + "@type": "Participant", + "name": "Room 4", + "calendarAddress": "urn:x-vandelay:attendee:abc", + "email": "room4@example.com", + "roles": {"required": true}, + "kind": "resource", + "participationStatus": "accepted", + "expectReply": true, + "description": "Third floor" + } + }) + .as_object() + .expect("participants object") + .clone(); + drop_calendar_address_dependents(&mut participants); + let p = &participants["1"]; + assert_eq!(p["@type"], "Participant"); + assert_eq!(p["name"], "Room 4"); + assert_eq!(p["description"], "Third floor"); + for key in CALENDAR_ADDRESS_DEPENDENTS { + assert!(p.get(*key).is_none(), "{key} must be dropped"); + } + } +} diff --git a/src/exchange/mod.rs b/src/exchange/mod.rs index 0a3f097..9d7fe9c 100644 --- a/src/exchange/mod.rs +++ b/src/exchange/mod.rs @@ -4,4 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 OR MIT */ +pub mod jscalendar; pub mod tz; diff --git a/src/exchange_ews/calendar_map.rs b/src/exchange_ews/calendar_map.rs index ab610e7..08821c7 100644 --- a/src/exchange_ews/calendar_map.rs +++ b/src/exchange_ews/calendar_map.rs @@ -6,6 +6,9 @@ use serde_json::{Map, Value, json}; +use crate::exchange::jscalendar::{ + drop_calendar_address_dependents, is_override_ignored, synthetic_attendee_address, +}; use crate::exchange_ews::parse::{CalendarItemRaw, RawAttendee, RawOccurrence}; use crate::exchange_ews::recurrence::to_jscalendar_rule; use crate::exchange_ews::tz::resolve_to_iana; @@ -181,6 +184,9 @@ fn build_event_map(raw: &CalendarItemRaw, iana: Option<&str>) -> Map) -> Map String { - format!( - "urn:x-vandelay:attendee:{}", - blake3::hash(identifier.as_bytes()).to_hex() - ) -} - fn is_smtp_routing(routing_type: Option<&str>) -> bool { routing_type.is_none_or(|rt| rt.eq_ignore_ascii_case("SMTP")) } @@ -320,21 +319,11 @@ fn add_attendees( } } -const OVERRIDE_IGNORED_POINTERS: &[&str] = &[ - "@type", - "uid", - "recurrenceRule", - "recurrenceOverrides", - "recurrenceId", - "recurrenceIdTimeZone", - "method", - "organizerCalendarAddress", - "privacy", - "prodId", - "relatedTo", - "created", - "updated", -]; +const NOT_PATCHED_PER_OCCURRENCE: &[&str] = &["created", "updated"]; + +fn is_override_excluded(key: &str) -> bool { + is_override_ignored(key) || NOT_PATCHED_PER_OCCURRENCE.contains(&key) +} fn build_recurrence_overrides( base_event: &Map, @@ -388,7 +377,7 @@ fn override_patch( let mut patch = Map::new(); let inherited_start = Value::String(recurrence_id.to_owned()); for (k, v) in occ_event { - if OVERRIDE_IGNORED_POINTERS.contains(&k.as_str()) { + if is_override_excluded(k) { continue; } let baseline = if k == "start" { @@ -401,7 +390,7 @@ fn override_patch( } } for k in base_event.keys() { - if k == "start" || OVERRIDE_IGNORED_POINTERS.contains(&k.as_str()) { + if k == "start" || is_override_excluded(k) { continue; } if !occ_event.contains_key(k) { @@ -710,6 +699,7 @@ mod tests { uid: Some("u".to_owned()), start: Some("2025-06-15T14:00:00Z".to_owned()), end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some("alice@example.com".to_owned()), required_attendees: vec![crate::exchange_ews::parse::RawAttendee { email: None, routing_type: None, @@ -796,6 +786,7 @@ mod tests { uid: Some("u".to_owned()), start: Some("2025-06-15T14:00:00Z".to_owned()), end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some("alice@example.com".to_owned()), required_attendees: vec![RawAttendee { email: Some("bob@example.com".to_owned()), routing_type: Some("SMTP".to_owned()), @@ -809,7 +800,7 @@ mod tests { .as_object() .unwrap() .values() - .next() + .find(|p| p["email"] == "bob@example.com") .unwrap(); assert_eq!(att["calendarAddress"], "mailto:bob@example.com"); assert_eq!(att["email"], "bob@example.com"); @@ -984,6 +975,7 @@ mod tests { uid: Some("uid-res".to_owned()), start: Some("2025-06-15T14:00:00Z".to_owned()), end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some("alice@example.com".to_owned()), resources: vec![crate::exchange_ews::parse::RawAttendee { email: Some("room-7@x".to_owned()), routing_type: None, @@ -1011,6 +1003,7 @@ mod tests { uid: Some("uid-rsvp".to_owned()), start: Some("2025-06-15T14:00:00Z".to_owned()), end: Some("2025-06-15T15:00:00Z".to_owned()), + organizer_smtp: Some("alice@example.com".to_owned()), required_attendees: vec![crate::exchange_ews::parse::RawAttendee { email: Some("bob@x".to_owned()), routing_type: None, @@ -1031,6 +1024,36 @@ mod tests { assert_eq!(bob["roles"]["required"], true); } + #[test] + fn attendees_lose_calendar_addresses_when_no_organizer_is_known() { + let raw = CalendarItemRaw { + uid: Some("uid-no-organizer".to_owned()), + start: Some("2025-06-15T14:00:00Z".to_owned()), + end: Some("2025-06-15T15:00:00Z".to_owned()), + required_attendees: vec![crate::exchange_ews::parse::RawAttendee { + email: Some("bob@x".to_owned()), + routing_type: None, + name: Some("Bob".to_owned()), + response_type: Some("Accept".to_owned()), + }], + ..CalendarItemRaw::default() + }; + let v = to_jscalendar(&raw).data; + assert!(v.get("organizerCalendarAddress").is_none()); + let bob = v["participants"] + .as_object() + .unwrap() + .values() + .find(|p| p["name"] == "Bob") + .unwrap(); + for key in ["calendarAddress", "email", "roles", "participationStatus"] { + assert!( + bob.get(key).is_none(), + "{key} requires calendarAddress, which requires organizerCalendarAddress" + ); + } + } + #[test] fn all_day_span_helper_is_inclusive_of_start() { assert_eq!(all_day_span_days("2025-07-04", "2025-07-04"), 1); diff --git a/src/exchange_graph/calendar_map.rs b/src/exchange_graph/calendar_map.rs index 930b906..3d9b9db 100644 --- a/src/exchange_graph/calendar_map.rs +++ b/src/exchange_graph/calendar_map.rs @@ -6,6 +6,7 @@ use serde_json::{Map, Value, json}; +use crate::exchange::jscalendar::{drop_calendar_address_dependents, synthetic_attendee_address}; use crate::exchange::tz::resolve_to_iana; use crate::exchange_graph::error::GraphError; use crate::exchange_graph::recurrence::convert_patterned_recurrence_rule; @@ -242,13 +243,8 @@ pub fn convert_event( .get("organizer") .and_then(|o| o.get("emailAddress")) { - if let Some(addr) = org.get("address").and_then(Value::as_str) - && !addr.is_empty() - { - card.insert( - "organizerCalendarAddress".to_owned(), - Value::from(format!("mailto:{addr}")), - ); + if let Some((cal_addr, _)) = resolve_calendar_address(org) { + card.insert("organizerCalendarAddress".to_owned(), Value::from(cal_addr)); } let p = build_participant(org, &["owner", "chair"], None, None); participants.push(("organizer".to_owned(), p)); @@ -275,7 +271,10 @@ pub fn convert_event( } } if !participants.is_empty() { - let map: Map = participants.into_iter().collect(); + let mut map: Map = participants.into_iter().collect(); + if !card.contains_key("organizerCalendarAddress") { + drop_calendar_address_dependents(&mut map); + } card.insert("participants".to_owned(), Value::Object(map)); } @@ -346,6 +345,23 @@ fn extract_local_datetime(slot: Option<&Value>) -> Option { Some(dt.to_owned()) } +fn non_empty(email: &Value, key: &str) -> Option { + email + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +fn resolve_calendar_address(email: &Value) -> Option<(String, Option)> { + if let Some(addr) = non_empty(email, "address") { + let cal_addr = format!("mailto:{addr}"); + return Some((cal_addr, Some(addr))); + } + non_empty(email, "name").map(|name| (synthetic_attendee_address(&name), None)) +} + fn build_participant( email: &Value, roles: &[&str], @@ -354,31 +370,28 @@ fn build_participant( ) -> Value { let mut map = Map::new(); map.insert("@type".to_owned(), Value::from("Participant")); - if let Some(addr) = email.get("address").and_then(Value::as_str) { - map.insert( - "calendarAddress".to_owned(), - Value::from(format!("mailto:{addr}")), - ); - map.insert("email".to_owned(), Value::from(addr.to_owned())); + if let Some(name) = non_empty(email, "name") { + map.insert("name".to_owned(), Value::from(name)); } - if let Some(name) = email.get("name").and_then(Value::as_str) - && !name.is_empty() - { - map.insert("name".to_owned(), Value::from(name.to_owned())); - } - let role_map: Map = roles - .iter() - .map(|r| ((*r).to_owned(), Value::Bool(true))) - .collect(); - map.insert("roles".to_owned(), Value::Object(role_map)); - if let Some(status) = participation_status { - map.insert( - "participationStatus".to_owned(), - Value::from(status.to_owned()), - ); - } - if let Some(b) = expect_reply { - map.insert("expectReply".to_owned(), Value::Bool(b)); + if let Some((cal_addr, smtp)) = resolve_calendar_address(email) { + map.insert("calendarAddress".to_owned(), Value::from(cal_addr)); + if let Some(addr) = smtp { + map.insert("email".to_owned(), Value::from(addr)); + } + let role_map: Map = roles + .iter() + .map(|r| ((*r).to_owned(), Value::Bool(true))) + .collect(); + map.insert("roles".to_owned(), Value::Object(role_map)); + if let Some(status) = participation_status { + map.insert( + "participationStatus".to_owned(), + Value::from(status.to_owned()), + ); + } + if let Some(b) = expect_reply { + map.insert("expectReply".to_owned(), Value::Bool(b)); + } } Value::Object(map) } @@ -650,6 +663,91 @@ mod tests { assert_eq!(participants["att-1"]["roles"]["required"], true); } + #[test] + fn a_name_only_attendee_gets_a_stable_synthetic_calendar_address() { + let mut v = sample(); + v["attendees"] = json!([ + { + "emailAddress": {"name": "Room 4"}, + "type": "resource", + "status": {"response": "accepted"} + } + ]); + 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 name-only attendee needs a synthetic calendarAddress, got {addr}" + ); + assert!( + p.get("email").is_none(), + "there is no SMTP address to report as email" + ); + assert_eq!(p["participationStatus"], "accepted"); + assert_eq!(p["roles"]["informational"], true); + assert_eq!( + addr, + convert_event(&v, None).unwrap().data["participants"]["att-1"]["calendarAddress"] + ); + } + + #[test] + fn an_unidentifiable_attendee_keeps_no_calendar_address_dependent_property() { + let mut v = sample(); + v["attendees"] = json!([ + {"emailAddress": {}, "type": "required", "status": {"response": "accepted"}} + ]); + let conv = convert_event(&v, None).unwrap(); + let p = &conv.data["participants"]["att-1"]; + for key in [ + "calendarAddress", + "email", + "roles", + "participationStatus", + "expectReply", + ] { + assert!( + p.get(key).is_none(), + "{key} requires calendarAddress (jscalendarbis 3.4.6)" + ); + } + assert_eq!(p["@type"], "Participant"); + } + + #[test] + fn an_organizer_without_an_address_still_yields_a_calendar_address() { + let mut v = sample(); + v["organizer"] = json!({"emailAddress": {"name": "Alice Example"}}); + let conv = convert_event(&v, None).unwrap(); + let organizer_address = conv.data["organizerCalendarAddress"].as_str().unwrap(); + assert!(organizer_address.starts_with("urn:x-vandelay:attendee:")); + assert_eq!( + conv.data["participants"]["organizer"]["calendarAddress"], + organizer_address + ); + } + + #[test] + fn participants_drop_calendar_addresses_when_no_organizer_is_known() { + let mut v = sample(); + v.as_object_mut().unwrap().remove("organizer"); + let conv = convert_event(&v, None).unwrap(); + assert!( + conv.data.get("organizerCalendarAddress").is_none(), + "no organizer is derivable from the source event" + ); + let participants = conv.data["participants"].as_object().unwrap(); + assert!(!participants.is_empty()); + for (key, participant) in participants { + assert!( + participant.get("calendarAddress").is_none(), + "{key} may not carry a calendarAddress without organizerCalendarAddress" + ); + assert!(participant.get("roles").is_none(), "{key} kept roles"); + } + } + #[test] fn optional_attendee_keeps_role_and_uses_status_when_present() { let mut v = sample(); diff --git a/src/exchange_graph/types.rs b/src/exchange_graph/types.rs index a0210c2..b15e70d 100644 --- a/src/exchange_graph/types.rs +++ b/src/exchange_graph/types.rs @@ -59,6 +59,59 @@ impl EventBodyFormat { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Surfaces { + pub mail: bool, + pub calendar: bool, + pub contacts: bool, +} + +impl Default for Surfaces { + fn default() -> Self { + Surfaces::ALL + } +} + +impl Surfaces { + pub const ALL: Surfaces = Surfaces { + mail: true, + calendar: true, + contacts: true, + }; + + pub const NONE: Surfaces = Surfaces { + mail: false, + calendar: false, + contacts: false, + }; + + pub fn parse_list(list: &str) -> Result { + let mut selected = Surfaces::NONE; + for token in list.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + match token.to_ascii_lowercase().as_str() { + "mail" => selected.mail = true, + "calendar" => selected.calendar = true, + "contacts" => selected.contacts = true, + _ => { + return Err(Error::Usage(format!( + "unknown surface: {token} (valid: mail, calendar, contacts)" + ))); + } + } + } + if selected == Surfaces::NONE { + return Err(Error::Usage( + "--objects given but resolved to an empty surface list".to_owned(), + )); + } + Ok(selected) + } +} + #[derive(Debug, Clone)] pub struct ResolvedPrincipal { pub id: String, @@ -102,6 +155,86 @@ mod tests { ); } + #[test] + fn surfaces_default_is_all_three() { + let all = Surfaces::default(); + assert!(all.mail && all.calendar && all.contacts); + } + + #[test] + fn surfaces_parse_each_name() { + assert_eq!( + Surfaces::parse_list("mail").unwrap(), + Surfaces { + mail: true, + calendar: false, + contacts: false + } + ); + assert_eq!( + Surfaces::parse_list("calendar").unwrap(), + Surfaces { + mail: false, + calendar: true, + contacts: false + } + ); + assert_eq!( + Surfaces::parse_list("contacts").unwrap(), + Surfaces { + mail: false, + calendar: false, + contacts: true + } + ); + } + + #[test] + fn surfaces_parse_is_case_insensitive() { + assert_eq!( + Surfaces::parse_list("Mail,CALENDAR,Contacts").unwrap(), + Surfaces::ALL + ); + } + + #[test] + fn surfaces_parse_dedups_and_trims() { + assert_eq!( + Surfaces::parse_list(" mail , contacts ,mail,, ").unwrap(), + Surfaces { + mail: true, + calendar: false, + contacts: true + } + ); + } + + #[test] + fn surfaces_parse_rejects_unknown_name() { + let err = Surfaces::parse_list("mail,contact").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("unknown surface: contact"), "msg was: {msg}"); + assert!( + msg.contains("valid: mail, calendar, contacts"), + "msg was: {msg}" + ); + } + + #[test] + fn surfaces_parse_rejects_jmap_type_names() { + assert!(Surfaces::parse_list("mailbox").is_err()); + assert!(Surfaces::parse_list("contactcard").is_err()); + } + + #[test] + fn surfaces_parse_rejects_empty_list() { + let err = Surfaces::parse_list(" , ").unwrap_err(); + assert!( + err.to_string().contains("empty surface list"), + "msg was: {err}" + ); + } + #[test] fn body_format_prefer_value() { assert_eq!( diff --git a/src/jmap/blob.rs b/src/jmap/blob.rs index ae4d2ac..acc04fb 100644 --- a/src/jmap/blob.rs +++ b/src/jmap/blob.rs @@ -4,10 +4,32 @@ * SPDX-License-Identifier: Apache-2.0 OR MIT */ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; use serde_json::{Map, Value}; +use crate::jmap::error::JmapError; + pub const SENTINEL_KEY: &str = "@blob"; +const DEFAULT_MEDIA_TYPE: &str = "application/octet-stream"; +const MEDIA_TYPE_KEYS: [&str; 2] = ["mediaType", "contentType"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InlineShape { + JsContactResource, + JsCalendarLink, +} + +impl InlineShape { + fn uri_key(self) -> &'static str { + match self { + InlineShape::JsContactResource => "uri", + InlineShape::JsCalendarLink => "href", + } + } +} + pub fn import_blob_ids(value: &mut Value, mut resolve: F) -> Result<(), BlobWalkError> where F: FnMut(&str) -> Result, @@ -39,31 +61,36 @@ where Ok(()) } -pub fn export_blob_ids(value: &mut Value, mut resolve: F) -> Result<(), BlobWalkError> +pub fn inline_blob_data_uris( + value: &mut Value, + shape: InlineShape, + mut fetch: F, +) -> Result<(), BlobWalkError> where - F: FnMut(i64) -> Result, + F: FnMut(i64) -> Result, BlobWalkError>, { - walk_export(value, &mut resolve) + walk_inline(value, shape, &mut fetch) } -fn walk_export(value: &mut Value, resolve: &mut F) -> Result<(), BlobWalkError> +fn walk_inline(value: &mut Value, shape: InlineShape, fetch: &mut F) -> Result<(), BlobWalkError> where - F: FnMut(i64) -> Result, + F: FnMut(i64) -> Result, BlobWalkError>, { match value { Value::Object(map) => { if let Some(sentinel) = map.remove(SENTINEL_KEY) { let local_id = sentinel.as_i64().ok_or(BlobWalkError::MalformedSentinel)?; - let target_jmap_id = resolve(local_id)?; - map.insert("blobId".to_owned(), Value::String(target_jmap_id)); + let bytes = fetch(local_id)?; + let uri = data_uri(map, &bytes); + map.insert(shape.uri_key().to_owned(), Value::String(uri)); } for child in map.values_mut() { - walk_export(child, resolve)?; + walk_inline(child, shape, fetch)?; } } Value::Array(items) => { for item in items { - walk_export(item, resolve)?; + walk_inline(item, shape, fetch)?; } } _ => {} @@ -71,6 +98,70 @@ where Ok(()) } +fn data_uri(map: &Map, bytes: &[u8]) -> String { + let media_type = MEDIA_TYPE_KEYS + .iter() + .find_map(|k| map.get(*k).and_then(Value::as_str).and_then(uri_media_type)) + .unwrap_or_else(|| DEFAULT_MEDIA_TYPE.to_owned()); + let mut uri = String::with_capacity(13 + media_type.len() + bytes.len().div_ceil(3) * 4); + uri.push_str("data:"); + uri.push_str(&media_type); + uri.push_str(";base64,"); + STANDARD.encode_string(bytes, &mut uri); + uri +} + +fn uri_media_type(raw: &str) -> Option { + let mut parts = raw.split(';').map(str::trim); + let essence = parts.next()?; + let (ty, subtype) = essence.split_once('/')?; + if ty.is_empty() || subtype.is_empty() || subtype.contains('/') { + return None; + } + let mut out = String::with_capacity(raw.len()); + escape_into(&mut out, essence); + for parameter in parts.filter(|p| !p.is_empty()) { + out.push(';'); + escape_into(&mut out, parameter); + } + Some(out) +} + +fn escape_into(out: &mut String, part: &str) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in part.bytes() { + if is_media_type_uri_byte(byte) { + out.push(byte as char); + } else { + out.push('%'); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0F) as usize] as char); + } + } +} + +fn is_media_type_uri_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b'-' + | b'.' + | b'/' + | b':' + | b'=' + | b'@' + | b'_' + | b'~' + ) +} + pub fn prepend_property(target: &mut Map, key: &str, value: Value) { let existing: Vec<(String, Value)> = target.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); @@ -88,5 +179,341 @@ pub enum BlobWalkError { #[error("malformed @blob sentinel: expected integer local id")] MalformedSentinel, #[error("blob resolver failed: {0}")] - Resolver(String), + Resolver(Box), +} + +impl BlobWalkError { + pub fn resolver(source: JmapError) -> BlobWalkError { + BlobWalkError::Resolver(Box::new(source)) + } + + pub fn into_source(self) -> JmapError { + match self { + BlobWalkError::Resolver(source) => *source, + other => JmapError::Blob(other), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fetch_ok(local_id: i64) -> Result, BlobWalkError> { + Ok(format!("bytes-{local_id}").into_bytes()) + } + + fn decode(uri: &str, expect_media_type: &str) -> Vec { + assert_well_formed(uri); + let prefix = format!("data:{expect_media_type};base64,"); + let payload = uri + .strip_prefix(&prefix) + .unwrap_or_else(|| panic!("{uri} does not start with {prefix}")); + STANDARD.decode(payload).expect("base64 payload") + } + + fn assert_well_formed(uri: &str) { + let rest = uri + .strip_prefix("data:") + .unwrap_or_else(|| panic!("{uri} is not a data URI")); + let (media_type, payload) = rest + .split_once(";base64,") + .unwrap_or_else(|| panic!("{uri} carries no ;base64, separator")); + let bytes = media_type.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'%' { + assert!( + i + 2 < bytes.len() + && bytes[i + 1].is_ascii_hexdigit() + && bytes[i + 2].is_ascii_hexdigit(), + "truncated percent escape in {uri}" + ); + i += 3; + continue; + } + assert!( + is_media_type_uri_byte(b) || b == b';', + "byte {b:#04x} is not allowed unescaped in {uri}" + ); + i += 1; + } + assert!( + payload + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')), + "non-base64 payload in {uri}" + ); + } + + fn assert_no_blob_id(value: &Value) { + match value { + Value::Object(map) => { + assert!(map.get("blobId").is_none(), "blobId survived in {value}"); + assert!(map.get(SENTINEL_KEY).is_none(), "@blob survived in {value}"); + for child in map.values() { + assert_no_blob_id(child); + } + } + Value::Array(items) => items.iter().for_each(assert_no_blob_id), + _ => {} + } + } + + #[test] + fn media_sentinel_becomes_a_uri_property() { + let mut card = json!({ + "@type": "Card", + "media": { "photo": { + "@type": "Media", "kind": "photo", + "@blob": 7, "mediaType": "image/png" + } } + }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, fetch_ok).unwrap(); + let photo = &card["media"]["photo"]; + assert_eq!( + decode(photo["uri"].as_str().unwrap(), "image/png"), + b"bytes-7" + ); + assert_eq!(photo["mediaType"], json!("image/png")); + assert!(photo.get("href").is_none()); + assert_no_blob_id(&card); + } + + #[test] + fn calendar_link_sentinel_becomes_an_href_property() { + let mut event = json!({ + "@type": "Event", + "links": { "1": { + "@type": "Link", "rel": "enclosure", + "@blob": 3, "contentType": "text/plain", "title": "note.txt" + } } + }); + inline_blob_data_uris(&mut event, InlineShape::JsCalendarLink, fetch_ok).unwrap(); + let link = &event["links"]["1"]; + assert_eq!( + decode(link["href"].as_str().unwrap(), "text/plain"), + b"bytes-3" + ); + assert_eq!(link["contentType"], json!("text/plain")); + assert_eq!(link["title"], json!("note.txt")); + assert!(link.get("uri").is_none()); + assert_no_blob_id(&event); + } + + #[test] + fn jscontact_link_keeps_the_resource_uri_property() { + let mut card = json!({ + "@type": "Card", + "links": { "l1": { "@type": "Link", "@blob": 1, "mediaType": "application/pdf" } } + }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, fetch_ok).unwrap(); + let link = &card["links"]["l1"]; + assert!(link.get("href").is_none(), "JSContact Link uses uri"); + assert_eq!( + decode(link["uri"].as_str().unwrap(), "application/pdf"), + b"bytes-1" + ); + } + + #[test] + fn absent_media_type_defaults_to_octet_stream() { + let mut card = json!({ "media": { "photo": { "@blob": 2 } } }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, fetch_ok).unwrap(); + let uri = card["media"]["photo"]["uri"].as_str().unwrap(); + assert_eq!(decode(uri, DEFAULT_MEDIA_TYPE), b"bytes-2"); + } + + #[test] + fn empty_media_type_defaults_to_octet_stream() { + let mut event = json!({ "links": { "1": { "@blob": 4, "contentType": "" } } }); + inline_blob_data_uris(&mut event, InlineShape::JsCalendarLink, fetch_ok).unwrap(); + let uri = event["links"]["1"]["href"].as_str().unwrap(); + assert_eq!(decode(uri, DEFAULT_MEDIA_TYPE), b"bytes-4"); + } + + #[test] + fn empty_media_type_falls_through_to_the_next_key() { + let mut card = json!({ "media": { "photo": { + "@blob": 4, "mediaType": "", "contentType": "image/jpeg" + } } }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, fetch_ok).unwrap(); + let uri = card["media"]["photo"]["uri"].as_str().unwrap(); + assert_eq!(decode(uri, "image/jpeg"), b"bytes-4"); + } + + #[test] + fn media_type_parameters_are_kept_without_whitespace() { + let mut event = json!({ "links": { "1": { + "@blob": 4, "contentType": "text/plain; charset=utf-8" + } } }); + inline_blob_data_uris(&mut event, InlineShape::JsCalendarLink, fetch_ok).unwrap(); + let uri = event["links"]["1"]["href"].as_str().unwrap(); + assert!(!uri.contains(' '), "{uri} carries a raw space"); + assert_eq!(decode(uri, "text/plain;charset=utf-8"), b"bytes-4"); + } + + #[test] + fn media_type_keeps_several_parameters_and_drops_empty_ones() { + let mut event = json!({ "links": { "1": { + "@blob": 4, "contentType": " text/plain ;; charset=utf-8 ; format=flowed " + } } }); + inline_blob_data_uris(&mut event, InlineShape::JsCalendarLink, fetch_ok).unwrap(); + let uri = event["links"]["1"]["href"].as_str().unwrap(); + assert_eq!( + decode(uri, "text/plain;charset=utf-8;format=flowed"), + b"bytes-4" + ); + } + + #[test] + fn media_type_characters_illegal_in_a_uri_are_percent_encoded() { + let mut event = json!({ "links": { "1": { + "@blob": 4, "contentType": "text/plain; name=\"rep ort,v2#1\u{e9}.txt\"" + } } }); + inline_blob_data_uris(&mut event, InlineShape::JsCalendarLink, fetch_ok).unwrap(); + let uri = event["links"]["1"]["href"].as_str().unwrap(); + assert_eq!( + decode(uri, "text/plain;name=%22rep%20ort%2Cv2%231%C3%A9.txt%22"), + b"bytes-4" + ); + } + + #[test] + fn a_percent_in_the_media_type_is_itself_escaped() { + let mut card = json!({ "media": { "photo": { + "@blob": 4, "mediaType": "image/png; note=100%" + } } }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, fetch_ok).unwrap(); + let uri = card["media"]["photo"]["uri"].as_str().unwrap(); + assert_eq!(decode(uri, "image/png;note=100%25"), b"bytes-4"); + } + + #[test] + fn a_media_type_that_is_not_type_slash_subtype_defaults_to_octet_stream() { + for bogus in ["png", "image/", "/png", "image/png/extra", ";charset=utf-8"] { + let mut card = json!({ "media": { "photo": { "@blob": 4, "mediaType": bogus } } }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, fetch_ok).unwrap(); + let uri = card["media"]["photo"]["uri"].as_str().unwrap(); + assert_eq!(decode(uri, DEFAULT_MEDIA_TYPE), b"bytes-4", "{bogus}"); + } + } + + #[test] + fn inlined_bytes_round_trip_exactly() { + let payload: Vec = (0u16..=255).map(|b| b as u8).collect(); + let mut card = json!({ "media": { "photo": { + "@blob": 1, "mediaType": "application/octet-stream; charset=binary" + } } }); + inline_blob_data_uris(&mut card, InlineShape::JsContactResource, |_| { + Ok(payload.clone()) + }) + .unwrap(); + let uri = card["media"]["photo"]["uri"].as_str().unwrap(); + assert_eq!( + decode(uri, "application/octet-stream;charset=binary"), + payload + ); + } + + #[test] + fn sentinels_nested_in_arrays_and_subobjects_are_all_inlined() { + let mut value = json!({ + "locations": { "loc1": { "links": [ + { "@blob": 5, "contentType": "image/gif" }, + { "@blob": 6 } + ] } }, + "links": { "top": { "@blob": 8, "contentType": "image/gif" } } + }); + inline_blob_data_uris(&mut value, InlineShape::JsCalendarLink, fetch_ok).unwrap(); + let arr = value["locations"]["loc1"]["links"].as_array().unwrap(); + assert_eq!( + decode(arr[0]["href"].as_str().unwrap(), "image/gif"), + b"bytes-5" + ); + assert_eq!( + decode(arr[1]["href"].as_str().unwrap(), DEFAULT_MEDIA_TYPE), + b"bytes-6" + ); + assert_eq!( + decode(value["links"]["top"]["href"].as_str().unwrap(), "image/gif"), + b"bytes-8" + ); + assert_no_blob_id(&value); + } + + #[test] + fn malformed_sentinel_is_rejected() { + let mut value = json!({ "media": { "photo": { "@blob": "not-an-id" } } }); + let err = inline_blob_data_uris(&mut value, InlineShape::JsContactResource, fetch_ok) + .expect_err("malformed sentinel"); + assert!(matches!(err, BlobWalkError::MalformedSentinel), "{err:?}"); + } + + #[test] + fn resolver_failure_propagates() { + let mut value = json!({ "media": { "photo": { "@blob": 9 } } }); + let err = inline_blob_data_uris(&mut value, InlineShape::JsContactResource, |_| { + Err(BlobWalkError::resolver(JmapError::malformed("gone"))) + }) + .expect_err("resolver failure"); + assert!(matches!(err, BlobWalkError::Resolver(_)), "{err:?}"); + } + + #[test] + fn the_resolver_error_kind_survives_the_walk() { + let mut value = json!({ "media": { "photo": { "@blob": 9 } } }); + let err = inline_blob_data_uris(&mut value, InlineShape::JsContactResource, |_| { + Err(BlobWalkError::resolver(JmapError::Sqlite( + rusqlite::Error::QueryReturnedNoRows, + ))) + }) + .expect_err("resolver failure") + .into_source(); + assert!(matches!(err, JmapError::Sqlite(_)), "{err:?}"); + let mapped = crate::error::Error::from(err); + assert!(mapped.aborts_run(), "{mapped} must abort the run"); + assert_eq!(mapped.exit_code(), 7); + } + + #[test] + fn a_malformed_sentinel_stays_a_blob_walk_error() { + let err = BlobWalkError::MalformedSentinel.into_source(); + assert!( + matches!(err, JmapError::Blob(BlobWalkError::MalformedSentinel)), + "{err:?}" + ); + assert_eq!(crate::error::Error::from(err).exit_code(), 5); + } + + #[test] + fn values_without_a_sentinel_are_untouched() { + let original = json!({ + "@type": "Card", + "media": { "photo": { "@type": "Media", "kind": "photo", + "uri": "https://example.test/p.png", "mediaType": "image/png" } }, + "nickNames": { "n1": { "name": "Jay" } } + }); + let mut value = original.clone(); + inline_blob_data_uris(&mut value, InlineShape::JsContactResource, |_| { + panic!("fetch must not be called") + }) + .unwrap(); + assert_eq!(value, original); + } + + #[test] + fn import_walker_still_produces_the_sentinel() { + let mut value = + json!({ "media": { "photo": { "blobId": "B1", "mediaType": "image/png" } } }); + import_blob_ids(&mut value, |id| { + assert_eq!(id, "B1"); + Ok(11) + }) + .unwrap(); + assert_eq!(value["media"]["photo"][SENTINEL_KEY], json!(11)); + assert!(value["media"]["photo"].get("blobId").is_none()); + } } diff --git a/src/jmap/connect.rs b/src/jmap/connect.rs index ab10fb9..b9586d3 100644 --- a/src/jmap/connect.rs +++ b/src/jmap/connect.rs @@ -31,6 +31,9 @@ pub fn prepare(ctx: &Context, connect: &ConnectConfig) -> Result for Error { match value { JmapError::Connect(m) | JmapError::Transport(m) => Error::Connection(m), JmapError::Auth(m) => Error::Connection(format!("authentication rejected: {m}")), - other => Error::Connection(other.to_string()), + JmapError::Sqlite(e) => Error::Db(OpenError::Sqlite(e)), + reached @ (JmapError::HttpStatus { .. } | JmapError::RetriesExhausted(_)) => { + Error::Connection(reached.to_string()) + } + per_unit => Error::Partial(per_unit.to_string()), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unreachable_endpoint_aborts_the_run() { + for e in [ + JmapError::Connect("refused".to_owned()), + JmapError::Transport("io: broken pipe".to_owned()), + JmapError::Auth("bad password".to_owned()), + JmapError::HttpStatus { + status: 404, + body: "404 Not Found".to_owned(), + }, + JmapError::RetriesExhausted("gave up".to_owned()), + ] { + let mapped = Error::from(e); + assert!(mapped.aborts_run(), "{mapped} must abort the run"); + assert_eq!(mapped.exit_code(), 2); + } + } + + #[test] + fn protocol_level_errors_stay_per_unit() { + for e in [ + JmapError::Method { + call_id: "q".to_owned(), + error_type: "unknownMethod".to_owned(), + description: None, + }, + JmapError::UnknownMethod, + JmapError::CannotCalculateChanges, + JmapError::AnchorNotFound, + JmapError::RequestTooLarge, + JmapError::SingleObjectTooLarge("one blob".to_owned()), + JmapError::Malformed("not a list".to_owned()), + ] { + let mapped = Error::from(e); + assert!(!mapped.aborts_run(), "{mapped} must not abort the run"); + assert_eq!(mapped.exit_code(), 5); + } + } + + #[test] + fn archive_failures_are_local_io() { + let mapped = Error::from(JmapError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); + assert!(mapped.aborts_run()); + assert_eq!(mapped.exit_code(), 7); + } +} diff --git a/src/jmap/http.rs b/src/jmap/http.rs index eaa0db3..68eec72 100644 --- a/src/jmap/http.rs +++ b/src/jmap/http.rs @@ -617,9 +617,11 @@ fn format_rate_headers(headers: &[(String, String)]) -> Option { Some(parts.join("; ")) } -fn format_retry_wait(d: Duration) -> String { +pub fn format_retry_wait(d: Duration) -> String { let total = d.as_secs(); - if total < 60 { + if total == 0 { + format!("{}ms", d.as_millis()) + } else if total < 60 { format!("{total}s") } else if total < 3600 { format!("{}m{:02}s", total / 60, total % 60) @@ -826,6 +828,7 @@ mod tests { #[test] fn format_retry_wait_short_medium_long() { + assert_eq!(format_retry_wait(Duration::from_millis(250)), "250ms"); assert_eq!(format_retry_wait(Duration::from_secs(7)), "7s"); assert_eq!(format_retry_wait(Duration::from_secs(67)), "1m07s"); assert_eq!(format_retry_wait(Duration::from_secs(1427)), "23m47s"); diff --git a/src/jmap/request.rs b/src/jmap/request.rs index 3d5ae08..9cec161 100644 --- a/src/jmap/request.rs +++ b/src/jmap/request.rs @@ -9,9 +9,11 @@ use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; use crate::jmap::error::JmapError; -use crate::jmap::http::HttpClient; +use crate::jmap::http::{HttpClient, format_retry_wait}; +use crate::jmap::retry::{Disposition, MethodCallKind, backoff_delay, jmap_method_disposition}; use crate::jmap::session::Limits; use crate::jmap::wire::JmapId; +use crate::logging::Logger; pub const URN_CORE: &str = "urn:ietf:params:jmap:core"; @@ -184,6 +186,43 @@ pub fn check_method_error(mr: &MethodCall) -> Result<(), JmapError> { }) } +fn method_error_is_retryable(err: &JmapError, kind: MethodCallKind) -> bool { + match err { + JmapError::Method { error_type, .. } => { + jmap_method_disposition(error_type, kind) == Disposition::Retryable + } + _ => false, + } +} + +pub fn retry_method_call( + client: &HttpClient, + kind: MethodCallKind, + logger: &Logger, + mut attempt: impl FnMut() -> Result, +) -> Result { + let policy = *client.retry(); + let mut tries = 0u32; + loop { + let err = match attempt() { + Ok(value) => return Ok(value), + Err(e) => e, + }; + if tries >= policy.max_retries || !method_error_is_retryable(&err, kind) { + return Err(err); + } + tries += 1; + let delay = backoff_delay(&policy, tries); + client.rate_limit().cooldown().arm(delay); + logger.warn(&format!( + "{err}; retrying in {} ({tries}/{})", + format_retry_wait(delay), + policy.max_retries + )); + std::thread::sleep(delay); + } +} + fn ids_array(ids: &[JmapId]) -> Value { Value::Array(ids.iter().map(|i| Value::String(i.0.clone())).collect()) } @@ -720,7 +759,96 @@ fn decode_set(mr: &MethodCall) -> SetOutcome { #[cfg(test)] mod tests { + use std::cell::Cell; + use super::*; + use crate::error::Error; + use crate::jmap::http::{Auth, RetryPolicy}; + + fn test_client(max_retries: u32) -> HttpClient { + HttpClient::new( + Auth::Bearer { + token: "t".to_owned(), + }, + RetryPolicy::new(max_retries), + false, + ) + } + + fn method_error(error_type: &str) -> JmapError { + JmapError::Method { + call_id: "i".to_owned(), + error_type: error_type.to_owned(), + description: None, + } + } + + fn drive( + max_retries: u32, + outcome: impl Fn(u32) -> Result<&'static str, JmapError>, + ) -> (Result<&'static str, JmapError>, u32) { + let client = test_client(max_retries); + let calls = Cell::new(0u32); + let result = retry_method_call( + &client, + MethodCallKind::SingleObjectWrite, + &Logger::from_flags(true, 0), + || { + calls.set(calls.get() + 1); + outcome(calls.get()) + }, + ); + (result, calls.get()) + } + + #[test] + fn transient_method_error_is_retried_until_it_succeeds() { + let (result, calls) = drive(3, |n| { + if n < 3 { + Err(method_error("serverUnavailable")) + } else { + Ok("created") + } + }); + assert_eq!(result.unwrap(), "created"); + assert_eq!(calls, 3); + } + + #[test] + fn exhausting_the_budget_returns_the_original_method_error() { + let (result, calls) = drive(2, |_| Err(method_error("serverUnavailable"))); + assert_eq!(calls, 3, "one initial attempt plus max_retries"); + let err = result.unwrap_err(); + assert!( + matches!(&err, JmapError::Method { error_type, .. } if error_type == "serverUnavailable"), + "the caller must still see the method error, not RetriesExhausted: {err}" + ); + assert!( + !Error::from(err).aborts_run(), + "an exhausted method retry stays a per-unit failure" + ); + } + + #[test] + fn permanent_method_error_consumes_no_attempt() { + let (result, calls) = drive(5, |_| Err(method_error("invalidArguments"))); + assert!(result.is_err()); + assert_eq!(calls, 1); + } + + #[test] + fn partial_fail_is_not_retried_for_a_write() { + let (result, calls) = drive(5, |_| Err(method_error("serverPartialFail"))); + assert!(result.is_err()); + assert_eq!(calls, 1, "retrying a partial write could duplicate it"); + } + + #[test] + fn non_method_errors_are_left_to_the_http_layer() { + let (result, calls) = drive(5, |_| Err(JmapError::Transport("reset".to_owned()))); + assert!(result.is_err()); + assert_eq!(calls, 1); + } #[test] fn using_union_includes_core_and_dedups() { diff --git a/src/jmap/retry.rs b/src/jmap/retry.rs index 0ab5487..3ea86cd 100644 --- a/src/jmap/retry.rs +++ b/src/jmap/retry.rs @@ -27,9 +27,19 @@ pub fn classify_http_status(status: u16) -> Disposition { } } -pub fn jmap_method_disposition(error_type: &str) -> Disposition { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodCallKind { + Read, + SingleObjectWrite, +} + +pub fn jmap_method_disposition(error_type: &str, kind: MethodCallKind) -> Disposition { match error_type { - "serverUnavailable" | "serverPartialFail" => Disposition::Retryable, + "serverUnavailable" => Disposition::Retryable, + "serverPartialFail" => match kind { + MethodCallKind::Read => Disposition::Retryable, + MethodCallKind::SingleObjectWrite => Disposition::Fatal, + }, _ => Disposition::Fatal, } } @@ -231,12 +241,33 @@ mod tests { #[test] fn method_classification() { + for kind in [MethodCallKind::Read, MethodCallKind::SingleObjectWrite] { + assert_eq!( + jmap_method_disposition("serverUnavailable", kind), + Disposition::Retryable, + "{kind:?}" + ); + assert_eq!( + jmap_method_disposition("invalidArguments", kind), + Disposition::Fatal, + "{kind:?}" + ); + assert_eq!( + jmap_method_disposition("forbidden", kind), + Disposition::Fatal, + "{kind:?}" + ); + } + } + + #[test] + fn partial_fail_is_never_retried_for_a_write() { assert_eq!( - jmap_method_disposition("serverUnavailable"), + jmap_method_disposition("serverPartialFail", MethodCallKind::Read), Disposition::Retryable ); assert_eq!( - jmap_method_disposition("invalidArguments"), + jmap_method_disposition("serverPartialFail", MethodCallKind::SingleObjectWrite), Disposition::Fatal ); } diff --git a/src/jmap/session.rs b/src/jmap/session.rs index 063ab5a..6a1ccc1 100644 --- a/src/jmap/session.rs +++ b/src/jmap/session.rs @@ -58,12 +58,100 @@ fn well_known_url(base: &str) -> String { format!("{trimmed}/.well-known/jmap") } +fn scheme_end(url: &str) -> Option { + let bytes = url.as_bytes(); + if !bytes.first()?.is_ascii_alphabetic() { + return None; + } + for (index, byte) in bytes.iter().copied().enumerate() { + if byte == b':' { + return Some(index); + } + if !(byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) { + return None; + } + } + None +} + +pub fn resolve_reference(base: &str, reference: &str) -> String { + if reference.is_empty() { + return base.to_owned(); + } + if scheme_end(reference).is_some() { + return reference.to_owned(); + } + let Some(base_scheme_end) = scheme_end(base) else { + return reference.to_owned(); + }; + let scheme = &base[..=base_scheme_end]; + let Some(authority_and_path) = base[base_scheme_end + 1..].strip_prefix("//") else { + return reference.to_owned(); + }; + let mut out = String::with_capacity(scheme.len() + 2 + base.len() + reference.len()); + out.push_str(scheme); + out.push_str("//"); + if let Some(rest) = reference.strip_prefix("//") { + out.push_str(rest); + return out; + } + let authority_len = authority_and_path + .find(['/', '?', '#']) + .unwrap_or(authority_and_path.len()); + out.push_str(&authority_and_path[..authority_len]); + if reference.starts_with('/') { + out.push_str(reference); + return out; + } + let path = &authority_and_path[authority_len..]; + let path = path.split(['?', '#']).next().unwrap_or(""); + match path.rfind('/') { + Some(last) => out.push_str(&path[..=last]), + None => out.push('/'), + } + out.push_str(reference); + out +} + +pub fn origin_of(url: &str) -> Option { + let origin = url::Url::parse(url).ok()?.origin(); + if !origin.is_tuple() { + return None; + } + Some(origin.ascii_serialization()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OriginMismatch { + pub fields: Vec<&'static str>, + pub session_origin: String, + pub connect_origin: String, +} + +impl std::fmt::Display for OriginMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("session advertises ")?; + for (index, field) in self.fields.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + f.write_str(field)?; + } + write!( + f, + " on {} (connected to {}); vandelay must use the advertised URL. \ + If this is wrong, fix the server's advertised HTTP URL setting.", + self.session_origin, self.connect_origin + ) + } +} + impl Session { pub fn discover(client: &HttpClient, url: &str) -> Result { match client.get(url) { Ok(direct) => { if let Some(session) = parse_session(&direct) { - return session.ensure_authenticated(); + return session.rebased_on(url).ensure_authenticated(); } } Err(JmapError::Auth(m)) => return Err(JmapError::Auth(m)), @@ -73,7 +161,7 @@ impl Session { match client.get(&well_known) { Ok(body) => { if let Some(session) = parse_session(&body) { - return session.ensure_authenticated(); + return session.rebased_on(&well_known).ensure_authenticated(); } Err(JmapError::Connect(format!( "no JMAP Session object at {url} or {well_known}" @@ -86,6 +174,44 @@ impl Session { } } + fn rebased_on(mut self, session_resource_url: &str) -> Session { + self.api_url = resolve_reference(session_resource_url, &self.api_url); + self.upload_url = resolve_reference(session_resource_url, &self.upload_url); + self.download_url = resolve_reference(session_resource_url, &self.download_url); + self + } + + pub fn origin_mismatches(&self, connect_url: &str) -> Vec { + let Some(connect_origin) = origin_of(connect_url) else { + return Vec::new(); + }; + let mut mismatches: Vec = Vec::new(); + for (field, value) in [ + ("apiUrl", &self.api_url), + ("uploadUrl", &self.upload_url), + ("downloadUrl", &self.download_url), + ] { + let Some(session_origin) = origin_of(value) else { + continue; + }; + if session_origin == connect_origin { + continue; + } + match mismatches + .iter_mut() + .find(|m| m.session_origin == session_origin) + { + Some(existing) => existing.fields.push(field), + None => mismatches.push(OriginMismatch { + fields: vec![field], + session_origin, + connect_origin: connect_origin.clone(), + }), + } + } + mismatches + } + fn ensure_authenticated(self) -> Result { if self.accounts.is_empty() { return Err(JmapError::Auth( @@ -236,6 +362,143 @@ mod tests { )); } + #[test] + fn origin_treats_the_default_port_as_equivalent() { + assert_eq!( + origin_of("https://mail.example/jmap"), + origin_of("https://mail.example:443/jmap") + ); + assert_eq!( + origin_of("http://mail.example/jmap"), + origin_of("http://mail.example:80/jmap") + ); + } + + #[test] + fn origin_distinguishes_port_host_and_scheme() { + let base = origin_of("https://mail.example:10443/jmap"); + assert_ne!(base, origin_of("https://mail.example/jmap")); + assert_ne!(base, origin_of("https://other.example:10443/jmap")); + assert_ne!(base, origin_of("http://mail.example:10443/jmap")); + } + + fn session_on(base: &str) -> Session { + let raw = format!( + r#"{{"apiUrl":"{base}/jmap/","uploadUrl":"{base}/jmap/upload/{{accountId}}/", + "downloadUrl":"{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}", + "capabilities":{{}},"accounts":{{}}}}"# + ); + parse_session(&raw).expect("session") + } + + #[test] + fn matching_origin_reports_no_mismatch() { + let session = session_on("https://mail.example:10443"); + assert!( + session + .origin_mismatches("https://mail.example:10443") + .is_empty() + ); + assert!( + session_on("https://mail.example") + .origin_mismatches("https://mail.example:443/") + .is_empty() + ); + } + + #[test] + fn dropped_port_is_reported_once_naming_every_field() { + let session = session_on("https://mail.example"); + let mismatches = session.origin_mismatches("https://mail.example:10443"); + assert_eq!(mismatches.len(), 1, "one warning per distinct origin"); + assert_eq!( + mismatches[0].fields, + vec!["apiUrl", "uploadUrl", "downloadUrl"] + ); + assert_eq!(mismatches[0].session_origin, "https://mail.example"); + assert_eq!(mismatches[0].connect_origin, "https://mail.example:10443"); + let warning = mismatches[0].to_string(); + assert!(warning.contains("session advertises apiUrl"), "{warning}"); + assert!( + warning.contains("on https://mail.example (connected to https://mail.example:10443)"), + "{warning}" + ); + assert!(warning.contains("advertised HTTP URL setting"), "{warning}"); + } + + #[test] + fn a_split_upload_host_is_reported_separately_from_the_api_host() { + let raw = r#"{"apiUrl":"https://mail.example:10443/jmap/", + "uploadUrl":"https://blobs.example/upload/{accountId}/", + "downloadUrl":"https://blobs.example/dl/{accountId}/{blobId}/{type}/{name}", + "capabilities":{},"accounts":{}}"#; + let session = parse_session(raw).expect("session"); + let mismatches = session.origin_mismatches("https://mail.example:10443"); + assert_eq!(mismatches.len(), 1); + assert_eq!(mismatches[0].fields, vec!["uploadUrl", "downloadUrl"]); + assert_eq!(mismatches[0].session_origin, "https://blobs.example"); + } + + #[test] + fn resolve_reference_keeps_absolute_urls_verbatim() { + assert_eq!( + resolve_reference( + "https://h.example/.well-known/jmap", + "https://other.example:8443/jmap/api" + ), + "https://other.example:8443/jmap/api" + ); + } + + #[test] + fn resolve_reference_handles_scheme_relative_absolute_and_relative_paths() { + let base = "https://h.example:10443/.well-known/jmap"; + assert_eq!( + resolve_reference(base, "//other.example/jmap/api"), + "https://other.example/jmap/api" + ); + assert_eq!( + resolve_reference(base, "/jmap/api"), + "https://h.example:10443/jmap/api" + ); + assert_eq!( + resolve_reference(base, "api"), + "https://h.example:10443/.well-known/api" + ); + } + + #[test] + fn resolve_reference_preserves_uri_template_braces() { + assert_eq!( + resolve_reference( + "https://h.example/.well-known/jmap", + "/jmap/upload/{accountId}/" + ), + "https://h.example/jmap/upload/{accountId}/" + ); + } + + #[test] + fn relative_session_urls_are_resolved_against_the_session_resource() { + let raw = r#"{"apiUrl":"/jmap/","uploadUrl":"/jmap/upload/{accountId}/", + "downloadUrl":"/jmap/download/{accountId}/{blobId}/{type}/{name}", + "capabilities":{},"accounts":{}}"#; + let session = parse_session(raw) + .expect("session") + .rebased_on("https://h.example:10443/.well-known/jmap"); + assert_eq!(session.api_url, "https://h.example:10443/jmap/"); + assert_eq!( + session.upload_url_for("w"), + "https://h.example:10443/jmap/upload/w/" + ); + assert!( + session + .origin_mismatches("https://h.example:10443") + .is_empty(), + "a resolved relative URL shares the connect origin" + ); + } + #[test] fn well_known_appends_correctly() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 3a9eae4..9d35884 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use clap::Parser; use vandelay::cli::{Action, Cli}; use vandelay::error::Error; use vandelay::inspect; -use vandelay::sync::{self, Summary}; +use vandelay::sync::{self, RunOutcome, Summary}; fn main() { let code = run(); @@ -30,42 +30,54 @@ fn run() -> i32 { Err(err) => return fail(&err), }; - let result = match action { + let (outcome, logger) = match action { Action::Import(common, config) => { let logger = common.logger; - sync::import_jmap::run(common, config).map(|s| (s, logger)) + (sync::import_jmap::run_reporting(common, config), logger) } Action::ImportImap(common, config) => { let logger = common.logger; - sync::import_imap::run(common, config).map(|s| (s, logger)) + (sync::import_imap::run_reporting(common, config), logger) } Action::ImportDav(common, config) => { let logger = common.logger; - sync::import_dav::run(common, config).map(|s| (s, logger)) + (sync::import_dav::run_reporting(common, config), logger) } Action::ImportManageSieve(common, config) => { let logger = common.logger; - sync::import_managesieve::run(common, config).map(|s| (s, logger)) + ( + RunOutcome::from_result(sync::import_managesieve::run(common, config)), + logger, + ) } Action::ImportMaildir(common, config) => { let logger = common.logger; - sync::import_maildir::run(common, config).map(|s| (s, logger)) + (sync::import_maildir::run_reporting(common, config), logger) } Action::ImportTakeout(common, config) => { let logger = common.logger; - sync::import_takeout::run(common, config).map(|s| (s, logger)) + (sync::import_takeout::run_reporting(common, config), logger) } Action::ImportExchangeEws(common, config) => { let logger = common.logger; - sync::import_exchange_ews::run(common, config).map(|s| (s, logger)) + ( + RunOutcome::from_result(sync::import_exchange_ews::run(common, config)), + logger, + ) } Action::ImportExchangeGraph(common, config) => { let logger = common.logger; - sync::import_exchange_graph::run(common, config).map(|s| (s, logger)) + ( + RunOutcome::from_result(sync::import_exchange_graph::run(common, config)), + logger, + ) } Action::Export(common, config) => { let logger = common.logger; - sync::export::run(common, config).map(|s| (s, logger)) + ( + RunOutcome::from_result(sync::export::run(common, config)), + logger, + ) } Action::Inspect(config) => { return match inspect::run(config) { @@ -75,17 +87,17 @@ fn run() -> i32 { } }; - match result { - Ok((summary, logger)) => { - report(&summary); - if summary.any_failed() { + report(&outcome.summary); + match outcome.error { + Some(err) => fail(&err), + None => { + if outcome.summary.any_failed() { logger.error("some objects failed; the archive is consistent and resumable"); 5 } else { 0 } } - Err(err) => fail(&err), } } diff --git a/src/sync/export.rs b/src/sync/export.rs index b661c76..12ec7f2 100644 --- a/src/sync/export.rs +++ b/src/sync/export.rs @@ -20,7 +20,7 @@ use crate::jmap::request::{Request, SetRequest, get_all, get_objects, query_all_ use crate::jmap::session::{Limits, Session}; use crate::jmap::wire::JmapId; use crate::logging::{LEVEL_DEFAULT, Logger}; -use crate::sync::import_jmap::mapping::{BlobUpload, TargetResolver}; +use crate::sync::import_jmap::mapping::{BlobBytes, TargetResolver}; use crate::sync::{CommonConfig, Context, ExportConfig, Summary, TypeCounts}; use crate::types::ObjectType; @@ -110,9 +110,10 @@ impl<'a> Uploader<'a> { } } -impl BlobUpload for Uploader<'_> { - fn upload(&mut self, local_id: i64) -> Result { - self.upload_with(local_id, "application/octet-stream") +impl BlobBytes for Uploader<'_> { + fn bytes(&self, local_id: i64) -> Result, JmapError> { + db::blobs::blob_bytes(self.conn, local_id)? + .ok_or_else(|| JmapError::malformed(format!("blob local id {local_id} missing"))) } } @@ -172,6 +173,7 @@ pub fn run(common: CommonConfig, config: ExportConfig) -> Result ); let plan = match res { Ok(p) => p, + Err(e) if e.aborts_run() => return Err(e), Err(e) => { logger.warn(&format!("type {} aborted: {e}", ty.jmap_name())); counts.failed += 1; diff --git a/src/sync/export/email.rs b/src/sync/export/email.rs index 4d60556..1d8c7ab 100644 --- a/src/sync/export/email.rs +++ b/src/sync/export/email.rs @@ -12,7 +12,10 @@ use super::common::{jid, target_query_get}; use super::{Maps, Net, Plan, Uploader}; use crate::error::Error; use crate::jmap::error::JmapError; -use crate::jmap::request::{Request, check_method_error, get_objects}; +use crate::jmap::request::{ + MethodCall, Request, check_method_error, get_objects, retry_method_call, +}; +use crate::jmap::retry::MethodCallKind; use crate::jmap::wire::JmapId; use crate::logging::Logger; use crate::sync::import_jmap::mapping::{EMAIL_SELECT, EmailRow, TargetResolver, row_to_email}; @@ -219,7 +222,7 @@ fn export_one( return; } let item = import_item(blob, mids, build_keywords(row), &row.received_at); - match send_single_import(net, &cid, item) { + match send_single_import(net, &cid, item, logger) { Ok(SingleImport::Created) => counts.created += 1, Ok(SingleImport::Skipped) => counts.skipped += 1, Ok(SingleImport::NotCreated { error_type, .. }) if error_type == "blobNotFound" => { @@ -277,7 +280,7 @@ fn retry_after_reupload( } }; let item = import_item(blob, mids, build_keywords(row), &row.received_at); - match send_single_import(net, cid, item) { + match send_single_import(net, cid, item, logger) { Ok(SingleImport::Created) => counts.created += 1, Ok(SingleImport::Skipped) => counts.skipped += 1, Ok(SingleImport::NotCreated { detail, .. }) => { @@ -304,7 +307,12 @@ enum SingleImport { NotCreated { error_type: String, detail: String }, } -fn send_single_import(net: &Net, cid: &str, item: Value) -> Result { +fn send_single_import( + net: &Net, + cid: &str, + item: Value, + logger: &Logger, +) -> Result { let mut emails = Map::new(); emails.insert(cid.to_owned(), item); let mut req = Request::new(); @@ -314,8 +322,15 @@ fn send_single_import(net: &Net, cid: &str, item: Value) -> Result Result { check_method_error(mr)?; if let Some(err) = mr .args diff --git a/src/sync/export/uidtype.rs b/src/sync/export/uidtype.rs index 90da298..fec9bf6 100644 --- a/src/sync/export/uidtype.rs +++ b/src/sync/export/uidtype.rs @@ -5,10 +5,11 @@ */ use std::collections::HashSet; +use std::fmt::Write as _; use serde_json::Value; -use super::common::{create_batch, jid, retry_if_blob_missing, target_query_get}; +use super::common::{create_batch, jid, target_query_get}; use super::{Maps, Net, Plan, Uploader}; use crate::error::Error; use crate::logging::Logger; @@ -23,6 +24,15 @@ fn target_uid(v: &Value) -> Option { v.get("uid").and_then(Value::as_str).map(str::to_owned) } +fn describe(ty: ObjectType, local: i64, uid: &str) -> String { + let mut out = String::new(); + let _ = write!(out, "{} local {local}", ty.jmap_name()); + if !uid.is_empty() { + let _ = write!(out, " (uid {uid})"); + } + out +} + pub fn reconcile( ctx: &Context, net: &Net, @@ -66,7 +76,7 @@ pub fn reconcile( }; let mut matched_uids: HashSet = HashSet::new(); - let mut uploader = Uploader::new(net, &ctx.conn); + let blobs = Uploader::new(net, &ctx.conn); for (local, uid) in &rows { if let Some(tid) = by_uid.get(uid) { maps.insert(ty, *local, crate::jmap::wire::JmapId(tid.clone())); @@ -75,28 +85,30 @@ pub fn reconcile( continue; } let cid = format!("c{local}"); - let _ = uploader.take_touched(); - let wire = match build_wire(ctx, ty, *local, maps, &mut uploader) { + let wire = match build_wire(ctx, ty, *local, maps, &blobs) { Ok(w) => w, + Err(e) if e.aborts_run() => return Err(e), Err(e) => { - logger.warn(&format!("{} local {local} skipped: {e}", ty.jmap_name())); + logger.warn(&format!("{} skipped: {e}", describe(ty, *local, uid))); counts.failed += 1; continue; } }; - let touched = uploader.take_touched(); - let outcome = create_batch(net, ty, vec![(cid.clone(), wire)]).map_err(Error::from)?; - let outcome = - match retry_if_blob_missing(net, ty, &cid, &mut uploader, touched, outcome, |up| { - build_wire(ctx, ty, *local, maps, up) - }) { - Ok(o) => o, - Err(e) => { - logger.warn(&format!("{} local {local} skipped: {e}", ty.jmap_name())); - counts.failed += 1; - continue; + let outcome = match create_batch(net, ty, vec![(cid.clone(), wire)]) { + Ok(o) => o, + Err(e) => { + let mapped = Error::from(e); + if mapped.aborts_run() { + return Err(mapped); } - }; + logger.warn(&format!( + "{} not created: {mapped}", + describe(ty, *local, uid) + )); + counts.failed += 1; + continue; + } + }; for (cid, v) in &outcome.created { if let Some(parsed) = cid.strip_prefix('c').and_then(|s| s.parse::().ok()) && let Some(id) = jid(v) @@ -136,7 +148,7 @@ fn build_wire( ty: ObjectType, local: i64, maps: &Maps, - up: &mut Uploader<'_>, + blobs: &Uploader<'_>, ) -> Result { if ty == ObjectType::ContactCard { let (uid, abids, data): (String, String, String) = ctx @@ -147,7 +159,7 @@ fn build_wire( |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?)), ) .map_err(|e| Error::Partial(e.to_string()))?; - contact_card_to_wire(&uid, &abids, &data, maps, up).map_err(Error::from) + contact_card_to_wire(&uid, &abids, &data, maps, blobs).map_err(Error::from) } else { let (cal, dr, ud, data): (String, i64, i64, String) = ctx .conn @@ -157,6 +169,6 @@ fn build_wire( |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), ) .map_err(|e| Error::Partial(e.to_string()))?; - calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, maps, up).map_err(Error::from) + calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, maps, blobs).map_err(Error::from) } } diff --git a/src/sync/import_dav.rs b/src/sync/import_dav.rs index 2b434fb..0b2af29 100644 --- a/src/sync/import_dav.rs +++ b/src/sync/import_dav.rs @@ -10,4 +10,48 @@ pub mod coordinator; pub mod items; pub mod tree; -pub use coordinator::{DavAuth, DavImportConfig, DavKindArg, run}; +pub use coordinator::{DavAuth, DavImportConfig, DavKindArg, run, run_reporting}; + +use crate::error::Error; +use crate::jmap::error::JmapError; + +pub(crate) fn per_collection_failure(err: JmapError) -> Error { + match err { + JmapError::Sqlite(e) => Error::Db(crate::db::init::OpenError::Sqlite(e)), + scoped => Error::Partial(scoped.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_forbidden_collection_is_a_per_unit_failure() { + for e in [ + JmapError::Auth("server returned 403: forbidden".to_owned()), + JmapError::HttpStatus { + status: 405, + body: "method not allowed".to_owned(), + }, + JmapError::RetriesExhausted("PROPFIND kept returning 503".to_owned()), + JmapError::Transport("io: connection reset".to_owned()), + JmapError::Malformed("multistatus parse".to_owned()), + ] { + let mapped = per_collection_failure(e); + assert!( + !mapped.aborts_run(), + "{mapped} is scoped to one collection and must not abort the run" + ); + assert_eq!(mapped.exit_code(), 5); + } + } + + #[test] + fn an_archive_failure_still_aborts() { + let mapped = + per_collection_failure(JmapError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); + assert!(mapped.aborts_run()); + assert_eq!(mapped.exit_code(), 7); + } +} diff --git a/src/sync/import_dav/coordinator.rs b/src/sync/import_dav/coordinator.rs index bd37f20..81db85a 100644 --- a/src/sync/import_dav/coordinator.rs +++ b/src/sync/import_dav/coordinator.rs @@ -13,7 +13,7 @@ use crate::db::sources::SourceKey; use crate::error::Error; use crate::jmap::http::{Auth, RetryPolicy}; use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; -use crate::sync::{CommonConfig, Summary, TypeCounts}; +use crate::sync::{CommonConfig, RunOutcome, Summary, TypeCounts}; use super::collections; use super::items; @@ -83,6 +83,20 @@ pub struct DavImportConfig { } pub fn run(common: CommonConfig, config: DavImportConfig) -> Result { + run_reporting(common, config).into_result() +} + +pub fn run_reporting(common: CommonConfig, config: DavImportConfig) -> RunOutcome { + let mut summary = Summary::default(); + let error = run_into(common, config, &mut summary).err(); + RunOutcome { summary, error } +} + +fn run_into( + common: CommonConfig, + config: DavImportConfig, + summary: &mut Summary, +) -> Result<(), Error> { let logger = common.logger; enforce_tls_policy(&config.url, config.allow_cleartext)?; @@ -138,38 +152,42 @@ pub fn run(common: CommonConfig, config: DavImportConfig) -> Result { - run_caldav(&mut conn, &client, source_id, &discovery, &config, logger)? + run_caldav(&mut conn, &client, source_id, &discovery, &config, logger) } DavKindArg::Carddav => { - run_carddav(&mut conn, &client, source_id, &discovery, &config, logger)? + run_carddav(&mut conn, &client, source_id, &discovery, &config, logger) } DavKindArg::Webdav => { - run_webdav(&mut conn, &client, source_id, &discovery, &config, logger)? + run_webdav(&mut conn, &client, source_id, &discovery, &config, logger) } }; + *summary = phase.summary; + summary.retries_observed = client.retries_observed(); + summary.retry_after_sleeps = client.retry_after_sleeps(); + if let Some(e) = phase.error { + return Err(e); + } + if !summary.any_failed() && let Err(e) = run_gc(&conn) { logger.warn(&format!("blob GC skipped: {e}")); } - let mut summary = summary; - summary.retries_observed = client.retries_observed(); - summary.retry_after_sleeps = client.retry_after_sleeps(); - Ok(summary) + Ok(()) } type ReconcileCollections = fn( @@ -198,25 +216,71 @@ fn run_collection_phase( config: &DavImportConfig, logger: Logger, phase: ItemPhase, -) -> Result { +) -> RunOutcome { let mut summary = Summary::default(); - let mut container_counts = TypeCounts::default(); - let mut item_counts = TypeCounts::default(); + let mut counts = PhaseCounts::default(); + let error = collection_phase_into( + conn, + client, + source_id, + PhaseInput { + discovery, + config, + logger, + phase: &phase, + }, + &mut counts, + ) + .err(); + + summary + .per_type + .push((phase.container_label, counts.container)); + summary.per_type.push((phase.item_label, counts.items)); + RunOutcome { summary, error } +} + +#[derive(Default)] +struct PhaseCounts { + container: TypeCounts, + items: TypeCounts, +} + +struct PhaseInput<'a> { + discovery: &'a Discovery, + config: &'a DavImportConfig, + logger: Logger, + phase: &'a ItemPhase, +} + +fn collection_phase_into( + conn: &mut Connection, + client: &DavClient, + source_id: i64, + input: PhaseInput<'_>, + counts: &mut PhaseCounts, +) -> Result<(), Error> { + let PhaseInput { + discovery, + config, + logger, + phase, + } = input; let upserted = (phase.reconcile_collections)( conn, source_id, &discovery.collections, - &mut container_counts, + &mut counts.container, logger, )?; if logger.enabled(LEVEL_DEFAULT) { eprintln!( "import: {} done (upserted={} deleted={} failed={})", phase.container_label, - container_counts.created + container_counts.fetched, - container_counts.deleted, - container_counts.failed + counts.container.created + counts.container.fetched, + counts.container.deleted, + counts.container.failed ); } @@ -229,23 +293,19 @@ fn run_collection_phase( logger, }; for (collection_href, local_id) in &upserted { - match (phase.reconcile_items)(conn, &ctx, collection_href, *local_id, &mut item_counts) { + match (phase.reconcile_items)(conn, &ctx, collection_href, *local_id, &mut counts.items) { Ok(()) => {} + Err(e) if e.aborts_run() => return Err(e), Err(e) => { logger.warn(&format!( "{} {collection_href:?}: items failed: {e}", phase.container_label )); - item_counts.failed += 1; + counts.items.failed += 1; } } } - - summary - .per_type - .push((phase.container_label, container_counts)); - summary.per_type.push((phase.item_label, item_counts)); - Ok(summary) + Ok(()) } fn run_dry_diff( @@ -254,11 +314,11 @@ fn run_dry_diff( discovery: &Discovery, config: &DavImportConfig, logger: Logger, -) -> Result { + summary: &mut Summary, +) -> Result<(), Error> { use crate::dav::href::join_absolute; use crate::dav::xml; use crate::db::dav_ids; - let mut summary = Summary::default(); let (container_label, item_label, container_type, item_type) = match config.kind { DavKindArg::Caldav => ( "calendar", @@ -295,33 +355,57 @@ fn run_dry_diff( for coll in &discovery.collections { let url = join_absolute(&discovery.home_set_url, coll.href.as_str()) .map_err(|e| Error::Partial(e.to_string()))?; - let ms = client + match client .propfind_responses(&url, 1, &xml::propfind_dav_items(), &url) - .map_err(Error::from)?; - let new_count = ms - .responses - .iter() - .filter(|r| !r.props.is_collection) - .count(); - item_counts.created += new_count as u64; - if logger.enabled(LEVEL_DEFAULT) { - eprintln!(" {} items: {new_count}", coll.href.as_str()); + .map_err(super::per_collection_failure) + { + Ok(ms) => { + let new_count = ms + .responses + .iter() + .filter(|r| !r.props.is_collection) + .count(); + item_counts.created += new_count as u64; + if logger.enabled(LEVEL_DEFAULT) { + eprintln!(" {} items: {new_count}", coll.href.as_str()); + } + } + Err(e) if e.aborts_run() => return Err(e), + Err(e) => { + logger.warn(&format!( + "{container_label} {:?}: enumeration failed: {e}", + coll.href.as_str() + )); + item_counts.failed += 1; + } } } } else if let Some(root) = discovery.collections.first() { let url = join_absolute(&discovery.home_set_url, root.href.as_str()) .map_err(|e| Error::Partial(e.to_string()))?; - let ms = client + match client .propfind_responses(&url, 1, &xml::propfind_webdav_listing(), &url) - .map_err(Error::from)?; - let new_count = ms - .responses - .iter() - .filter(|r| !r.props.is_collection) - .count(); - item_counts.created += new_count as u64; - if logger.enabled(LEVEL_DEFAULT) { - eprintln!(" root {} files: {new_count}", root.href.as_str()); + .map_err(super::per_collection_failure) + { + Ok(ms) => { + let new_count = ms + .responses + .iter() + .filter(|r| !r.props.is_collection) + .count(); + item_counts.created += new_count as u64; + if logger.enabled(LEVEL_DEFAULT) { + eprintln!(" root {} files: {new_count}", root.href.as_str()); + } + } + Err(e) if e.aborts_run() => return Err(e), + Err(e) => { + logger.warn(&format!( + "{container_label} {:?}: enumeration failed: {e}", + root.href.as_str() + )); + container_counts.failed += 1; + } } } @@ -332,7 +416,7 @@ fn run_dry_diff( if !matches!(config.kind, DavKindArg::Webdav) { summary.per_type.push((item_label, item_counts)); } - Ok(summary) + Ok(()) } fn run_caldav( @@ -342,7 +426,7 @@ fn run_caldav( discovery: &Discovery, config: &DavImportConfig, logger: Logger, -) -> Result { +) -> RunOutcome { run_collection_phase( conn, client, @@ -366,7 +450,7 @@ fn run_carddav( discovery: &Discovery, config: &DavImportConfig, logger: Logger, -) -> Result { +) -> RunOutcome { run_collection_phase( conn, client, @@ -390,13 +474,16 @@ fn run_webdav( discovery: &Discovery, config: &DavImportConfig, logger: Logger, -) -> Result { +) -> RunOutcome { let mut summary = Summary::default(); let mut file_counts = TypeCounts::default(); if discovery.collections.is_empty() { summary.per_type.push(("filenode", file_counts)); - return Ok(summary); + return RunOutcome { + summary, + error: None, + }; } let root = &discovery.collections[0]; let ctx = tree::WebDavCtx { @@ -406,10 +493,10 @@ fn run_webdav( dav_connections: config.dav_connections, logger, }; - tree::reconcile_filenodes(conn, &ctx, root, &mut file_counts)?; + let error = tree::reconcile_filenodes(conn, &ctx, root, &mut file_counts).err(); summary.per_type.push(("filenode", file_counts)); - Ok(summary) + RunOutcome { summary, error } } fn map_discovery_error(err: DiscoveryError) -> Error { diff --git a/src/sync/import_dav/items.rs b/src/sync/import_dav/items.rs index fed37df..dac1ffc 100644 --- a/src/sync/import_dav/items.rs +++ b/src/sync/import_dav/items.rs @@ -113,7 +113,7 @@ fn enumerate_items(client: &DavClient, url: &str) -> Result, Err let body = xml::propfind_dav_items(); let ms = client .propfind_responses(url, 1, &body, url) - .map_err(Error::from)?; + .map_err(super::per_collection_failure)?; if ms.status >= 400 { return Err(Error::Partial(format!( "enumerate {url}: http {}", diff --git a/src/sync/import_dav/tree.rs b/src/sync/import_dav/tree.rs index e2a230d..1984f06 100644 --- a/src/sync/import_dav/tree.rs +++ b/src/sync/import_dav/tree.rs @@ -115,6 +115,7 @@ pub fn reconcile_filenodes( } } } + Err(e) if e.aborts_run() => return Err(e), Err(e) => { logger.warn(&format!("PROPFIND {url}: {e}")); counts.failed += 1; @@ -394,7 +395,7 @@ fn walk_one( let body = xml::propfind_webdav_listing(); let ms = client .propfind_responses(url, 1, &body, url) - .map_err(Error::from)?; + .map_err(super::per_collection_failure)?; if ms.status >= 400 { return Err(Error::Partial(format!("http {}", ms.status))); } diff --git a/src/sync/import_exchange_graph/calendar.rs b/src/sync/import_exchange_graph/calendar.rs index 9f4981e..e20c830 100644 --- a/src/sync/import_exchange_graph/calendar.rs +++ b/src/sync/import_exchange_graph/calendar.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use crate::db::exchange_graph_ids; use crate::error::Error; +use crate::exchange::jscalendar::override_patch_from_event; use crate::exchange_graph::api::{self, PREFER_BODY_HTML, PREFER_BODY_TEXT, PREFER_TIMEZONE_UTC}; use crate::exchange_graph::calendar_map::{ ConvertedEvent, EventType, classify_event_type, convert_event, @@ -185,7 +186,7 @@ fn merge_exception_into(master: &mut ConvertedEvent, ex: &ConvertedEvent) { let Value::Object(overrides) = overrides else { return; }; - overrides.insert(key, ex.data.clone()); + overrides.insert(key, override_patch_from_event(&ex.data)); } fn merge_exception_into_existing( @@ -253,7 +254,7 @@ fn merge_persisted_master( .entry("recurrenceOverrides".to_owned()) .or_insert_with(|| Value::Object(serde_json::Map::new())); if let Value::Object(overrides) = entry { - overrides.insert(key, ex_data.clone()); + overrides.insert(key, override_patch_from_event(ex_data)); } } tx.execute( diff --git a/src/sync/import_exchange_graph/coordinator.rs b/src/sync/import_exchange_graph/coordinator.rs index f242087..9e2fc5f 100644 --- a/src/sync/import_exchange_graph/coordinator.rs +++ b/src/sync/import_exchange_graph/coordinator.rs @@ -16,11 +16,10 @@ use crate::exchange_graph::error::GraphError; use crate::exchange_graph::oauth::{ AcquiredToken, OAuthFlow, acquire, default_authority, refresh_access_token, }; -use crate::exchange_graph::types::{EventBodyFormat, MailboxKind, synthetic_account_id}; +use crate::exchange_graph::types::{EventBodyFormat, MailboxKind, Surfaces, synthetic_account_id}; use crate::jmap::http::RetryPolicy; use crate::logging::LEVEL_DEFAULT; use crate::sync::{CommonConfig, Summary, TypeCounts}; -use crate::types::ObjectType; #[derive(Debug, Clone)] pub enum GraphAuth { @@ -39,7 +38,7 @@ pub struct GraphImportConfig { pub api_base: String, pub user_target: Option, pub mailbox_kind: MailboxKind, - pub objects: Option>, + pub surfaces: Surfaces, pub event_body_format: EventBodyFormat, pub graph_connections: usize, pub top: usize, @@ -141,32 +140,10 @@ pub fn run(common: CommonConfig, config: GraphImportConfig) -> Result Result { + run_reporting(common, config).into_result() +} + +pub fn run_reporting(common: CommonConfig, config: ImapImportConfig) -> RunOutcome { + let mut summary = Summary::default(); + let error = run_into(common, config, &mut summary).err(); + RunOutcome { summary, error } +} + +fn run_into( + common: CommonConfig, + config: ImapImportConfig, + summary: &mut Summary, +) -> Result<(), Error> { let logger = common.logger; let mut conn = db::init::open(&common.archive)?; @@ -341,14 +355,15 @@ pub fn run(common: CommonConfig, config: ImapImportConfig) -> Result Result {} + Err(e) if e.aborts_run() => { + pool.shutdown(); + let _ = client.logout(); + *summary = Summary { + per_type: vec![("mailbox", mailbox_counts), ("email", email_counts)], + retries_observed: backoff.total_retries(), + retry_after_sleeps: backoff.transient_retries() as u64, + }; + return Err(e); + } Err(e) => { log_at( logger, @@ -424,11 +449,12 @@ pub fn run(common: CommonConfig, config: ImapImportConfig) -> Result String { } pub fn run(common: CommonConfig, config: ImportConfig) -> Result { + run_reporting(common, config).into_result() +} + +pub fn run_reporting(common: CommonConfig, config: ImportConfig) -> RunOutcome { + let mut summary = Summary::default(); + let error = run_into(common, config, &mut summary).err(); + RunOutcome { summary, error } +} + +fn run_into( + common: CommonConfig, + config: ImportConfig, + summary: &mut Summary, +) -> Result<(), Error> { let logger = common.logger; let ctx = Context::open(common, &config.connect)?; let connected = connect::prepare(&ctx, &config.connect)?; @@ -196,7 +210,6 @@ pub fn run(common: CommonConfig, config: ImportConfig) -> Result session: connected.session.clone(), }; - let mut summary = Summary::default(); let mut dry_rows: Vec<(&'static str, u64, u64, u64)> = Vec::new(); let threads = ctx.common.threads; @@ -216,6 +229,7 @@ pub fn run(common: CommonConfig, config: ImportConfig) -> Result &mut dry_rows, ) { Ok(()) => {} + Err(e) if e.aborts_run() => return Err(e), Err(e) => { logger.warn(&format!( "type {} aborted: {e}; continuing (run will exit 5)", @@ -229,7 +243,8 @@ pub fn run(common: CommonConfig, config: ImportConfig) -> Result if ctx.dry_run() { print_dry_run(&dry_rows); - return Ok(Summary::default()); + *summary = Summary::default(); + return Ok(()); } if !summary.any_failed() @@ -240,7 +255,7 @@ pub fn run(common: CommonConfig, config: ImportConfig) -> Result summary.retries_observed = ctx.client.retries_observed(); summary.retry_after_sleeps = ctx.client.retry_after_sleeps(); - Ok(summary) + Ok(()) } fn work_list(config: &ImportConfig, connected: &Connected) -> Vec { diff --git a/src/sync/import_jmap/mapping.rs b/src/sync/import_jmap/mapping.rs index 0727b97..e203407 100644 --- a/src/sync/import_jmap/mapping.rs +++ b/src/sync/import_jmap/mapping.rs @@ -8,7 +8,7 @@ use indexmap::IndexMap; use rusqlite::{Connection, Row, params}; use serde_json::{Map, Value}; -use crate::jmap::blob::{export_blob_ids, import_blob_ids}; +use crate::jmap::blob::{BlobWalkError, InlineShape, import_blob_ids, inline_blob_data_uris}; use crate::jmap::error::JmapError; use crate::jmap::wire::JmapId; use crate::jmap::wire::address_book::AddressBook; @@ -35,8 +35,8 @@ pub trait BlobIntern { fn intern(&mut self, jmap_blob_id: &str) -> Result; } -pub trait BlobUpload { - fn upload(&mut self, local_id: i64) -> Result; +pub trait BlobBytes { + fn bytes(&self, local_id: i64) -> Result, JmapError>; } fn translate_in( @@ -662,10 +662,10 @@ pub fn contact_card_to_wire( address_book_ids: &str, data: &str, resolver: &impl TargetResolver, - blobs: &mut impl BlobUpload, + blobs: &impl BlobBytes, ) -> Result { let mut value: Value = serde_json::from_str(data)?; - restore_blobs_in(&mut value, blobs)?; + inline_blobs_in(&mut value, InlineShape::JsContactResource, blobs)?; let locals = parse_local_id_array(address_book_ids)?; let abids = translate_out(&locals, ObjectType::AddressBook, resolver)?; if let Value::Object(map) = &mut value { @@ -681,10 +681,10 @@ pub fn calendar_event_to_wire( use_default_alerts: bool, data: &str, resolver: &impl TargetResolver, - blobs: &mut impl BlobUpload, + blobs: &impl BlobBytes, ) -> Result { let mut value: Value = serde_json::from_str(data)?; - restore_blobs_in(&mut value, blobs)?; + inline_blobs_in(&mut value, InlineShape::JsCalendarLink, blobs)?; let locals = parse_local_id_array(calendar_ids)?; let calids = translate_out(&locals, ObjectType::Calendar, resolver)?; if let Value::Object(map) = &mut value { @@ -725,21 +725,20 @@ fn take_string(value: &mut Value, key: &str) -> Option { fn rewrite_blobs_in(data: &mut Value, blobs: &mut impl BlobIntern) -> Result<(), JmapError> { import_blob_ids(data, |jmap_blob_id| { - blobs - .intern(jmap_blob_id) - .map_err(|e| crate::jmap::blob::BlobWalkError::Resolver(e.to_string())) + blobs.intern(jmap_blob_id).map_err(BlobWalkError::resolver) }) - .map_err(JmapError::from) + .map_err(BlobWalkError::into_source) } -fn restore_blobs_in(data: &mut Value, blobs: &mut impl BlobUpload) -> Result<(), JmapError> { - export_blob_ids(data, |local_id| { - blobs - .upload(local_id) - .map(|id| id.0) - .map_err(|e| crate::jmap::blob::BlobWalkError::Resolver(e.to_string())) +fn inline_blobs_in( + data: &mut Value, + shape: InlineShape, + blobs: &impl BlobBytes, +) -> Result<(), JmapError> { + inline_blob_data_uris(data, shape, |local_id| { + blobs.bytes(local_id).map_err(BlobWalkError::resolver) }) - .map_err(JmapError::from) + .map_err(BlobWalkError::into_source) } fn opt_json(value: &Option) -> Result, JmapError> { @@ -904,9 +903,35 @@ mod tests { Ok(42) } } - impl BlobUpload for FakeBlobs { - fn upload(&mut self, local_id: i64) -> Result { - Ok(JmapId(format!("T{local_id}"))) + impl BlobBytes for FakeBlobs { + fn bytes(&self, local_id: i64) -> Result, JmapError> { + Ok(format!("payload-{local_id}").into_bytes()) + } + } + + fn decode_data_uri(value: &Value, media_type: &str) -> Vec { + use base64::Engine; + let uri = value.as_str().unwrap_or_else(|| panic!("{value} is a URI")); + let prefix = format!("data:{media_type};base64,"); + let payload = uri + .strip_prefix(&prefix) + .unwrap_or_else(|| panic!("{uri} does not start with {prefix}")); + base64::engine::general_purpose::STANDARD + .decode(payload) + .expect("base64 payload") + } + + fn assert_no_blob_id(value: &Value) { + match value { + Value::Object(map) => { + assert!(map.get("blobId").is_none(), "blobId present in {value}"); + assert!(map.get("@blob").is_none(), "@blob present in {value}"); + for child in map.values() { + assert_no_blob_id(child); + } + } + Value::Array(items) => items.iter().for_each(assert_no_blob_id), + _ => {} } } @@ -1032,7 +1057,7 @@ mod tests { } #[test] - fn contact_card_strips_uid_and_rewrites_blob() { + fn contact_card_strips_uid_and_inlines_media_as_data_uri() { let c = mem(); let res = MapResolver { to_local: HashMap::from([((ObjectType::AddressBook, "AB".to_owned()), 1)]), @@ -1043,7 +1068,10 @@ mod tests { "addressBookIds": { "AB": true }, "uid": "urn:uuid:42", "name": { "full": "Jane" }, - "photos": { "p1": { "blobId": "PB", "mediaType": "image/png" } } + "media": { "photo": { + "@type": "Media", "kind": "photo", + "blobId": "PB", "mediaType": "image/png" + } } })) .unwrap(); let mut blobs = FakeBlobs; @@ -1058,14 +1086,127 @@ mod tests { assert_eq!(uid, "urn:uuid:42"); let stored: Value = serde_json::from_str(&data).unwrap(); assert!(stored.get("uid").is_none()); - assert_eq!(stored["photos"]["p1"]["@blob"], Value::from(42)); + assert_eq!(stored["media"]["photo"]["@blob"], Value::from(42)); - let mut up = FakeBlobs; - let wire = contact_card_to_wire(&uid, &abids, &data, &res, &mut up).unwrap(); + let up = FakeBlobs; + let wire = contact_card_to_wire(&uid, &abids, &data, &res, &up).unwrap(); assert_eq!(wire["uid"], Value::from("urn:uuid:42")); assert_eq!(wire["addressBookIds"]["TAB"], Value::Bool(true)); - assert_eq!(wire["photos"]["p1"]["blobId"], Value::from("T42")); - assert!(wire["photos"]["p1"].get("@blob").is_none()); + assert_eq!( + decode_data_uri(&wire["media"]["photo"]["uri"], "image/png"), + b"payload-42" + ); + assert_eq!( + wire["media"]["photo"]["mediaType"], + Value::from("image/png") + ); + assert!(wire["media"]["photo"].get("href").is_none()); + assert_no_blob_id(&wire); + } + + #[test] + fn calendar_event_inlines_link_enclosure_as_data_uri() { + let c = mem(); + let res = MapResolver { + to_local: HashMap::from([((ObjectType::Calendar, "CAL".to_owned()), 5)]), + to_target: HashMap::from([((ObjectType::Calendar, 5), "TCAL".to_owned())]), + }; + let ev: CalendarEvent = serde_json::from_value(serde_json::json!({ + "id": "EV1", + "calendarIds": { "CAL": true }, + "uid": "ev-with-enclosure", + "title": "Review", + "@type": "Event", + "links": { "1": { + "@type": "Link", "rel": "enclosure", + "blobId": "AB", "contentType": "text/plain", "title": "agenda.txt" + } } + })) + .unwrap(); + let mut blobs = FakeBlobs; + let local = insert_calendar_event(&c, &ev, &res, &mut blobs).unwrap(); + let (cal, dr, ud, data): (String, i64, i64, String) = c + .query_row( + &format!("{CALENDAR_EVENT_SELECT} AND id = ?1"), + params![local], + |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + let stored: Value = serde_json::from_str(&data).unwrap(); + assert_eq!(stored["links"]["1"]["@blob"], Value::from(42)); + + let up = FakeBlobs; + let wire = calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, &res, &up).unwrap(); + assert_eq!( + decode_data_uri(&wire["links"]["1"]["href"], "text/plain"), + b"payload-42" + ); + assert_eq!(wire["links"]["1"]["contentType"], Value::from("text/plain")); + assert_eq!(wire["links"]["1"]["rel"], Value::from("enclosure")); + assert!(wire["links"]["1"].get("uri").is_none()); + assert_no_blob_id(&wire); + } + + #[test] + fn contact_card_media_without_media_type_defaults_to_octet_stream() { + let c = mem(); + let res = MapResolver { + to_local: HashMap::from([((ObjectType::AddressBook, "AB".to_owned()), 1)]), + to_target: HashMap::from([((ObjectType::AddressBook, 1), "TAB".to_owned())]), + }; + c.execute( + "INSERT INTO contact_cards (id,uid,address_book_ids,data) + VALUES (1,'u-1','[1]',?1)", + params![ + serde_json::json!({ "@type": "Card", "media": { "photo": { "@blob": 9 } } }) + .to_string() + ], + ) + .unwrap(); + let (uid, abids, data): (String, String, String) = c + .query_row(&format!("{CONTACT_CARD_SELECT} WHERE id = 1"), [], |r| { + Ok((r.get(1)?, r.get(2)?, r.get(3)?)) + }) + .unwrap(); + let up = FakeBlobs; + let wire = contact_card_to_wire(&uid, &abids, &data, &res, &up).unwrap(); + assert_eq!( + decode_data_uri(&wire["media"]["photo"]["uri"], "application/octet-stream"), + b"payload-9" + ); + assert_no_blob_id(&wire); + } + + #[test] + fn an_archive_read_failure_while_inlining_is_an_archive_error_not_a_unit_failure() { + struct BrokenBlobs; + impl BlobBytes for BrokenBlobs { + fn bytes(&self, _local_id: i64) -> Result, JmapError> { + Err(JmapError::Sqlite(rusqlite::Error::QueryReturnedNoRows)) + } + } + let res = MapResolver { + to_local: HashMap::new(), + to_target: HashMap::from([ + ((ObjectType::AddressBook, 1), "TAB".to_owned()), + ((ObjectType::Calendar, 1), "TCAL".to_owned()), + ]), + }; + let card = serde_json::json!({ "@type": "Card", "media": { "photo": { "@blob": 9 } } }) + .to_string(); + let event = + serde_json::json!({ "@type": "Event", "links": { "1": { "@blob": 9 } } }).to_string(); + let failures = [ + contact_card_to_wire("u-1", "[1]", &card, &res, &BrokenBlobs).expect_err("card"), + calendar_event_to_wire("[1]", false, false, &event, &res, &BrokenBlobs) + .expect_err("event"), + ]; + for err in failures { + assert!(matches!(err, JmapError::Sqlite(_)), "{err:?}"); + let mapped = crate::error::Error::from(err); + assert!(mapped.aborts_run(), "{mapped} must abort the run"); + assert_eq!(mapped.exit_code(), 7); + } } #[test] @@ -1103,8 +1244,8 @@ mod tests { assert!(stored.get("calendarIds").is_none()); assert_eq!(stored["title"], Value::from("Sprint")); - let mut up = FakeBlobs; - let wire = calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, &res, &mut up).unwrap(); + let up = FakeBlobs; + let wire = calendar_event_to_wire(&cal, dr != 0, ud != 0, &data, &res, &up).unwrap(); assert_eq!(wire["calendarIds"]["TCAL"], Value::Bool(true)); assert_eq!(wire["isDraft"], Value::Bool(true)); assert_eq!(wire["title"], Value::from("Sprint")); diff --git a/src/sync/import_maildir.rs b/src/sync/import_maildir.rs index 8590d50..2ebf43b 100644 --- a/src/sync/import_maildir.rs +++ b/src/sync/import_maildir.rs @@ -9,4 +9,4 @@ pub mod keywords; pub mod messages; pub mod tree; -pub use coordinator::{MaildirImportConfig, run}; +pub use coordinator::{MaildirImportConfig, run, run_reporting}; diff --git a/src/sync/import_maildir/coordinator.rs b/src/sync/import_maildir/coordinator.rs index 2ff08f0..2eaafe2 100644 --- a/src/sync/import_maildir/coordinator.rs +++ b/src/sync/import_maildir/coordinator.rs @@ -15,7 +15,7 @@ use crate::db; use crate::db::sources::SourceKey; use crate::error::Error; use crate::logging::{LEVEL_DEFAULT, LEVEL_PROGRESS, Logger}; -use crate::sync::{CommonConfig, Summary, TypeCounts}; +use crate::sync::{CommonConfig, RunOutcome, Summary, TypeCounts}; use super::messages; use super::tree; @@ -32,6 +32,20 @@ pub struct MaildirImportConfig { } pub fn run(common: CommonConfig, config: MaildirImportConfig) -> Result { + run_reporting(common, config).into_result() +} + +pub fn run_reporting(common: CommonConfig, config: MaildirImportConfig) -> RunOutcome { + let mut summary = Summary::default(); + let error = run_into(common, config, &mut summary).err(); + RunOutcome { summary, error } +} + +fn run_into( + common: CommonConfig, + config: MaildirImportConfig, + summary: &mut Summary, +) -> Result<(), Error> { let logger = common.logger; if common.threads > 1 { log_at( @@ -121,8 +135,8 @@ pub fn run(common: CommonConfig, config: MaildirImportConfig) -> Result Result Result Result { + run_reporting(common, config).into_result() +} + +pub fn run_reporting(common: CommonConfig, config: TakeoutImportConfig) -> RunOutcome { + let mut summary = Summary::default(); + let error = run_into(common, config, &mut summary).err(); + RunOutcome { summary, error } +} + +fn run_into( + common: CommonConfig, + config: TakeoutImportConfig, + summary: &mut Summary, +) -> Result<(), Error> { let logger = common.logger; if common.threads > 1 && logger.enabled(LEVEL_PROGRESS) { eprintln!("takeout importer is single-threaded; --threads value will be ignored"); @@ -90,7 +104,8 @@ pub fn run(common: CommonConfig, config: TakeoutImportConfig) -> Result Result = takeout_ids::all_for_type(&conn, source_id, takeout_ids::MAILBOX)?; - process_mbox_files( + let mut aborted: Option = None; + if let Err(e) = process_mbox_files( &mut conn, source_id, &walk_result, @@ -116,27 +132,38 @@ pub fn run(common: CommonConfig, config: TakeoutImportConfig) -> Result Result Result Err(e), + None => Ok(()), + } } #[allow(clippy::too_many_arguments)] @@ -212,6 +243,7 @@ fn process_mbox_files( }; match mail::process_file(conn, &file.path, ctx, mailbox_counts, email_counts, logger) { Ok(()) => {} + Err(e) if e.aborts_run() => return Err(e), Err(e) => { logger.warn(&format!("mbox {:?}: {e}", file.path)); email_counts.failed += 1; @@ -244,6 +276,9 @@ fn process_ics_files( event_counts, logger, ) { + if e.aborts_run() { + return Err(e); + } logger.warn(&format!("ics {:?}: {e}", file.path)); event_counts.failed += 1; } @@ -274,6 +309,9 @@ fn process_vcf_files( card_counts, logger, ) { + if e.aborts_run() { + return Err(e); + } logger.warn(&format!("vcf {:?}: {e}", file.path)); card_counts.failed += 1; } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 31bbe73..e01de3b 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -94,6 +94,34 @@ impl Summary { } } +#[derive(Debug)] +pub struct RunOutcome { + pub summary: Summary, + pub error: Option, +} + +impl RunOutcome { + pub fn from_result(result: Result) -> RunOutcome { + match result { + Ok(summary) => RunOutcome { + summary, + error: None, + }, + Err(error) => RunOutcome { + summary: Summary::default(), + error: Some(error), + }, + } + } + + pub fn into_result(self) -> Result { + match self.error { + Some(error) => Err(error), + None => Ok(self.summary), + } + } +} + pub struct Context { pub conn: Connection, pub client: HttpClient, @@ -119,3 +147,28 @@ impl Context { self.common.dry_run } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_aborted_run_keeps_its_error_and_its_partial_counts() { + let mut summary = Summary::default(); + summary.per_type.push(("Mailbox", TypeCounts::default())); + let outcome = RunOutcome { + summary, + error: Some(Error::Connection("http status 404".to_owned())), + }; + assert_eq!(outcome.summary.per_type.len(), 1); + let err = outcome.into_result().expect_err("aborted"); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn a_result_without_a_native_outcome_reports_nothing_on_abort() { + let outcome = RunOutcome::from_result(Err(Error::Partial("one object".to_owned()))); + assert!(outcome.summary.per_type.is_empty()); + assert!(outcome.error.is_some()); + } +} diff --git a/tests/integration/stalwart.rs b/tests/integration/stalwart.rs index 0ad4a45..9ae7550 100644 --- a/tests/integration/stalwart.rs +++ b/tests/integration/stalwart.rs @@ -151,7 +151,10 @@ fn sweep_abandoned_containers() { return; } eprintln!("removing {} abandoned test container(s)", ids.len()); - let _ = Command::new("docker").args(["rm", "-f"]).args(&ids).output(); + let _ = Command::new("docker") + .args(["rm", "-f"]) + .args(&ids) + .output(); } fn pick_free_port() -> ContainerResult { diff --git a/tests/mock_dav.rs b/tests/mock_dav.rs index 41e0581..3d7fa83 100644 --- a/tests/mock_dav.rs +++ b/tests/mock_dav.rs @@ -1595,3 +1595,234 @@ fn mixed_source_archive_allows_distinct_kinds_against_same_url() { let no_conflict = sources::conflicting_source(&conn, "caldav", "https://x", "alice").unwrap(); assert!(no_conflict.is_none()); } + +fn dav_archive() -> std::path::PathBuf { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut p = std::env::temp_dir(); + p.push(format!( + "vandelay-mockdav-{}-{:?}-{n}.sqlite", + std::process::id(), + std::thread::current().id(), + )); + let _ = std::fs::remove_file(&p); + p +} + +fn caldav_import_config(url: &str) -> vandelay::sync::import_dav::DavImportConfig { + vandelay::sync::import_dav::DavImportConfig { + kind: vandelay::sync::import_dav::DavKindArg::Caldav, + url: url.to_owned(), + auth: vandelay::sync::import_dav::DavAuth::Basic { + user: "u".to_owned(), + password: "p".to_owned(), + }, + allow_cleartext: true, + dav_connections: 1, + multiget_batch: 8, + allow_source_change: false, + } +} + +fn dav_common(archive: &std::path::Path) -> vandelay::sync::CommonConfig { + vandelay::sync::CommonConfig { + archive: archive.to_path_buf(), + threads: 1, + dry_run: false, + max_retries: 0, + allow_invalid_certs: false, + logger: vandelay::logging::Logger::from_flags(true, 0), + } +} + +fn row_count(archive: &std::path::Path, table: &str) -> i64 { + let conn = rusqlite::Connection::open(archive).expect("open archive"); + conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0)) + .expect("count") +} + +#[test] +fn forbidden_collection_is_a_per_unit_failure_and_the_others_still_import() { + let mut server = mockito::Server::new(); + let url = server.url(); + + let home_set = format!( + r#" + + + {url}/dav/cal/u/work/ + + + + Work + + HTTP/1.1 200 OK + + + + {url}/dav/cal/u/shared/ + + + + Shared + + HTTP/1.1 200 OK + + +"# + ); + let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set); + + let work_items = format!( + r#" + + + {url}/dav/cal/u/work/e1.ics + + "v1" + HTTP/1.1 200 OK + + +"# + ); + let _work_enumeration = + multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/work/", &work_items); + + let work_data = format!( + r#" + + + {url}/dav/cal/u/work/e1.ics + + + "v1" + BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:e1@example.com +DTSTAMP:20260101T000000Z +DTSTART:20260101T090000Z +DTEND:20260101T100000Z +SUMMARY:Standup +END:VEVENT +END:VCALENDAR + + + HTTP/1.1 200 OK + + +"# + ); + let _work_multiget = + multistatus_response(&mut server, "REPORT", "/dav/cal/u/work/", &work_data); + + let forbidden = server + .mock("PROPFIND", "/dav/cal/u/shared/") + .with_status(403) + .with_header("content-type", "text/plain") + .with_body("you may not read this calendar") + .expect_at_least(1) + .create(); + + let archive = dav_archive(); + let summary = vandelay::sync::import_dav::run( + dav_common(&archive), + caldav_import_config(&format!("{url}/dav/cal/u/")), + ) + .expect("a forbidden collection must not abort the run"); + + forbidden.assert(); + + let events = summary + .per_type + .iter() + .find(|(t, _)| *t == "calendarevent") + .map(|(_, c)| c.clone()) + .expect("calendarevent counts"); + assert_eq!(events.failed, 1, "the forbidden calendar counts as failed"); + assert_eq!(events.fetched, 1, "the readable calendar still imported"); + assert!( + summary.any_failed(), + "a per-unit failure ends the run at exit 5" + ); + + assert_eq!(row_count(&archive, "calendars"), 2); + assert_eq!(row_count(&archive, "calendar_events"), 1); + let _ = std::fs::remove_file(&archive); +} + +#[test] +fn credentials_rejected_during_discovery_still_aborts_the_run() { + let mut server = mockito::Server::new(); + let url = server.url(); + + let _rejected = server + .mock("PROPFIND", mockito::Matcher::Any) + .with_status(401) + .with_header("content-type", "text/plain") + .with_body("bad password") + .expect_at_least(1) + .create(); + + let archive = dav_archive(); + let err = vandelay::sync::import_dav::run( + dav_common(&archive), + caldav_import_config(&format!("{url}/dav/cal/u/")), + ) + .expect_err("credentials rejected for the principal aborts the run"); + + assert!( + matches!(err, vandelay::error::Error::Connection(_)), + "discovery-level auth rejection is a whole-run failure, got {err:?}" + ); + assert_eq!(err.exit_code(), 2); + let _ = std::fs::remove_file(&archive); +} + +#[test] +fn dav_import_reports_both_phases_when_every_collection_fails() { + let mut server = mockito::Server::new(); + let url = server.url(); + + let home_set = format!( + r#" + + + {url}/dav/cal/u/work/ + + + + Work + + HTTP/1.1 200 OK + + +"# + ); + let _discovery = multistatus_response(&mut server, "PROPFIND", "/dav/cal/u/", &home_set); + let _forbidden = server + .mock("PROPFIND", "/dav/cal/u/work/") + .with_status(403) + .with_body("nope") + .expect_at_least(1) + .create(); + + let archive = dav_archive(); + let outcome = vandelay::sync::import_dav::run_reporting( + dav_common(&archive), + caldav_import_config(&format!("{url}/dav/cal/u/")), + ); + assert!(outcome.error.is_none()); + assert_eq!( + outcome + .summary + .per_type + .iter() + .map(|(t, _)| *t) + .collect::>(), + vec!["calendar", "calendarevent"], + "both phases are reported even when every collection failed" + ); + let _ = std::fs::remove_file(&archive); +} diff --git a/tests/mock_exchange_graph.rs b/tests/mock_exchange_graph.rs index 044813a..c8ee9a2 100644 --- a/tests/mock_exchange_graph.rs +++ b/tests/mock_exchange_graph.rs @@ -20,6 +20,7 @@ use vandelay::exchange_graph::oauth::{ }; use vandelay::exchange_graph::recurrence::convert_patterned_recurrence; use vandelay::exchange_graph::retry::{HttpClass, classify_http_status}; +use vandelay::exchange_graph::types::Surfaces; use vandelay::jmap::http::RetryPolicy; static INIT: Once = Once::new(); @@ -584,7 +585,7 @@ fn integration_dry_run_against_mock_server_lists_three_surfaces() { api_base: base.clone(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: None, + surfaces: Surfaces::ALL, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -693,10 +694,7 @@ fn integration_full_run_mail_only_imports_mime_via_value() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -792,10 +790,7 @@ fn integration_duplicate_message_id_does_not_abort_run() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -884,10 +879,7 @@ fn integration_full_run_is_convergent_on_second_invocation() { api_base: api_base.clone(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -966,7 +958,7 @@ fn source_change_protection_refuses_a_different_account() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: None, + surfaces: Surfaces::ALL, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -1016,10 +1008,14 @@ fn make_common(archive: std::path::PathBuf) -> vandelay::sync::CommonConfig { } } +fn surfaces(list: &str) -> vandelay::exchange_graph::types::Surfaces { + vandelay::exchange_graph::types::Surfaces::parse_list(list).unwrap() +} + fn make_config( api_base: String, user_target: Option, - objects: Option>, + surfaces: vandelay::exchange_graph::types::Surfaces, ) -> vandelay::sync::import_exchange_graph::GraphImportConfig { vandelay::sync::import_exchange_graph::GraphImportConfig { auth: vandelay::sync::import_exchange_graph::GraphAuth::PreAcquired { @@ -1028,7 +1024,7 @@ fn make_config( api_base, user_target, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects, + surfaces, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -1094,7 +1090,7 @@ fn users_upn_routes_through_users_segment_not_me() { }; let summary = vandelay::sync::import_exchange_graph::run( common, - make_config(base, Some("alice@x.com".to_owned()), None), + make_config(base, Some("alice@x.com".to_owned()), Surfaces::ALL), ) .unwrap(); let mailbox = summary @@ -1193,14 +1189,7 @@ fn series_master_with_exception_merges_into_recurrence_overrides() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let summary = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), - make_config( - base, - None, - Some(vec![ - vandelay::types::ObjectType::Calendar, - vandelay::types::ObjectType::CalendarEvent, - ]), - ), + make_config(base, None, surfaces("calendar")), ) .unwrap(); let events = summary @@ -1227,6 +1216,26 @@ fn series_master_with_exception_merges_into_recurrence_overrides() { ); let override_data = &overrides["2026-05-11T15:00:00"]; assert_eq!(override_data["title"], "Moved sync"); + assert_eq!(override_data["start"], "2026-05-11T16:00:00"); + for ignored in [ + "@type", + "uid", + "method", + "organizerCalendarAddress", + "privacy", + "prodId", + "recurrenceId", + "recurrenceIdTimeZone", + "sentBy", + "recurrenceRule", + "recurrenceOverrides", + "relatedTo", + ] { + assert!( + override_data.get(ignored).is_none(), + "{ignored} must not appear in a PatchObject (jscalendarbis 3.3.4); got {override_data}" + ); + } } #[test] @@ -1285,14 +1294,7 @@ fn occurrence_event_is_skipped_no_row_no_id_mapping() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let summary = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), - make_config( - base, - None, - Some(vec![ - vandelay::types::ObjectType::Calendar, - vandelay::types::ObjectType::CalendarEvent, - ]), - ), + make_config(base, None, surfaces("calendar")), ) .unwrap(); let events = summary @@ -1372,14 +1374,7 @@ fn hidden_mail_folder_has_is_subscribed_zero() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let _ = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), - make_config( - base, - None, - Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), - ), + make_config(base, None, surfaces("mail")), ) .unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); @@ -1473,14 +1468,7 @@ fn well_known_folder_probes_assign_jmap_roles() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let _ = vandelay::sync::import_exchange_graph::run( make_common(archive.clone()), - make_config( - base, - None, - Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), - ), + make_config(base, None, surfaces("mail")), ) .unwrap(); let conn = vandelay::db::init::open(&archive).unwrap(); @@ -1557,14 +1545,7 @@ SGVsbG8=\r\n--X--\r\n"; let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let summary = vandelay::sync::import_exchange_graph::run( make_common(archive), - make_config( - base, - None, - Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), - ), + make_config(base, None, surfaces("mail")), ) .unwrap(); let emails = summary @@ -1652,14 +1633,7 @@ fn event_get_carries_outlook_timezone_and_body_content_type_prefer() { let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); let _ = vandelay::sync::import_exchange_graph::run( make_common(archive), - make_config( - base, - None, - Some(vec![ - vandelay::types::ObjectType::Calendar, - vandelay::types::ObjectType::CalendarEvent, - ]), - ), + make_config(base, None, surfaces("calendar")), ) .unwrap(); } @@ -1758,7 +1732,7 @@ fn full_run_records_graph_id_in_sync_id_exchange_graph_with_padding() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: Some(vec![vandelay::types::ObjectType::Mailbox]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -1828,7 +1802,7 @@ fn archive_mailbox_kind_encodes_synthetic_suffix_in_account_id() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Archive, - objects: Some(vec![vandelay::types::ObjectType::Mailbox]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -1893,7 +1867,7 @@ fn allow_source_change_permits_overwriting_a_different_account() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: Some(vec![vandelay::types::ObjectType::Mailbox]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -1974,7 +1948,7 @@ fn dry_run_makes_no_per_item_get_and_no_sqlite_writes() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: None, + surfaces: Surfaces::ALL, event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -2142,10 +2116,7 @@ fn folder_enumeration_failure_skips_vanished_deletion() { api_base: server.url(), user_target: None, mailbox_kind: vandelay::exchange_graph::types::MailboxKind::Primary, - objects: Some(vec![ - vandelay::types::ObjectType::Mailbox, - vandelay::types::ObjectType::Email, - ]), + surfaces: surfaces("mail"), event_body_format: vandelay::exchange_graph::types::EventBodyFormat::Text, graph_connections: 2, top: 100, @@ -2163,3 +2134,211 @@ fn folder_enumeration_failure_skips_vanished_deletion() { "transient folder-enumeration failure must NOT delete the locally-known message" ); } + +fn row_count(conn: &rusqlite::Connection, table: &str) -> i64 { + conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() +} + +fn stub_contacts_surface(server: &mut Server) { + server + .mock("GET", "/me/contactFolders?$top=100") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[{"id":"CON1","displayName":"Contacts"}]}"#) + .expect_at_least(0) + .create(); + server + .mock("GET", "/me/contactFolders/CON1/childFolders?$top=100") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[]}"#) + .expect_at_least(0) + .create(); + server + .mock( + "GET", + "/me/contactFolders/CON1/contacts?$top=100&$select=id", + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[{"id":"CT1"}]}"#) + .expect_at_least(0) + .create(); + server + .mock("GET", "/me/contacts/CT1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"id":"CT1","displayName":"Alice Liddell","givenName":"Alice", + "surname":"Liddell","emailAddresses":[{"name":"Alice","address":"alice@x.com"}]}"#, + ) + .expect_at_least(0) + .create(); +} + +fn stub_mail_surface(server: &mut Server) { + server + .mock("GET", "/me/mailFolders?$top=100&includeHiddenFolders=true") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[{"id":"FMAIL","displayName":"Inbox","isHidden":false}]}"#) + .expect_at_least(0) + .create(); + server + .mock( + "GET", + "/me/mailFolders/FMAIL/childFolders?$top=100&includeHiddenFolders=true", + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[]}"#) + .expect_at_least(0) + .create(); + stub_well_known_folders(server, "FMAIL"); + server + .mock("GET", "/me/mailFolders/FMAIL/messages?$top=100&$select=id") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[{"id":"MSG-S"}]}"#) + .expect_at_least(0) + .create(); + server + .mock("GET", "/me/messages/MSG-S/$value") + .with_status(200) + .with_header("content-type", "text/plain") + .with_body( + "From: a@x\r\nTo: b@x\r\nSubject: surface\r\n\ + Date: Tue, 27 May 2026 10:00:00 +0000\r\nMessage-ID: \r\n\r\nbody", + ) + .expect_at_least(0) + .create(); +} + +fn stub_calendar_surface(server: &mut Server) { + server + .mock("GET", "/me/mailboxSettings?$select=timeZone") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"timeZone":"UTC"}"#) + .expect_at_least(0) + .create(); + server + .mock("GET", "/me/calendars?$top=100") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[{"id":"CAL1","name":"Calendar","isDefaultCalendar":true}]}"#) + .expect_at_least(0) + .create(); + server + .mock( + "GET", + "/me/calendars/CAL1/events?$top=100&$select=id,type,seriesMasterId", + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":[{"id":"EV1","type":"singleInstance","iCalUId":"uid-ev1"}]}"#) + .expect_at_least(0) + .create(); + server + .mock("GET", "/me/events/EV1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"id":"EV1","iCalUId":"uid-ev1","type":"singleInstance","subject":"Standup", + "start":{"dateTime":"2026-05-04T15:00:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-05-04T16:00:00.0000000","timeZone":"UTC"}}"#, + ) + .expect_at_least(0) + .create(); +} + +#[test] +fn contacts_surface_imports_only_address_books_and_cards() { + let mut server = Server::new(); + server + .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"id":"uid-surface-c","userPrincipalName":"alice@x.com"}"#) + .create(); + stub_contacts_surface(&mut server); + stub_calendar_surface(&mut server); + stub_mail_surface(&mut server); + let no_mail = server + .mock("GET", Matcher::Regex(r"^/me/mailFolders\?".to_owned())) + .with_status(500) + .with_body("MUST NOT BE CALLED") + .expect(0) + .create(); + let no_calendars = server + .mock("GET", Matcher::Regex(r"^/me/calendars\?".to_owned())) + .with_status(500) + .with_body("MUST NOT BE CALLED") + .expect(0) + .create(); + + let base = server.url(); + let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); + vandelay::sync::import_exchange_graph::run( + make_common(archive.clone()), + make_config(base, None, surfaces("contacts")), + ) + .unwrap(); + no_mail.assert(); + no_calendars.assert(); + + let conn = vandelay::db::init::open(&archive).unwrap(); + assert_eq!(row_count(&conn, "address_books"), 1); + assert_eq!(row_count(&conn, "contact_cards"), 1); + assert_eq!(row_count(&conn, "mailboxes"), 0); + assert_eq!(row_count(&conn, "emails"), 0); + assert_eq!(row_count(&conn, "calendars"), 0); + assert_eq!(row_count(&conn, "calendar_events"), 0); +} + +#[test] +fn mail_surface_imports_only_mailboxes_and_emails() { + let mut server = Server::new(); + server + .mock("GET", Matcher::Regex(r"^/me\?\$select=id".to_owned())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"id":"uid-surface-m","userPrincipalName":"alice@x.com"}"#) + .create(); + stub_mail_surface(&mut server); + stub_calendar_surface(&mut server); + stub_contacts_surface(&mut server); + let no_calendars = server + .mock("GET", Matcher::Regex(r"^/me/calendars\?".to_owned())) + .with_status(500) + .with_body("MUST NOT BE CALLED") + .expect(0) + .create(); + let no_contact_folders = server + .mock("GET", Matcher::Regex(r"^/me/contactFolders\?".to_owned())) + .with_status(500) + .with_body("MUST NOT BE CALLED") + .expect(0) + .create(); + + let base = server.url(); + let archive = tempfile::NamedTempFile::new().unwrap().path().to_owned(); + vandelay::sync::import_exchange_graph::run( + make_common(archive.clone()), + make_config(base, None, surfaces("mail")), + ) + .unwrap(); + no_calendars.assert(); + no_contact_folders.assert(); + + let conn = vandelay::db::init::open(&archive).unwrap(); + assert_eq!(row_count(&conn, "mailboxes"), 1); + assert_eq!(row_count(&conn, "emails"), 1); + assert_eq!(row_count(&conn, "address_books"), 0); + assert_eq!(row_count(&conn, "contact_cards"), 0); + assert_eq!(row_count(&conn, "calendars"), 0); + assert_eq!(row_count(&conn, "calendar_events"), 0); +} diff --git a/tests/mock_jmap.rs b/tests/mock_jmap.rs index ee801c5..abc4fd7 100644 --- a/tests/mock_jmap.rs +++ b/tests/mock_jmap.rs @@ -1201,3 +1201,97 @@ fn get_state_reads_state_from_empty_get() { let st = get_state(&client(0), &url, "w", "Email").expect("get_state"); assert_eq!(st.as_deref(), Some("snap-1")); } + +fn archive_path() -> std::path::PathBuf { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut p = std::env::temp_dir(); + p.push(format!( + "vandelay-mockjmap-{}-{n}.sqlite", + std::process::id() + )); + let _ = std::fs::remove_file(&p); + p +} + +#[test] +fn session_urls_on_a_foreign_origin_are_reported_as_a_mismatch() { + let mut server = mockito::Server::new(); + let base = server.url(); + let elsewhere = "https://mail.example".to_owned(); + let _root = server.mock("GET", "/").with_status(404).create(); + let _wk = server + .mock("GET", "/.well-known/jmap") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(session_json(&elsewhere)) + .create(); + + let session = Session::discover(&client(0), &base).expect("session discovered"); + let mismatches = session.origin_mismatches(&base); + assert_eq!(mismatches.len(), 1, "one warning per distinct origin"); + assert_eq!( + mismatches[0].fields, + vec!["apiUrl", "uploadUrl", "downloadUrl"] + ); + let warning = mismatches[0].to_string(); + assert!(warning.contains("session advertises apiUrl"), "{warning}"); + assert!(warning.contains("connected to "), "{warning}"); + assert!(warning.contains("advertised HTTP URL setting"), "{warning}"); +} + +#[test] +fn import_aborts_with_exit_two_when_the_advertised_api_url_is_unreachable() { + let mut api_host = mockito::Server::new(); + let mut session_host = mockito::Server::new(); + let session_base = session_host.url(); + let api_base = api_host.url(); + + let _root = session_host.mock("GET", "/").with_status(404).create(); + let _wk = session_host + .mock("GET", "/.well-known/jmap") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(session_json(&api_base)) + .create(); + let _nginx = api_host + .mock("POST", "/jmap/api") + .with_status(404) + .with_header("content-type", "text/html") + .with_body( + "404 Not Found404 Not Found", + ) + .create(); + + let archive = archive_path(); + let err = vandelay::sync::import_jmap::run( + vandelay::sync::CommonConfig { + archive: archive.clone(), + threads: 1, + dry_run: false, + max_retries: 0, + allow_invalid_certs: false, + logger: vandelay::logging::Logger::from_flags(true, 0), + }, + vandelay::sync::ImportConfig { + connect: vandelay::sync::ConnectConfig { + url: session_base.clone(), + auth: Auth::Basic { + user: "u".into(), + password: "p".into(), + }, + account: AccountSelector::Id("w".into()), + }, + objects: None, + allow_source_change: false, + }, + ) + .expect_err("an unreachable apiUrl must abort the run"); + + assert!( + matches!(err, vandelay::error::Error::Connection(_)), + "a 404 from the advertised apiUrl is a whole-run connection failure, got {err:?}" + ); + assert_eq!(err.exit_code(), 2, "must not report a partial failure"); + let _ = std::fs::remove_file(&archive); +} diff --git a/tests/mock_sync.rs b/tests/mock_sync.rs index bdb82e4..5e87812 100644 --- a/tests/mock_sync.rs +++ b/tests/mock_sync.rs @@ -1761,6 +1761,449 @@ fn export_calendar_creates_only_missing() { let _ = std::fs::remove_file(&archive); } +#[test] +fn export_contact_and_event_blobs_are_inlined_never_blob_ids() { + let mut server = mockito::Server::new(); + let base = server.url(); + let api = "/jmap/api"; + let archive = tmp(); + let photo: &[u8] = b"\x89PNG\r\n\x1a\nfake-photo-bytes"; + let agenda: &[u8] = b"agenda for the review meeting"; + { + let conn = db::init::open(&archive).unwrap(); + conn.execute( + "INSERT INTO address_books (id,name,is_default) VALUES (1,'Personal',1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO calendars (id,name,is_default) VALUES (1,'Work',1)", + [], + ) + .unwrap(); + let photo_blob = db::blobs::intern_blob(&conn, photo).unwrap(); + let agenda_blob = db::blobs::intern_blob(&conn, agenda).unwrap(); + let card = json!({ + "@type": "Card", + "version": "1.0", + "name": { "full": "Photo Person" }, + "media": { "photo": { + "@type": "Media", "kind": "photo", + "@blob": photo_blob, "mediaType": "image/png" + } } + }) + .to_string(); + conn.execute( + "INSERT INTO contact_cards (id,uid,address_book_ids,data) + VALUES (1,'card-uid-1','[1]',?1)", + [card.as_str()], + ) + .unwrap(); + let event = json!({ + "@type": "Event", + "uid": "event-uid-1", + "title": "Review", + "start": "2026-03-01T09:00:00", + "duration": "PT1H", + "timeZone": "Etc/UTC", + "links": { "1": { + "@type": "Link", "rel": "enclosure", + "@blob": agenda_blob, "contentType": "text/plain", "title": "agenda.txt" + } } + }) + .to_string(); + conn.execute( + "INSERT INTO calendar_events (id,calendar_ids,is_draft,use_default_alerts,data) + VALUES (1,'[1]',0,0,?1)", + [event.as_str()], + ) + .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 no_blob_id_in_card = server + .mock("POST", api) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("ContactCard/set".into()), + Matcher::Regex("blobId".into()), + ])) + .with_body( + json!({"methodResponses":[["ContactCard/set",{"accountId":"w", + "created":{"c1":{"id":"TCARD"}}},"s"]]}) + .to_string(), + ) + .expect(0) + .create(); + let no_blob_id_in_event = server + .mock("POST", api) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("CalendarEvent/set".into()), + Matcher::Regex("blobId".into()), + ])) + .with_body( + json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w", + "created":{"c1":{"id":"TEVENT"}}},"s"]]}) + .to_string(), + ) + .expect(0) + .create(); + + let _ab_get = server + .mock("POST", api) + .match_body(Matcher::Regex("AddressBook/get".into())) + .with_body( + json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[], + "notFound":[]},"g"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _ab_set = server + .mock("POST", api) + .match_body(Matcher::Regex("AddressBook/set".into())) + .with_body( + json!({"methodResponses":[["AddressBook/set",{"accountId":"w", + "created":{"c1":{"id":"TAB"}}},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + let _cal_get = server + .mock("POST", api) + .match_body(Matcher::Regex("Calendar/get".into())) + .with_body( + json!({"methodResponses":[["Calendar/get",{"accountId":"w","list":[], + "notFound":[]},"g"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _cal_set = server + .mock("POST", api) + .match_body(Matcher::Regex("Calendar/set".into())) + .with_body( + json!({"methodResponses":[["Calendar/set",{"accountId":"w", + "created":{"c1":{"id":"TCAL"}}},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + let _cc_query = server + .mock("POST", api) + .match_body(Matcher::Regex("ContactCard/query".into())) + .with_body( + json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":[]},"q"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _ce_query = server + .mock("POST", api) + .match_body(Matcher::Regex("CalendarEvent/query".into())) + .with_body( + json!({"methodResponses":[["CalendarEvent/query",{"accountId":"w","ids":[]},"q"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + + let card_inlined = server + .mock("POST", api) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("ContactCard/set".into()), + Matcher::Regex("data:image/png;base64,".into()), + ])) + .with_body( + json!({"methodResponses":[["ContactCard/set",{"accountId":"w", + "created":{"c1":{"id":"TCARD"}}},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + let event_inlined = server + .mock("POST", api) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("CalendarEvent/set".into()), + Matcher::Regex("data:text/plain;base64,".into()), + ])) + .with_body( + json!({"methodResponses":[["CalendarEvent/set",{"accountId":"w", + "created":{"c1":{"id":"TEVENT"}}},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + + let summary = sync::export::run( + common(&archive), + export_cfg_objects( + &base, + vec![ + ObjectType::AddressBook, + ObjectType::Calendar, + ObjectType::ContactCard, + ObjectType::CalendarEvent, + ], + ), + ) + .expect("export"); + + no_blob_id_in_card.assert(); + no_blob_id_in_event.assert(); + card_inlined.assert(); + event_inlined.assert(); + + for name in ["ContactCard", "CalendarEvent"] { + let counts = summary + .per_type + .iter() + .find(|(t, _)| *t == name) + .map(|(_, c)| c.clone()) + .unwrap_or_else(|| panic!("{name} counts")); + assert_eq!(counts.created, 1, "{name} created: {counts:?}"); + assert_eq!(counts.failed, 0, "{name} failed: {counts:?}"); + } + let _ = std::fs::remove_file(&archive); +} + +fn session_body_small_request(base: &str, max_size_request: u64) -> String { + json!({ + "apiUrl": format!("{base}/jmap/api"), + "uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"), + "downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"), + "capabilities": { "urn:ietf:params:jmap:core": { + "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, + "maxConcurrentRequests": 4, "maxConcurrentUpload": 4, + "maxSizeRequest": max_size_request, "maxSizeUpload": 50000000 + } }, + "accounts": { "w": { "name": "alice", + "accountCapabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {} + } } } + }) + .to_string() +} + +#[test] +fn export_oversized_contact_card_fails_alone_and_the_type_keeps_going() { + 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,is_default) VALUES (1,'Personal',1)", + [], + ) + .unwrap(); + let photo = vec![b'P'; 8000]; + let photo_blob = db::blobs::intern_blob(&conn, &photo).unwrap(); + let huge = json!({ + "@type": "Card", + "name": { "full": "Photo Person" }, + "media": { "photo": { + "@type": "Media", "kind": "photo", + "@blob": photo_blob, "mediaType": "image/png" + } } + }) + .to_string(); + conn.execute( + "INSERT INTO contact_cards (id,uid,address_book_ids,data) + VALUES (1,'huge-card','[1]',?1)", + [huge.as_str()], + ) + .unwrap(); + let small = json!({ "@type": "Card", "name": { "full": "Small Person" } }).to_string(); + conn.execute( + "INSERT INTO contact_cards (id,uid,address_book_ids,data) + VALUES (2,'small-card','[1]',?1)", + [small.as_str()], + ) + .unwrap(); + } + + let _root = server.mock("GET", "/").with_status(404).create(); + let _wk = server + .mock("GET", "/.well-known/jmap") + .with_body(session_body_small_request(&base, 4000)) + .expect_at_least(1) + .create(); + let _ccterm = anchor_terminator(&mut server, api, "ContactCard"); + + let _ab_get = server + .mock("POST", api) + .match_body(Matcher::Regex("AddressBook/get".into())) + .with_body( + json!({"methodResponses":[["AddressBook/get",{"accountId":"w","list":[], + "notFound":[]},"g"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _ab_set = server + .mock("POST", api) + .match_body(Matcher::Regex("AddressBook/set".into())) + .with_body( + json!({"methodResponses":[["AddressBook/set",{"accountId":"w", + "created":{"c1":{"id":"TAB"}}},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + let _cc_query = server + .mock("POST", api) + .match_body(Matcher::Regex("ContactCard/query".into())) + .with_body( + json!({"methodResponses":[["ContactCard/query",{"accountId":"w", + "ids":["TSTALE"]},"q"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _cc_get = server + .mock("POST", api) + .match_body(Matcher::Regex("ContactCard/get".into())) + .with_body( + json!({"methodResponses":[["ContactCard/get",{"accountId":"w","list":[ + {"id":"TSTALE","uid":"stale-card"} + ],"notFound":[]},"g"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let destroyed = server + .mock("POST", api) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("ContactCard/set".into()), + Matcher::Regex("destroy".into()), + ])) + .with_body( + json!({"methodResponses":[["ContactCard/set",{"accountId":"w", + "destroyed":["TSTALE"]},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + let created = server + .mock("POST", api) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("ContactCard/set".into()), + Matcher::Regex("Small Person".into()), + ])) + .with_body( + json!({"methodResponses":[["ContactCard/set",{"accountId":"w", + "created":{"c2":{"id":"TSMALL"}}},"s"]]}) + .to_string(), + ) + .expect(1) + .create(); + + let summary = sync::export::run( + common(&archive), + ExportConfig { + prune: true, + ..export_cfg_objects( + &base, + vec![ObjectType::AddressBook, ObjectType::ContactCard], + ) + }, + ) + .expect("the oversized card must not abort the run"); + + created.assert(); + destroyed.assert(); + let counts = summary + .per_type + .iter() + .find(|(t, _)| *t == "ContactCard") + .map(|(_, c)| c.clone()) + .expect("ContactCard counts"); + assert_eq!(counts.created, 1, "{counts:?}"); + assert_eq!(counts.failed, 1, "{counts:?}"); + assert_eq!( + counts.deleted, 1, + "the prune plan stays complete: {counts:?}" + ); + let _ = std::fs::remove_file(&archive); +} + +#[test] +fn export_archive_read_failure_while_inlining_exits_seven() { + 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,is_default) VALUES (1,'Personal',1)", + [], + ) + .unwrap(); + let photo_blob = db::blobs::intern_blob(&conn, b"photo-bytes").unwrap(); + let card = json!({ + "@type": "Card", + "media": { "photo": { "@type": "Media", "kind": "photo", + "@blob": photo_blob, "mediaType": "image/png" } } + }) + .to_string(); + conn.execute( + "INSERT INTO contact_cards (id,uid,address_book_ids,data) + VALUES (1,'card-uid-1','[1]',?1)", + [card.as_str()], + ) + .unwrap(); + conn.execute("ALTER TABLE blobs RENAME COLUMN data TO payload", []) + .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 _cc_query = server + .mock("POST", api) + .match_body(Matcher::Regex("ContactCard/query".into())) + .with_body( + json!({"methodResponses":[["ContactCard/query",{"accountId":"w","ids":[]},"q"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let no_create = server + .mock("POST", api) + .match_body(Matcher::Regex("ContactCard/set".into())) + .with_body( + json!({"methodResponses":[["ContactCard/set",{"accountId":"w", + "created":{"c1":{"id":"TCARD"}}},"s"]]}) + .to_string(), + ) + .expect(0) + .create(); + + let err = sync::export::run( + common(&archive), + export_cfg_objects(&base, vec![ObjectType::ContactCard]), + ) + .expect_err("an unreadable blobs table must abort the run"); + + no_create.assert(); + assert!(err.aborts_run(), "{err}"); + assert_eq!(err.exit_code(), 7, "{err}"); + let _ = std::fs::remove_file(&archive); +} + #[test] fn export_sieve_script_matches_by_name_not_content() { let mut server = mockito::Server::new(); @@ -3264,3 +3707,369 @@ fn import_jmap_duplicate_role_is_deduplicated_to_single_mailbox() { drop(conn); let _ = std::fs::remove_file(&archive); } + +#[test] +fn export_aborts_with_exit_two_when_the_advertised_api_url_is_unreachable() { + let mut session_host = mockito::Server::new(); + let mut api_host = mockito::Server::new(); + let session_base = session_host.url(); + let api_base = api_host.url(); + let archive = tmp(); + { + let conn = db::init::open(&archive).unwrap(); + conn.execute( + "INSERT INTO mailboxes (id,name,parent_id,role,sort_order,is_subscribed) + VALUES (1,'Inbox',NULL,'inbox',0,1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO address_books (id,name,description,is_default) + VALUES (1,'Personal',NULL,1)", + [], + ) + .unwrap(); + } + + let _root = session_host.mock("GET", "/").with_status(404).create(); + let _wk = session_host + .mock("GET", "/.well-known/jmap") + .with_body(session_body_full(&api_base)) + .expect_at_least(1) + .create(); + let nginx = api_host + .mock("POST", "/jmap/api") + .with_status(404) + .with_header("content-type", "text/html") + .with_body("404 Not Foundnginx") + .expect(1) + .create(); + + let err = sync::export::run( + CommonConfig { + archive: archive.clone(), + threads: 1, + dry_run: false, + max_retries: 0, + allow_invalid_certs: false, + logger: Logger::from_flags(true, 0), + }, + export_cfg_objects( + &session_base, + vec![ObjectType::Mailbox, ObjectType::AddressBook], + ), + ) + .expect_err("an unreachable apiUrl must abort the run"); + + assert!( + matches!(err, vandelay::error::Error::Connection(_)), + "a 404 from the advertised apiUrl is a whole-run connection failure, got {err:?}" + ); + assert_eq!(err.exit_code(), 2, "must not report a partial failure"); + nginx.assert(); + let _ = std::fs::remove_file(&archive); +} + +#[test] +fn aborting_import_still_reports_the_types_that_finished() { + 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 _term = anchor_terminator(&mut server, api, "Mailbox"); + + let _mq = server + .mock("POST", api) + .match_body(Matcher::Regex("Mailbox/query".into())) + .with_body( + json!({"methodResponses":[["Mailbox/query", + {"accountId":"w","ids":["A"]},"q"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _mg = server + .mock("POST", api) + .match_body(Matcher::Regex("Mailbox/get".into())) + .with_body( + json!({"methodResponses":[["Mailbox/get",{"accountId":"w","state":"s1","list":[ + {"id":"A","name":"alpha","parentId":null,"role":null,"sortOrder":0,"isSubscribed":true} + ],"notFound":[]},"g"]]}) + .to_string(), + ) + .expect_at_least(1) + .create(); + let _dead_endpoint = server + .mock("POST", api) + .match_body(Matcher::Regex("Email/query".into())) + .with_status(404) + .with_header("content-type", "text/html") + .with_body("404 Not Foundnginx") + .expect_at_least(1) + .create(); + + let outcome = sync::import_jmap::run_reporting( + common(&archive), + import_cfg_objects(&base, vec![ObjectType::Mailbox, ObjectType::Email]), + ); + + let err = outcome + .error + .as_ref() + .expect("a 404 from the api endpoint aborts the run"); + assert!( + matches!(err, vandelay::error::Error::Connection(_)), + "the abort must keep its own exit code, got {err:?}" + ); + assert_eq!(err.exit_code(), 2); + + let mailbox = outcome + .summary + .per_type + .iter() + .find(|(t, _)| *t == "Mailbox") + .map(|(_, c)| c.clone()) + .expect("the type that finished before the abort must still be reported"); + assert_eq!(mailbox.fetched, 1); + assert!( + !outcome.summary.per_type.iter().any(|(t, _)| *t == "Email"), + "the type that aborted contributes no counts" + ); + let _ = std::fs::remove_file(&archive); +} + +fn seed_single_email(archive: &Path) { + let conn = db::init::open(archive).unwrap(); + conn.execute( + "INSERT INTO mailboxes (id,name,parent_id,role) VALUES (1,'Inbox',NULL,'inbox')", + [], + ) + .unwrap(); + let blob = db::blobs::intern_blob( + &conn, + b"From: a@x\r\nSubject: hi\r\nMessage-ID: \r\n\r\nbody", + ) + .unwrap(); + conn.execute( + "INSERT INTO emails (blob_id,received_at,mailbox_ids,keywords) + VALUES (?1,'2020-01-01T00:00:00Z','[1]','[\"$seen\"]')", + rusqlite::params![blob], + ) + .unwrap(); +} + +fn mock_reconcile_for_single_email(server: &mut mockito::ServerGuard, base: &str, api: &str) { + server.mock("GET", "/").with_status(404).create(); + server + .mock("GET", "/.well-known/jmap") + .with_body(session_body(base)) + .create(); + server + .mock("POST", api) + .match_body(Matcher::Regex("Mailbox/query".into())) + .with_body( + json!({"methodResponses":[["Mailbox/query", + {"accountId":"w","ids":["t1"]},"q"]]}) + .to_string(), + ) + .expect(1) + .create(); + server + .mock("POST", api) + .match_body(Matcher::Regex("Mailbox/query".into())) + .with_body( + json!({"methodResponses":[["Mailbox/query", + {"accountId":"w","ids":[]},"q"]]}) + .to_string(), + ) + .expect(1) + .create(); + server + .mock("POST", api) + .match_body(Matcher::Regex("Mailbox/get".into())) + .with_body( + json!({"methodResponses":[["Mailbox/get",{"accountId":"w","list":[ + {"id":"t1","name":"Inbox","role":"inbox","parentId":null, + "myRights":{"mayDelete":true}}],"notFound":[]},"g"]]}) + .to_string(), + ) + .expect(1) + .create(); + server + .mock("POST", api) + .match_body(Matcher::Regex("Email/query".into())) + .with_body( + json!({"methodResponses":[["Email/query", + {"accountId":"w","ids":[]},"q"]]}) + .to_string(), + ) + .expect(1) + .create(); +} + +fn run_export(archive: &Path, base: &str, max_retries: u32) -> sync::Summary { + sync::export::run( + CommonConfig { + archive: archive.to_path_buf(), + threads: 1, + dry_run: false, + max_retries, + allow_invalid_certs: false, + logger: Logger::from_flags(true, 0), + }, + ExportConfig { + connect: ConnectConfig { + url: base.to_owned(), + auth: Auth::Basic { + user: "u".into(), + password: "p".into(), + }, + account: AccountSelector::Id("w".into()), + }, + objects: None, + prune: false, + yes: true, + }, + ) + .expect("export run") +} + +fn email_counts(summary: &sync::Summary) -> sync::TypeCounts { + summary + .per_type + .iter() + .find(|(t, _)| *t == "Email") + .map(|(_, c)| c.clone()) + .expect("email counts") +} + +fn method_error_body(error_type: &str) -> String { + json!({"methodResponses":[["error", + {"type": error_type, + "description":"This server is temporarily unavailable. \ + Attempting this same operation later may succeed."}, + "i"]]}) + .to_string() +} + +#[test] +fn export_email_server_unavailable_is_retried_not_dropped() { + let mut server = mockito::Server::new(); + let base = server.url(); + let api = "/jmap/api"; + + let archive = tmp(); + seed_single_email(&archive); + mock_reconcile_for_single_email(&mut server, &base, api); + + let up = server + .mock("POST", Matcher::Regex("/jmap/upload/".into())) + .with_body(json!({"blobId":"UPLOADED"}).to_string()) + .expect(1) + .create(); + let failing = server + .mock("POST", api) + .match_body(Matcher::Regex("Email/import".into())) + .with_body(method_error_body("serverUnavailable")) + .expect(2) + .create(); + let succeeding = server + .mock("POST", api) + .match_body(Matcher::Regex("Email/import".into())) + .with_body( + json!({"methodResponses":[["Email/import",{"accountId":"w", + "created":{"e1":{"id":"E1","blobId":"UPLOADED","threadId":"T1","size":48}}},"i"]]}) + .to_string(), + ) + .expect(1) + .create(); + + let summary = run_export(&archive, &base, 3); + let email = email_counts(&summary); + + failing.assert(); + succeeding.assert(); + up.assert(); + assert_eq!(email.created, 1, "the message must survive a transient error"); + assert_eq!(email.failed, 0, "a retried message is not a failure"); + assert!(!summary.any_failed()); + + let _ = std::fs::remove_file(&archive); +} + +#[test] +fn export_email_exhausted_method_retries_stay_per_message() { + let mut server = mockito::Server::new(); + let base = server.url(); + let api = "/jmap/api"; + + let archive = tmp(); + seed_single_email(&archive); + mock_reconcile_for_single_email(&mut server, &base, api); + + let _up = server + .mock("POST", Matcher::Regex("/jmap/upload/".into())) + .with_body(json!({"blobId":"UPLOADED"}).to_string()) + .expect(1) + .create(); + let failing = server + .mock("POST", api) + .match_body(Matcher::Regex("Email/import".into())) + .with_body(method_error_body("serverUnavailable")) + .expect(3) + .create(); + + let summary = run_export(&archive, &base, 2); + let email = email_counts(&summary); + + failing.assert(); + assert_eq!(email.created, 0); + assert_eq!( + email.failed, 1, + "exhausting the retries stays a per-message failure, not a run abort" + ); + assert!(summary.any_failed(), "the run must still exit 5"); + + let _ = std::fs::remove_file(&archive); +} + +#[test] +fn export_email_fatal_method_error_is_not_retried() { + let mut server = mockito::Server::new(); + let base = server.url(); + let api = "/jmap/api"; + + let archive = tmp(); + seed_single_email(&archive); + mock_reconcile_for_single_email(&mut server, &base, api); + + let _up = server + .mock("POST", Matcher::Regex("/jmap/upload/".into())) + .with_body(json!({"blobId":"UPLOADED"}).to_string()) + .expect(1) + .create(); + let failing = server + .mock("POST", api) + .match_body(Matcher::Regex("Email/import".into())) + .with_body(method_error_body("invalidArguments")) + .expect(1) + .create(); + + let summary = run_export(&archive, &base, 5); + let email = email_counts(&summary); + + failing.assert(); + assert_eq!( + email.failed, 1, + "a permanent method error must not consume retries" + ); + + let _ = std::fs::remove_file(&archive); +} diff --git a/tests/sync_jmap.rs b/tests/sync_jmap.rs index 59c6dfa..e06fbc5 100644 --- a/tests/sync_jmap.rs +++ b/tests/sync_jmap.rs @@ -689,6 +689,167 @@ fn prune_non_tree_type_contact_card() { seeder::teardown(base_url()).expect("teardown"); } +const ISSUE30_CARD_UID: &str = "vandelay-issue30-card"; +const ISSUE30_EVENT_UID: &str = "vandelay-issue30-event"; + +fn data_uri_bytes(resource: &Value, uri_key: &str, expect_media_type: &str) -> Vec { + use base64::Engine; + let uri = resource + .get(uri_key) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("no {uri_key} on {resource}")); + assert!( + resource.get("blobId").is_none(), + "target must not hold a blobId for {uri_key}: {resource}" + ); + let prefix = format!("data:{expect_media_type};base64,"); + let payload = uri + .strip_prefix(&prefix) + .unwrap_or_else(|| panic!("{uri_key} is not a {prefix}... data URI: {resource}")); + base64::engine::general_purpose::STANDARD + .decode(payload) + .expect("base64 payload") +} + +#[test] +#[ignore = "requires Docker"] +fn export_inlines_contact_and_event_blobs_instead_of_blob_ids() { + let fx = seeder::provision(base_url()).expect("provision"); + let tgt = fx.account("test6").expect("test6"); + let archive = tmp_archive("issue30"); + + let photo: &[u8] = b"\x89PNG\r\n\x1a\nvandelay issue 30 contact photo bytes"; + let agenda: &[u8] = b"vandelay issue 30 calendar enclosure bytes"; + { + let conn = vandelay::db::init::open(&archive).expect("init archive"); + conn.execute( + "INSERT INTO address_books (id,name,is_default) VALUES (1,'Issue30 Book',1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO calendars (id,name,is_default) VALUES (1,'Issue30 Calendar',1)", + [], + ) + .unwrap(); + let photo_blob = vandelay::db::blobs::intern_blob(&conn, photo).unwrap(); + let agenda_blob = vandelay::db::blobs::intern_blob(&conn, agenda).unwrap(); + let card = json!({ + "@type": "Card", + "version": "1.0", + "name": { "full": "Issue Thirty" }, + "media": { "photo": { + "@type": "Media", "kind": "photo", + "@blob": photo_blob, "mediaType": "image/png" + } } + }) + .to_string(); + conn.execute( + "INSERT INTO contact_cards (id,uid,address_book_ids,data) VALUES (1,?1,'[1]',?2)", + [ISSUE30_CARD_UID, card.as_str()], + ) + .unwrap(); + let event = json!({ + "@type": "Event", + "uid": ISSUE30_EVENT_UID, + "title": "Issue 30 review", + "start": "2026-03-01T09:00:00", + "duration": "PT1H", + "timeZone": "Etc/UTC", + "links": { "1": { + "@type": "Link", "rel": "enclosure", + "@blob": agenda_blob, "contentType": "text/plain", "title": "agenda.txt" + } } + }) + .to_string(); + conn.execute( + "INSERT INTO calendar_events (id,calendar_ids,is_draft,use_default_alerts,data) + VALUES (1,'[1]',0,0,?1)", + [event.as_str()], + ) + .unwrap(); + } + + let summary = sync::export::run( + common(&archive, false), + export_cfg("test6", &tgt.account_id, false), + ) + .expect("export"); + for name in ["ContactCard", "CalendarEvent"] { + let counts = summary + .per_type + .iter() + .find(|(t, _)| *t == name) + .map(|(_, c)| c.clone()) + .unwrap_or_else(|| panic!("{name} counts missing: {summary:?}")); + assert_eq!(counts.created, 1, "{name} created exactly one: {counts:?}"); + assert_eq!(counts.failed, 0, "{name} had no failures: {counts:?}"); + } + + let client = HttpClient::new(basic("test6"), RetryPolicy::new(5), true); + let session = Session::discover(&client, base_url()).expect("discover target session"); + let api = session.api_url.clone(); + + let cards = client + .post_json( + &api, + &json!({ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"], + "methodCalls": [["ContactCard/get", + { "accountId": tgt.account_id, "ids": Value::Null }, "g"]] + }), + ) + .expect("ContactCard/get"); + let card = cards + .pointer("/methodResponses/0/1/list") + .and_then(Value::as_array) + .expect("ContactCard list") + .iter() + .find(|c| c.get("uid").and_then(Value::as_str) == Some(ISSUE30_CARD_UID)) + .unwrap_or_else(|| panic!("exported card not found on the target: {cards}")) + .clone(); + let photo_resource = card + .pointer("/media/photo") + .unwrap_or_else(|| panic!("card has no media.photo: {card}")); + assert_eq!( + data_uri_bytes(photo_resource, "uri", "image/png"), + photo, + "contact photo bytes survived the export: {card}" + ); + + let events = client + .post_json( + &api, + &json!({ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"], + "methodCalls": [["CalendarEvent/get", + { "accountId": tgt.account_id, "ids": Value::Null }, "g"]] + }), + ) + .expect("CalendarEvent/get"); + let event = events + .pointer("/methodResponses/0/1/list") + .and_then(Value::as_array) + .expect("CalendarEvent list") + .iter() + .find(|e| e.get("uid").and_then(Value::as_str) == Some(ISSUE30_EVENT_UID)) + .unwrap_or_else(|| panic!("exported event not found on the target: {events}")) + .clone(); + let link = event + .get("links") + .and_then(Value::as_object) + .and_then(|m| m.values().next()) + .unwrap_or_else(|| panic!("event has no links: {event}")); + assert_eq!( + data_uri_bytes(link, "href", "text/plain"), + agenda, + "calendar enclosure bytes survived the export: {event}" + ); + + let _ = std::fs::remove_file(&archive); + seeder::teardown(base_url()).expect("teardown"); +} + #[test] #[ignore = "requires Docker"] fn export_round_trip_and_convergence() {