From be16176b6e34eab675670c4cae3b4595c3e16bac Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 23 Oct 2026 10:10:57 +0700 Subject: [PATCH] mailmig: Stalwart -> SNM IMAP migration tool (initial) --- .gitignore | 12 + Cargo.lock | 2253 ++++++++++++++++++++++++ Cargo.toml | 29 + flake.nix | 53 + mailmig.example.toml | 71 + rustfmt.toml | 2 + src/config.rs | 396 +++++ src/dns/cloudflare.rs | 319 ++++ src/dns/doh.rs | 71 + src/dns/mod.rs | 416 +++++ src/imap/client.rs | 677 +++++++ src/imap/mod.rs | 5 + src/imap/proto.rs | 388 ++++ src/imap/utf7.rs | 119 ++ src/main.rs | 544 ++++++ src/plan.rs | 556 ++++++ src/snm.rs | 249 +++ src/stalwart.rs | 439 +++++ src/sync.rs | 660 +++++++ tests/e2e.nix | 310 ++++ tests/expected-dovecot-master-user.nix | 20 + tests/expected-mail-accounts.nix | 35 + tests/imap_fixture.py | 132 ++ tests/provision.py | 48 + 24 files changed, 7804 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 flake.nix create mode 100644 mailmig.example.toml create mode 100644 rustfmt.toml create mode 100644 src/config.rs create mode 100644 src/dns/cloudflare.rs create mode 100644 src/dns/doh.rs create mode 100644 src/dns/mod.rs create mode 100644 src/imap/client.rs create mode 100644 src/imap/mod.rs create mode 100644 src/imap/proto.rs create mode 100644 src/imap/utf7.rs create mode 100644 src/main.rs create mode 100644 src/plan.rs create mode 100644 src/snm.rs create mode 100644 src/stalwart.rs create mode 100644 src/sync.rs create mode 100644 tests/e2e.nix create mode 100644 tests/expected-dovecot-master-user.nix create mode 100644 tests/expected-mail-accounts.nix create mode 100644 tests/imap_fixture.py create mode 100644 tests/provision.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf8d825 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +/result* + +# Working files of a real migration: contain account data and password hashes. +/mailmig.toml +/plan.toml +/secrets.json +/raw*.json +/out/ +/reports/ +/dns-journal/ +/dkim/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..328da8c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2253 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mailmig" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.23.1", + "chrono", + "clap", + "hex", + "rand", + "reqwest", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sha-crypt", + "sha2", + "tempfile", + "tokio", + "tokio-rustls", + "toml", + "tracing", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "mcf" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2044bdbade272ded4a1529324f4f30ca7b0c5b496541f2a9095bbd1c9d03cb06" +dependencies = [ + "base64ct", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "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-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-crypt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ee31e0190f9dc366d26b032ac36a99328532f40a237a79385a20d2d7f29810" +dependencies = [ + "base64ct", + "ctutils", + "mcf", + "password-hash", + "sha2", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9023c61 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "mailmig" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +base64 = "0.23.1" +chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } +clap = { version = "4.6.7", features = ["derive", "env"] } +hex = "0.4.3" +rand = "0.10.3" +reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "rustls-no-provider", "http2"] } +rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-pki-types = "1.15.1" +rustls-platform-verifier = "0.7.1" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +sha-crypt = { version = "0.6.0", features = ["getrandom"] } +sha2 = "0.11.1" +tokio = { version = "1.54.1", features = ["full"] } +tokio-rustls = { version = "0.26.6", default-features = false, features = ["ring", "tls12", "logging"] } +toml = "1.1.8" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } + +[dev-dependencies] +tempfile = "3.27.0" +wiremock = "0.6.5" diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..06a437f --- /dev/null +++ b/flake.nix @@ -0,0 +1,53 @@ +{ + description = "mailmig: migrate mail from Stalwart to Simple NixOS Mailserver over IMAP"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + # Same branch the cnx-network-clan mx1 runs. + nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05"; + nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = + { + self, + nixpkgs, + nixos-mailserver, + }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + inherit (pkgs) lib; + mailmig = pkgs.rustPlatform.buildRustPackage { + pname = "mailmig"; + version = (lib.importTOML ./Cargo.toml).package.version; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./src + ./mailmig.example.toml + ]; + }; + cargoLock.lockFile = ./Cargo.lock; + meta.mainProgram = "mailmig"; + }; + in + { + packages.${system}.default = mailmig; + + checks.${system} = { + inherit mailmig; + e2e = pkgs.testers.runNixOSTest (import ./tests/e2e.nix { inherit mailmig nixos-mailserver; }); + }; + + devShells.${system}.default = pkgs.mkShell { + inputsFrom = [ mailmig ]; + packages = [ + pkgs.rustfmt + pkgs.clippy + ]; + }; + }; +} diff --git a/mailmig.example.toml b/mailmig.example.toml new file mode 100644 index 0000000..296dc27 --- /dev/null +++ b/mailmig.example.toml @@ -0,0 +1,71 @@ +# mailmig configuration: Stalwart (old) -> Simple NixOS Mailserver (new). +# Secrets are read from environment variables or files, never stored here. + +# ---- old server: Stalwart ------------------------------------------------- +[source] +host = "mail.b4l.co.th" +tls = "implicit" # implicit (993) | starttls (143) | none +# Stalwart impersonation: the master account needs the `impersonate` +# permission; mailmig logs in as "%" with the master's +# *main* password (app passwords are refused for impersonation). +[source.auth] +mode = "master" +user = "mailmig@b4l.co.th" +password_env = "MAILMIG_SRC_MASTER_PASSWORD" +login_format = "{user}%{master}" + +# Management API, used by `mailmig inventory`. +[stalwart] +url = "https://mail.b4l.co.th" +kind = "auto" # auto | rest (<= 0.15) | jmap (>= 0.16) +user = "admin" +password_env = "MAILMIG_STALWART_ADMIN_PASSWORD" +# token_env = "MAILMIG_STALWART_TOKEN" # API key instead of user/password + +# ---- new server: Simple NixOS Mailserver ---------------------------------- +[destination] +host = "mx1.cnx.email" +tls = "implicit" +# Temporary Dovecot master user from out/dovecot-master-user.nix. +[destination.auth] +mode = "master" +user = "mailmig" +password_env = "MAILMIG_DST_MASTER_PASSWORD" +login_format = "{user}*{master}" + +# ---- folders ---------------------------------------------------------------- +[folders] +# Special-use folders (\Sent, \Trash, ...) are matched by attribute; Stalwart's +# "Sent Items"/"Deleted Items"/"Junk Mail" land in SNM's Sent/Trash/Junk. +exclude = ["Shared Folders/*"] +separator_replacement = "_" +# [folders.map] +# "Old Name" = "New Name" + +[sync] +jobs = 4 +skip_deleted = true +retries = 2 + +# ---- users (SNM / clan) --------------------------------------------------------- +[snm] +clan_machine = "mx1" +clan_generator_prefix = "mail-passwd-" +master_user = "mailmig" + +# ---- DNS ------------------------------------------------------------------ +[dns] +provider = "cloudflare" # cloudflare | zonefile +api_token_env = "CLOUDFLARE_API_TOKEN" # Zone:DNS:Edit on the mail domains +mx = ["10 mx1.cnx.email"] +spf = "v=spf1 mx -all" +dmarc = "v=DMARC1; p=quarantine; rua=mailto:dmarc@cnx.email" +dkim_selector = "mail" +dkim_dir = "dkim" # scp 'mx1:/var/dkim/*.txt' dkim/ +old_hosts = ["mail.b4l.co.th"] +client_host = "mail.cnx.email" +srv = true +ttl = 300 + +# [dns.overrides."poyrecords.com"] +# spf = "v=spf1 mx include:_spf.google.com -all" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ae5373b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,396 @@ +//! `mailmig.toml`: where the servers are and how to log in to them. + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub source: Endpoint, + pub destination: Endpoint, + /// Stalwart management API (used by `inventory`). + pub stalwart: Option, + #[serde(default)] + pub folders: FolderCfg, + #[serde(default)] + pub sync: SyncCfg, + #[serde(default)] + pub snm: SnmCfg, + pub dns: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum TlsMode { + #[default] + Implicit, + Starttls, + /// Plain text, no TLS. Only for tests on a private network. + None, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + pub host: String, + pub port: Option, + #[serde(default)] + pub tls: TlsMode, + /// PEM file with extra CA certificates to trust (instead of the system store). + pub ca_file: Option, + /// Name to verify the certificate against, if it differs from `host` + /// (e.g. connecting by IP before DNS is switched). + pub tls_server_name: Option, + pub auth: Auth, + /// Seconds to wait for a single server response. + #[serde(default = "default_timeout")] + pub timeout: u64, +} + +fn default_timeout() -> u64 { + 300 +} + +impl Endpoint { + pub fn port(&self) -> u16 { + self.port.unwrap_or(match self.tls { + TlsMode::Implicit => 993, + _ => 143, + }) + } +} + +/// How mailmig logs in as each account. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "mode", rename_all = "kebab-case", deny_unknown_fields)] +pub enum Auth { + /// Master user / impersonation via a composite login name. + /// Stalwart: `login_format = "{user}%{master}"` (master needs the + /// `impersonate` permission). Dovecot: `"{user}*{master}"`. + Master { + user: String, + password: Option, + password_env: Option, + password_file: Option, + login_format: String, + }, + /// SASL PLAIN with an authorization identity (authzid = target account, + /// authcid = master). Supported by Dovecot master users. + SaslAuthzid { user: String, password: Option, password_env: Option, password_file: Option }, + /// Each account's own password: looked up in `passwords_file` + /// (`login:password` per line) and then in the inventory secrets + /// (plaintext passwords found on the source). + Password { passwords_file: Option }, +} + +impl Auth { + pub fn master_secret(&self) -> Option { + match self { + Auth::Master { password, password_env, password_file, .. } + | Auth::SaslAuthzid { password, password_env, password_file, .. } => Some(SecretRef { + password: password.clone(), + password_env: password_env.clone(), + password_file: password_file.clone(), + }), + Auth::Password { .. } => None, + } + } +} + +/// A secret given inline, via environment variable, or via file. +#[derive(Debug, Clone, Default)] +pub struct SecretRef { + pub password: Option, + pub password_env: Option, + pub password_file: Option, +} + +impl SecretRef { + pub fn resolve(&self, what: &str) -> Result { + if let Some(v) = &self.password_env { + return std::env::var(v).with_context(|| format!("{what}: environment variable {v} is not set")); + } + if let Some(f) = &self.password_file { + let s = std::fs::read_to_string(f).with_context(|| format!("{what}: reading {}", f.display()))?; + return Ok(s.lines().next().unwrap_or("").to_string()); + } + if let Some(p) = &self.password { + return Ok(p.clone()); + } + bail!("{what}: set one of password_env, password_file or password") + } +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ApiKind { + #[default] + Auto, + /// Stalwart <= 0.15: REST `/api/principal`. + Rest, + /// Stalwart >= 0.16: JMAP `x:Account` objects at `/api`. + Jmap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StalwartApi { + /// Base URL, e.g. `https://mail.b4l.co.th`. + pub url: String, + #[serde(default)] + pub kind: ApiKind, + /// Basic-auth user (an admin account). + pub user: Option, + pub password: Option, + pub password_env: Option, + pub password_file: Option, + /// Bearer token (API key) instead of user/password. + pub token_env: Option, +} + +impl StalwartApi { + pub fn secret(&self) -> SecretRef { + SecretRef { + password: self.password.clone(), + password_env: self.password_env.clone(), + password_file: self.password_file.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FolderCfg { + /// Explicit source → destination folder names (decoded, with the + /// source/destination hierarchy separators respectively). + #[serde(default)] + pub map: BTreeMap, + /// Source folders to skip. `Name` matches exactly, `Name/*` matches the + /// folder and everything below it (using the source separator). + #[serde(default)] + pub exclude: Vec, + /// Replacement for the destination hierarchy separator when it occurs + /// inside a source folder name (e.g. "v1.2" on a "." server). + #[serde(default = "default_sep_repl")] + pub separator_replacement: String, + /// Destination names for special-use folders that do not exist yet. + #[serde(default = "default_special")] + pub special: BTreeMap, + /// Subscribe every folder mailmig creates on the destination. + #[serde(default = "yes")] + pub subscribe: bool, +} + +impl Default for FolderCfg { + fn default() -> Self { + Self { + map: BTreeMap::new(), + exclude: Vec::new(), + separator_replacement: default_sep_repl(), + special: default_special(), + subscribe: true, + } + } +} + +fn default_sep_repl() -> String { + "_".into() +} + +/// SNM's default mailbox names (`mailserver.mailboxes`) plus Archive. +fn default_special() -> BTreeMap { + [("\\Sent", "Sent"), ("\\Trash", "Trash"), ("\\Junk", "Junk"), ("\\Drafts", "Drafts"), ("\\Archive", "Archive")] + .into_iter() + .map(|(a, b)| (a.to_string(), b.to_string())) + .collect() +} + +fn yes() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SyncCfg { + /// Accounts migrated in parallel (two IMAP connections each). + #[serde(default = "default_jobs")] + pub jobs: usize, + /// Don't copy messages flagged \Deleted on the source. + #[serde(default = "yes")] + pub skip_deleted: bool, + /// Max messages per body FETCH batch. + #[serde(default = "default_batch")] + pub batch_messages: usize, + /// Max bytes per body FETCH batch. + #[serde(default = "default_batch_bytes")] + pub batch_bytes: u64, + /// Retries per account after a connection error (sync is idempotent). + #[serde(default = "default_retries")] + pub retries: u32, +} + +impl Default for SyncCfg { + fn default() -> Self { + Self { + jobs: default_jobs(), + skip_deleted: true, + batch_messages: default_batch(), + batch_bytes: default_batch_bytes(), + retries: default_retries(), + } + } +} + +fn default_jobs() -> usize { + 4 +} +fn default_batch() -> usize { + 50 +} +fn default_batch_bytes() -> u64 { + 32 * 1024 * 1024 +} +fn default_retries() -> u32 { + 2 +} + +/// How `mailmig users` renders the destination side. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SnmCfg { + /// Clan machine running SNM (for the `clan vars set` script). + #[serde(default = "default_machine")] + pub clan_machine: String, + /// Clan vars generator name prefix; `@`→`-at-`, `.`→`-` is appended + /// (matches modules/mail.nix in cnx-network-clan). + #[serde(default = "default_gen_prefix")] + pub clan_generator_prefix: String, + /// Login of the temporary Dovecot master user (see dovecot-master-user.nix). + #[serde(default = "default_master")] + pub master_user: String, +} + +impl Default for SnmCfg { + fn default() -> Self { + Self { + clan_machine: default_machine(), + clan_generator_prefix: default_gen_prefix(), + master_user: default_master(), + } + } +} + +fn default_machine() -> String { + "mx1".into() +} +fn default_gen_prefix() -> String { + "mail-passwd-".into() +} +fn default_master() -> String { + "mailmig".into() +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DnsCfg { + /// Domains to manage; defaults to every domain in the plan. + #[serde(default)] + pub domains: Vec, + /// `cloudflare` (API) or `zonefile` (print records only). + #[serde(default = "default_provider")] + pub provider: String, + #[serde(default = "default_cf_env")] + pub api_token_env: String, + #[serde(default = "default_cf_base")] + pub api_base: String, + /// MX targets as "priority host". + pub mx: Vec, + #[serde(default = "default_spf")] + pub spf: String, + pub dmarc: Option, + #[serde(default = "default_selector")] + pub dkim_selector: String, + /// Directory with SNM's `..txt` DKIM public keys + /// (copied from the destination's /var/dkim). + #[serde(default = "default_dkim_dir")] + pub dkim_dir: PathBuf, + /// Hostnames of the old server; SRV/CNAME records pointing there are + /// retargeted or reported. + #[serde(default)] + pub old_hosts: Vec, + /// Client-facing IMAP/submission host for RFC 6186 SRV records. + pub client_host: Option, + #[serde(default = "yes")] + pub srv: bool, + /// TTL for records mailmig writes (Cloudflare: 1 = auto). + #[serde(default = "default_ttl")] + pub ttl: u32, + /// DNS-over-HTTPS JSON endpoint for `dns verify`. + #[serde(default = "default_doh")] + pub doh_url: String, + /// Per-domain overrides of spf / dmarc / mx. + #[serde(default)] + pub overrides: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DnsOverride { + pub spf: Option, + pub dmarc: Option, + pub mx: Option>, + /// Skip DKIM for this domain (e.g. key not generated yet). + #[serde(default)] + pub skip_dkim: bool, +} + +fn default_provider() -> String { + "cloudflare".into() +} +fn default_cf_env() -> String { + "CLOUDFLARE_API_TOKEN".into() +} +fn default_cf_base() -> String { + "https://api.cloudflare.com/client/v4".into() +} +fn default_spf() -> String { + "v=spf1 mx -all".into() +} +fn default_selector() -> String { + "mail".into() +} +fn default_dkim_dir() -> PathBuf { + "dkim".into() +} +fn default_ttl() -> u32 { + 300 +} +fn default_doh() -> String { + "https://cloudflare-dns.com/dns-query".into() +} + +impl Config { + pub fn load(path: &Path) -> Result { + let s = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + toml::from_str(&s).with_context(|| format!("parsing {}", path.display())) + } +} + +pub const EXAMPLE: &str = include_str!("../mailmig.example.toml"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn example_parses() { + let c: Config = toml::from_str(EXAMPLE).unwrap(); + assert!(matches!(c.source.auth, Auth::Master { .. })); + assert!(matches!(c.destination.auth, Auth::Master { .. })); + assert_eq!(c.destination.port(), 993); + assert_eq!(c.folders.special["\\Trash"], "Trash"); + assert!(c.dns.unwrap().mx[0].contains("mx1.cnx.email")); + } +} diff --git a/src/dns/cloudflare.rs b/src/dns/cloudflare.rs new file mode 100644 index 0000000..447c120 --- /dev/null +++ b/src/dns/cloudflare.rs @@ -0,0 +1,319 @@ +//! Cloudflare DNS API v4 (zones, dns_records). + +use super::{Change, Rec, norm_host, norm_txt}; +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Value, json}; + +pub struct Cloudflare { + http: reqwest::Client, + base: String, + token: String, +} + +impl Cloudflare { + pub fn new(base: &str, token: String) -> Result { + Ok(Cloudflare { http: crate::http_client()?, base: base.trim_end_matches('/').to_string(), token }) + } + + async fn call(&self, method: reqwest::Method, path: &str, body: Option) -> Result { + let mut rb = self.http.request(method.clone(), format!("{}{}", self.base, path)).bearer_auth(&self.token); + if let Some(b) = body { + rb = rb.json(&b); + } + let resp = rb.send().await.with_context(|| format!("Cloudflare {method} {path}"))?; + let status = resp.status(); + let v: Value = resp.json().await.with_context(|| format!("Cloudflare {method} {path}: bad JSON"))?; + if !status.is_success() || v.get("success").and_then(|s| s.as_bool()) != Some(true) { + bail!("Cloudflare {method} {path}: HTTP {status}: {}", v.get("errors").unwrap_or(&v)); + } + Ok(v) + } + + pub async fn zone_id(&self, domain: &str) -> Result { + let v = self.call(reqwest::Method::GET, &format!("/zones?name={}", norm_host(domain)), None).await?; + v["result"] + .as_array() + .and_then(|a| a.first()) + .and_then(|z| z["id"].as_str()) + .map(String::from) + .ok_or_else(|| anyhow!("no Cloudflare zone for {domain} visible to this token")) + } + + pub async fn list(&self, zone: &str) -> Result> { + let mut out = Vec::new(); + let mut page = 1; + loop { + let v = self + .call(reqwest::Method::GET, &format!("/zones/{zone}/dns_records?per_page=500&page={page}"), None) + .await?; + for r in v["result"].as_array().cloned().unwrap_or_default() { + if let Some(rec) = from_cf(&r) { + out.push(rec); + } + } + let total_pages = v["result_info"]["total_pages"].as_u64().unwrap_or(1); + if page as u64 >= total_pages { + break; + } + page += 1; + } + Ok(out) + } + + pub async fn create(&self, zone: &str, r: &Rec) -> Result { + let v = self.call(reqwest::Method::POST, &format!("/zones/{zone}/dns_records"), Some(to_cf(r))).await?; + v["result"]["id"].as_str().map(String::from).ok_or_else(|| anyhow!("Cloudflare create: no id returned")) + } + + pub async fn update(&self, zone: &str, id: &str, r: &Rec) -> Result<()> { + self.call(reqwest::Method::PUT, &format!("/zones/{zone}/dns_records/{id}"), Some(to_cf(r))).await?; + Ok(()) + } + + pub async fn delete(&self, zone: &str, id: &str) -> Result<()> { + self.call(reqwest::Method::DELETE, &format!("/zones/{zone}/dns_records/{id}"), None).await?; + Ok(()) + } + + /// Apply changes in order (creates first, deletes last, so a slot is never + /// empty). Returns the journal of what was done, for rollback. + pub async fn apply(&self, zone: &str, changes: &[Change]) -> Result> { + let mut done = Vec::new(); + let rank = |c: &Change| match c { + Change::Create { .. } => 0, + Change::Update { .. } => 1, + Change::Delete { .. } => 2, + }; + let mut ordered: Vec<&Change> = changes.iter().collect(); + ordered.sort_by_key(|c| rank(c)); + for c in ordered { + let r = match c { + Change::Create { new } => { + self.create(zone, new).await.map(|id| Applied::Created { id, rec: new.clone() }) + } + Change::Update { old, new } => { + let id = old.id.clone().ok_or_else(|| anyhow!("update without record id"))?; + self.update(zone, &id, new).await.map(|_| Applied::Updated { + id, + old: old.clone(), + new: new.clone(), + }) + } + Change::Delete { old, .. } => { + let id = old.id.clone().ok_or_else(|| anyhow!("delete without record id"))?; + self.delete(zone, &id).await.map(|_| Applied::Deleted { old: old.clone() }) + } + }; + match r { + Ok(a) => { + tracing::info!(" applied {}", c.display().lines().next().unwrap_or("")); + done.push(a) + } + Err(e) => { + tracing::error!(" FAILED {}: {e:#}", c.display().lines().next().unwrap_or("")); + return Err(ApplyError { done, source: e }.into()); + } + } + } + Ok(done) + } + + /// Undo a journal in reverse order. + pub async fn rollback(&self, zone: &str, journal: &[Applied]) -> Result<()> { + for a in journal.iter().rev() { + match a { + Applied::Created { id, rec } => { + self.delete(zone, id).await?; + tracing::info!(" removed {}", rec.display()); + } + Applied::Updated { id, old, .. } => { + self.update(zone, id, old).await?; + tracing::info!(" restored {}", old.display()); + } + Applied::Deleted { old } => { + self.create(zone, old).await?; + tracing::info!(" re-created {}", old.display()); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum Applied { + Created { id: String, rec: Rec }, + Updated { id: String, old: Rec, new: Rec }, + Deleted { old: Rec }, +} + +#[derive(Debug)] +pub struct ApplyError { + pub done: Vec, + pub source: anyhow::Error, +} +impl std::fmt::Display for ApplyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:#} (after {} successful changes)", self.source, self.done.len()) + } +} +impl std::error::Error for ApplyError {} + +fn from_cf(r: &Value) -> Option { + let rtype = r["type"].as_str()?.to_string(); + let name = norm_host(r["name"].as_str()?); + let ttl = r["ttl"].as_u64().unwrap_or(1) as u32; + let id = r["id"].as_str().map(String::from); + let (content, priority) = match rtype.as_str() { + "MX" => (norm_host(r["content"].as_str()?), r["priority"].as_u64().map(|p| p as u16)), + "TXT" => (norm_txt(r["content"].as_str()?), None), + "SRV" => { + let d = &r["data"]; + if d.is_object() { + ( + format!( + "{} {} {} {}", + d["priority"].as_u64().unwrap_or(0), + d["weight"].as_u64().unwrap_or(0), + d["port"].as_u64().unwrap_or(0), + norm_host(d["target"].as_str().unwrap_or("")) + ), + None, + ) + } else { + // "weight port target" with separate priority + let c = r["content"].as_str()?; + let p = r["priority"].as_u64().unwrap_or(0); + (format!("{p} {}", norm_host(c)), None) + } + } + "CNAME" | "NS" => (norm_host(r["content"].as_str()?), None), + _ => (r["content"].as_str().unwrap_or("").to_string(), None), + }; + Some(Rec { name, rtype, content, priority, ttl, id }) +} + +fn to_cf(r: &Rec) -> Value { + match r.rtype.as_str() { + "MX" => { + json!({"type": "MX", "name": r.name, "content": r.content, "priority": r.priority.unwrap_or(10), "ttl": r.ttl}) + } + "SRV" => { + let p: Vec<&str> = r.content.split(' ').collect(); + let num = |i: usize| p.get(i).and_then(|x| x.parse::().ok()).unwrap_or(0); + json!({"type": "SRV", "name": r.name, "ttl": r.ttl, "data": { + "priority": num(0), "weight": num(1), "port": num(2), "target": p.get(3).copied().unwrap_or("") + }}) + } + "TXT" => json!({"type": "TXT", "name": r.name, "content": quote_txt(&r.content), "ttl": r.ttl}), + t => json!({"type": t, "name": r.name, "content": r.content, "ttl": r.ttl}), + } +} + +/// Quoted TXT content, split into 255-byte strings (Cloudflare accepts both +/// forms; quoting explicitly keeps long DKIM keys intact). +fn quote_txt(s: &str) -> String { + s.as_bytes() + .chunks(255) + .map(|c| format!("\"{}\"", String::from_utf8_lossy(c).replace('\\', "\\\\").replace('"', "\\\""))) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::DnsCfg; + use crate::dns::{desired_records, plan_domain}; + use wiremock::matchers::{header, method, path, query_param}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + fn ok(result: Value) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({"success": true, "errors": [], "result": result, + "result_info": {"page": 1, "total_pages": 1}})) + } + + struct EchoCreate; + impl Respond for EchoCreate { + fn respond(&self, req: &Request) -> ResponseTemplate { + let mut body: Value = serde_json::from_slice(&req.body).unwrap(); + body["id"] = json!(format!("new-{}", body["name"].as_str().unwrap())); + ok(body) + } + } + + #[tokio::test] + async fn cloudflare_end_to_end() { + crate::init_crypto(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/zones")) + .and(query_param("name", "b4l.co.th")) + .and(header("authorization", "Bearer tok")) + .respond_with(ok(json!([{"id": "z1", "name": "b4l.co.th"}]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/zones/z1/dns_records")) + .respond_with(ok(json!([ + {"id": "r1", "type": "MX", "name": "b4l.co.th", "content": "mail.b4l.co.th", "priority": 10, "ttl": 1}, + {"id": "r2", "type": "TXT", "name": "b4l.co.th", "content": "\"v=spf1 mx -all\"", "ttl": 1}, + {"id": "r3", "type": "SRV", "name": "_imaps._tcp.b4l.co.th", "content": "1 993 mail.b4l.co.th", "priority": 0, + "data": {"priority": 0, "weight": 1, "port": 993, "target": "mail.b4l.co.th"}, "ttl": 1}, + {"id": "r4", "type": "A", "name": "www.b4l.co.th", "content": "1.2.3.4", "ttl": 1} + ]))) + .mount(&server) + .await; + Mock::given(method("POST")).and(path("/zones/z1/dns_records")).respond_with(EchoCreate).mount(&server).await; + Mock::given(method("PUT")).respond_with(ok(json!({}))).mount(&server).await; + Mock::given(method("DELETE")).respond_with(ok(json!({}))).mount(&server).await; + + let cf = Cloudflare::new(&server.uri(), "tok".into()).unwrap(); + let zone = cf.zone_id("b4l.co.th").await.unwrap(); + assert_eq!(zone, "z1"); + let current = cf.list(&zone).await.unwrap(); + assert_eq!(current.len(), 4); + assert_eq!(current[1].content, "v=spf1 mx -all"); + assert_eq!(current[2].content, "0 1 993 mail.b4l.co.th"); + + let cfg: DnsCfg = toml::from_str( + "mx = [\"10 mx1.cnx.email\"]\nold_hosts=[\"mail.b4l.co.th\"]\nclient_host=\"mail.cnx.email\"\ndmarc=\"v=DMARC1; p=none\"", + ) + .unwrap(); + let want = desired_records(&cfg, "b4l.co.th", Some("v=DKIM1; k=rsa; p=K")).unwrap(); + let plan = plan_domain(&cfg, "b4l.co.th", want, ¤t); + // MX + SRV _imaps updated; dmarc, dkim, 2 SRV created; SPF unchanged. + let n_upd = plan.changes.iter().filter(|c| matches!(c, Change::Update { .. })).count(); + let n_new = plan.changes.iter().filter(|c| matches!(c, Change::Create { .. })).count(); + assert_eq!((n_upd, n_new), (2, 4), "{:#?}", plan.changes); + + let journal = cf.apply(&zone, &plan.changes).await.unwrap(); + assert_eq!(journal.len(), 6); + let reqs = server.received_requests().await.unwrap(); + let mx_put = reqs.iter().find(|r| r.method.as_str() == "PUT" && r.url.path().ends_with("/r1")).unwrap(); + let body: Value = serde_json::from_slice(&mx_put.body).unwrap(); + assert_eq!(body["content"], "mx1.cnx.email"); + assert_eq!(body["priority"], 10); + let srv_post = reqs + .iter() + .filter(|r| r.method.as_str() == "POST") + .map(|r| serde_json::from_slice::(&r.body).unwrap()) + .find(|b| b["type"] == "SRV") + .unwrap(); + assert_eq!(srv_post["data"]["target"], "mail.cnx.email"); + + cf.rollback(&zone, &journal).await.unwrap(); + let reqs = server.received_requests().await.unwrap(); + let deletes = reqs.iter().filter(|r| r.method.as_str() == "DELETE").count(); + assert_eq!(deletes, 4, "rollback deletes the 4 created records"); + } + + #[test] + fn txt_quoting() { + let long = "x".repeat(300); + let q = quote_txt(&long); + assert!(q.starts_with('"') && q.contains("\" \"")); + assert_eq!(norm_txt(&q), long); + } +} diff --git a/src/dns/doh.rs b/src/dns/doh.rs new file mode 100644 index 0000000..58a09c3 --- /dev/null +++ b/src/dns/doh.rs @@ -0,0 +1,71 @@ +//! Check what the world sees, via DNS-over-HTTPS (JSON API). + +use super::{Rec, norm_host, norm_txt}; +use anyhow::{Context, Result}; +use serde_json::Value; + +pub struct Doh { + http: reqwest::Client, + url: String, +} + +impl Doh { + pub fn new(url: &str) -> Result { + Ok(Doh { http: crate::http_client()?, url: url.to_string() }) + } + + /// Answers of `rtype` for `name`, normalised like provider records. + pub async fn query(&self, name: &str, rtype: &str) -> Result> { + let v: Value = self + .http + .get(&self.url) + .query(&[("name", name), ("type", rtype)]) + .header("Accept", "application/dns-json") + .send() + .await + .with_context(|| format!("DoH {name} {rtype}"))? + .json() + .await?; + let mut out = Vec::new(); + for a in v["Answer"].as_array().cloned().unwrap_or_default() { + let t = match a["type"].as_u64() { + Some(15) => "MX", + Some(16) => "TXT", + Some(33) => "SRV", + Some(5) => "CNAME", + _ => continue, + }; + if t != rtype { + continue; + } + let data = a["data"].as_str().unwrap_or(""); + let ttl = a["TTL"].as_u64().unwrap_or(0) as u32; + let (content, prio) = match t { + "MX" => { + let (p, h) = data.split_once(' ').unwrap_or(("0", data)); + (norm_host(h), p.parse().ok()) + } + "TXT" => (norm_txt(data), None), + "SRV" => { + let parts: Vec<&str> = data.split_whitespace().collect(); + let target = parts.last().map(|h| norm_host(h)).unwrap_or_default(); + (format!("{} {target}", parts[..parts.len().saturating_sub(1)].join(" ")), None) + } + _ => (norm_host(data), None), + }; + out.push(Rec::new(&norm_host(a["name"].as_str().unwrap_or(name)), t, &content, prio, ttl)); + } + Ok(out) + } + + /// For each desired record: is an identical one publicly visible? + pub async fn verify(&self, desired: &[Rec]) -> Result)>> { + let mut out = Vec::new(); + for r in desired { + let seen = self.query(&r.name, &r.rtype).await?; + let ok = seen.iter().any(|s| s.same(r)); + out.push((r.clone(), ok, seen)); + } + Ok(out) + } +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs new file mode 100644 index 0000000..d5edf19 --- /dev/null +++ b/src/dns/mod.rs @@ -0,0 +1,416 @@ +//! Mail DNS records: compute the desired set per domain, diff it against +//! what is published, and apply via Cloudflare (or print zone-file lines). + +pub mod cloudflare; +pub mod doh; + +use crate::config::DnsCfg; +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Rec { + /// FQDN, lowercase, no trailing dot. + pub name: String, + #[serde(rename = "type")] + pub rtype: String, + /// Normalised content: MX/CNAME target host; TXT unquoted joined text; + /// SRV "priority weight port target". + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + #[serde(default)] + pub ttl: u32, + /// Provider record id (current records only). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +impl Rec { + pub fn new(name: &str, rtype: &str, content: &str, priority: Option, ttl: u32) -> Rec { + Rec { name: norm_host(name), rtype: rtype.to_string(), content: content.to_string(), priority, ttl, id: None } + } + /// Same data (ignoring TTL and id). + pub fn same(&self, o: &Rec) -> bool { + self.name == o.name + && self.rtype == o.rtype + && self.priority == o.priority + && if self.rtype == "TXT" { + self.content == o.content + } else { + self.content.eq_ignore_ascii_case(&o.content) + } + } + pub fn display(&self) -> String { + match self.priority { + Some(p) => format!("{} {} {} {}", self.name, self.rtype, p, self.content), + None if self.rtype == "TXT" => format!("{} TXT \"{}\"", self.name, short(&self.content, 90)), + None => format!("{} {} {}", self.name, self.rtype, self.content), + } + } +} + +fn short(s: &str, n: usize) -> String { + if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n).collect::()) } +} + +pub fn norm_host(h: &str) -> String { + h.trim().trim_end_matches('.').to_ascii_lowercase() +} + +/// TXT content as published may be quoted and split into 255-byte strings +/// (`"v=DKIM1; k=rsa; " "p=MIIB..."`); join it back into one value. +pub fn norm_txt(s: &str) -> String { + let t = s.trim(); + if !t.starts_with('"') { + return t.to_string(); + } + let mut out = String::new(); + let mut chars = t.chars(); + let mut inq = false; + while let Some(c) = chars.next() { + match c { + '"' => inq = !inq, + '\\' if inq => { + if let Some(n) = chars.next() { + out.push(n); + } + } + c if inq => out.push(c), + _ => {} + } + } + out +} + +/// Parse SNM's `/var/dkim/..txt` (rspamadm dkim_keygen +/// output: `mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " "p=..." ) ;`). +pub fn parse_dkim_file(s: &str) -> Result { + let mut out = String::new(); + let mut inq = false; + for c in s.chars() { + match c { + '"' => inq = !inq, + c if inq => out.push(c), + _ => {} + } + } + let out = out.trim().to_string(); + if !out.starts_with("v=DKIM1") || !out.contains("p=") { + bail!("not a DKIM TXT record"); + } + Ok(out) +} + +pub fn load_dkim(dir: &Path, domain: &str, selector: &str) -> Result { + let path = dir.join(format!("{domain}.{selector}.txt")); + let s = std::fs::read_to_string(&path).with_context(|| { + format!("reading {} (copy it from the SNM host: /var/dkim/{domain}.{selector}.txt)", path.display()) + })?; + parse_dkim_file(&s).with_context(|| format!("parsing {}", path.display())) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum Change { + Create { new: Rec }, + Update { old: Rec, new: Rec }, + Delete { old: Rec, reason: String }, +} + +impl Change { + pub fn display(&self) -> String { + match self { + Change::Create { new } => format!("+ {}", new.display()), + Change::Update { old, new } => format!("~ {}\n was {}", new.display(), old.display()), + Change::Delete { old, reason } => format!("- {} ({reason})", old.display()), + } + } +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DomainPlan { + pub domain: String, + pub desired: Vec, + pub changes: Vec, + pub unchanged: Vec, + pub warnings: Vec, + pub info: Vec, +} + +const MAIL_SRV: &[&str] = &[ + "_imap._tcp", + "_imaps._tcp", + "_pop3._tcp", + "_pop3s._tcp", + "_submission._tcp", + "_submissions._tcp", + "_jmap._tcp", + "_caldav._tcp", + "_caldavs._tcp", + "_carddav._tcp", + "_carddavs._tcp", + "_autodiscover._tcp", +]; + +/// Records mailmig wants for `domain`. +pub fn desired_records(cfg: &DnsCfg, domain: &str, dkim: Option<&str>) -> Result> { + let d = norm_host(domain); + let ov = cfg.overrides.get(&d).cloned().unwrap_or_default(); + let ttl = cfg.ttl; + let mut out = Vec::new(); + for mx in ov.mx.as_ref().unwrap_or(&cfg.mx) { + let (p, host) = mx + .split_once(char::is_whitespace) + .with_context(|| format!("dns.mx entry '{mx}' must be 'priority host'"))?; + let p: u16 = p.trim().parse().with_context(|| format!("bad MX priority in '{mx}'"))?; + out.push(Rec::new(&d, "MX", &norm_host(host), Some(p), ttl)); + } + out.push(Rec::new(&d, "TXT", ov.spf.as_deref().unwrap_or(&cfg.spf), None, ttl)); + if let Some(dmarc) = ov.dmarc.as_ref().or(cfg.dmarc.as_ref()) { + out.push(Rec::new(&format!("_dmarc.{d}"), "TXT", dmarc, None, ttl)); + } + if let Some(k) = dkim { + out.push(Rec::new(&format!("{}._domainkey.{d}", cfg.dkim_selector), "TXT", k, None, ttl)); + } + if cfg.srv + && let Some(h) = &cfg.client_host + { + let h = norm_host(h); + out.push(Rec::new(&format!("_imaps._tcp.{d}"), "SRV", &format!("0 1 993 {h}"), None, ttl)); + out.push(Rec::new(&format!("_submissions._tcp.{d}"), "SRV", &format!("0 1 465 {h}"), None, ttl)); + out.push(Rec::new(&format!("_submission._tcp.{d}"), "SRV", &format!("0 1 587 {h}"), None, ttl)); + } + Ok(out) +} + +/// Which "slot" a record belongs to: records in the same slot replace each other. +fn slot(r: &Rec, domain: &str, selector: &str) -> Option { + let d = norm_host(domain); + let lc = r.content.to_ascii_lowercase(); + match r.rtype.as_str() { + "MX" if r.name == d => Some("mx".into()), + "TXT" if r.name == d && lc.starts_with("v=spf1") => Some("spf".into()), + "TXT" if r.name == format!("_dmarc.{d}") && lc.starts_with("v=dmarc1") => Some("dmarc".into()), + "TXT" if r.name == format!("{selector}._domainkey.{d}") => Some("dkim".into()), + "SRV" => r.name.strip_suffix(&format!(".{d}")).map(|svc| format!("srv:{svc}")), + _ => None, + } +} + +/// Diff desired vs current for one domain. Records outside mailmig's slots +/// are left alone, but anything still pointing at an old host is reported. +pub fn plan_domain(cfg: &DnsCfg, domain: &str, desired: Vec, current: &[Rec]) -> DomainPlan { + let d = norm_host(domain); + let mut plan = DomainPlan { domain: d.clone(), desired: desired.clone(), ..Default::default() }; + let old_hosts: Vec = cfg.old_hosts.iter().map(|h| norm_host(h)).collect(); + let points_old = |r: &Rec| { + let target = r.content.rsplit(' ').next().unwrap_or(""); + old_hosts.iter().any(|h| norm_host(target) == *h) + }; + + let mut slots: Vec = desired.iter().filter_map(|r| slot(r, &d, &cfg.dkim_selector)).collect(); + slots.sort(); + slots.dedup(); + + for s in &slots { + let want: Vec<&Rec> = desired.iter().filter(|r| slot(r, &d, &cfg.dkim_selector).as_ref() == Some(s)).collect(); + let mut have: Vec<&Rec> = + current.iter().filter(|r| slot(r, &d, &cfg.dkim_selector).as_ref() == Some(s)).collect(); + let mut todo: Vec<&Rec> = Vec::new(); + for w in want { + if let Some(pos) = have.iter().position(|h| h.same(w)) { + plan.unchanged.push(have.remove(pos).clone()); + } else { + todo.push(w); + } + } + // Reuse existing records (keeps ids, fewer API calls), then create/delete the rest. + for w in todo { + if !have.is_empty() { + let old = have.remove(0).clone(); + plan.changes.push(Change::Update { old, new: w.clone() }); + } else { + plan.changes.push(Change::Create { new: w.clone() }); + } + } + for h in have { + plan.changes.push(Change::Delete { old: h.clone(), reason: format!("replaced ({s})") }); + } + } + + // Mail SRV records we don't publish but which point at the old server. + for r in current { + if r.rtype != "SRV" || slot(r, &d, &cfg.dkim_selector).is_some_and(|s| slots.contains(&s)) { + continue; + } + let svc = r.name.strip_suffix(&format!(".{d}")).unwrap_or(&r.name); + if MAIL_SRV.contains(&svc) && points_old(r) { + plan.changes.push(Change::Delete { + old: r.clone(), + reason: "service not offered by SNM, points at old server".into(), + }); + } + } + + for r in current { + let in_changes = plan.changes.iter().any(|c| match c { + Change::Update { old, .. } | Change::Delete { old, .. } => old.id == r.id && old.same(r), + _ => false, + }); + if in_changes { + continue; + } + if r.name == format!("_mta-sts.{d}") && r.rtype == "TXT" { + plan.warnings.push(format!( + "{d} publishes MTA-STS ({}). Senders cache the policy (max_age); if it lists only the old MX in \ + 'enforce' mode they will refuse to deliver to the new MX. Before switching: publish a policy that \ + lists both MX hosts (or mode: testing), bump the id, and wait max_age.", + r.content + )); + } else if r.name.ends_with(&format!("._domainkey.{d}")) && r.rtype == "TXT" { + plan.info.push(format!("old DKIM key {} kept (harmless; delete after the cutover)", r.name)); + } else if (r.rtype == "CNAME" || r.rtype == "MX" || r.rtype == "SRV") && points_old(r) { + plan.warnings + .push(format!("{} still points at the old server; it will dangle after decommissioning", r.display())); + } else if r.name == format!("_smtp._tls.{d}") { + plan.info.push(format!("TLS-RPT record kept: {}", r.content)); + } + } + plan +} + +/// Zone-file lines (Knot/BIND) for the desired records. +pub fn render_zone(desired: &[Rec], origin: &str) -> String { + let o = norm_host(origin); + let rel = |n: &str| -> String { + if n == o { + "@".into() + } else { + n.strip_suffix(&format!(".{o}")).map(String::from).unwrap_or_else(|| format!("{n}.")) + } + }; + let mut s = format!("; ---- Mail (mailmig) for {o} ----\n"); + for r in desired { + let name = rel(&r.name); + let line = match r.rtype.as_str() { + "MX" => format!("{name:<24} IN MX {} {}.", r.priority.unwrap_or(10), r.content), + "SRV" => { + let mut parts: Vec<&str> = r.content.split(' ').collect(); + let target = format!("{}.", parts.pop().unwrap_or("")); + format!("{name:<24} IN SRV {} {target}", parts.join(" ")) + } + "TXT" => { + let chunks: Vec = r + .content + .as_bytes() + .chunks(255) + .map(|c| format!("\"{}\"", String::from_utf8_lossy(c).replace('"', "\\\""))) + .collect(); + if chunks.len() == 1 { + format!("{name:<24} IN TXT {}", chunks[0]) + } else { + format!("{name:<24} IN TXT ( {} )", chunks.join("\n ")) + } + } + t => format!("{name:<24} IN {t} {}", r.content), + }; + s.push_str(&line); + s.push('\n'); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> DnsCfg { + toml::from_str( + r#" + mx = ["10 mx1.cnx.email"] + dmarc = "v=DMARC1; p=quarantine" + old_hosts = ["mail.b4l.co.th"] + client_host = "mail.cnx.email" + "#, + ) + .unwrap() + } + + #[test] + fn dkim_file() { + let f = "mail._domainkey IN TXT ( \"v=DKIM1; k=rsa; \"\n\t\"p=MIIBIjAN\"\n\t\"BgkqhkiG\" ) ;\n"; + assert_eq!(parse_dkim_file(f).unwrap(), "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG"); + assert!(parse_dkim_file("garbage").is_err()); + } + + #[test] + fn txt_normalisation() { + assert_eq!(norm_txt("\"v=DKIM1; k=rsa; \" \"p=AB\""), "v=DKIM1; k=rsa; p=AB"); + assert_eq!(norm_txt("v=spf1 mx -all"), "v=spf1 mx -all"); + assert_eq!(norm_txt("\"a\\\"b\""), "a\"b"); + } + + #[test] + fn diff_against_stalwart_records() { + let c = cfg(); + let want = desired_records(&c, "b4l.co.th", Some("v=DKIM1; k=rsa; p=NEW")).unwrap(); + let cur = |name: &str, t: &str, content: &str, prio: Option, id: &str| Rec { + id: Some(id.into()), + ..Rec::new(name, t, content, prio, 1) + }; + let current = vec![ + cur("b4l.co.th", "MX", "mail.b4l.co.th", Some(10), "1"), + cur("b4l.co.th", "TXT", "v=spf1 mx ra=postmaster -all", None, "2"), + cur("b4l.co.th", "TXT", "google-site-verification=abc", None, "3"), + cur("_dmarc.b4l.co.th", "TXT", "v=DMARC1; p=quarantine", None, "4"), + cur("202401e._domainkey.b4l.co.th", "TXT", "v=DKIM1; k=ed25519; p=OLD", None, "5"), + cur("_imaps._tcp.b4l.co.th", "SRV", "0 1 993 mail.b4l.co.th", None, "6"), + cur("_pop3s._tcp.b4l.co.th", "SRV", "0 1 995 mail.b4l.co.th", None, "7"), + cur("_jmap._tcp.b4l.co.th", "SRV", "0 1 443 mail.b4l.co.th", None, "8"), + cur("autoconfig.b4l.co.th", "CNAME", "mail.b4l.co.th", None, "9"), + cur("_mta-sts.b4l.co.th", "TXT", "v=STSv1; id=123", None, "10"), + ]; + let p = plan_domain(&c, "b4l.co.th", want, ¤t); + let shown: Vec = p.changes.iter().map(|c| c.display()).collect(); + let text = shown.join("\n"); + assert!(text.contains("~ b4l.co.th MX 10 mx1.cnx.email"), "{text}"); + assert!(text.contains("~ b4l.co.th TXT \"v=spf1 mx -all\""), "{text}"); + assert!(text.contains("+ mail._domainkey.b4l.co.th TXT"), "{text}"); + assert!(text.contains("~ _imaps._tcp.b4l.co.th SRV 0 1 993 mail.cnx.email"), "{text}"); + assert!(text.contains("+ _submissions._tcp.b4l.co.th SRV 0 1 465 mail.cnx.email"), "{text}"); + assert!(text.contains("- _pop3s._tcp.b4l.co.th SRV"), "{text}"); + assert!(text.contains("- _jmap._tcp.b4l.co.th SRV"), "{text}"); + // DMARC already matches; google verification untouched + assert!(p.unchanged.iter().any(|r| r.name == "_dmarc.b4l.co.th")); + assert!(!text.contains("google")); + assert!(p.warnings.iter().any(|w| w.contains("MTA-STS"))); + assert!(p.warnings.iter().any(|w| w.contains("autoconfig"))); + assert!(p.info.iter().any(|w| w.contains("202401e"))); + // Second run against the applied state is a no-op. + let applied: Vec = p + .unchanged + .iter() + .cloned() + .chain(p.changes.iter().filter_map(|c| match c { + Change::Create { new } | Change::Update { new, .. } => Some(new.clone()), + _ => None, + })) + .collect(); + let want = desired_records(&c, "b4l.co.th", Some("v=DKIM1; k=rsa; p=NEW")).unwrap(); + assert!(plan_domain(&c, "b4l.co.th", want, &applied).changes.is_empty()); + } + + #[test] + fn zone_output() { + let c = cfg(); + let long = format!("v=DKIM1; k=rsa; p={}", "A".repeat(400)); + let want = desired_records(&c, "poyrecords.com", Some(&long)).unwrap(); + let z = render_zone(&want, "poyrecords.com"); + assert!(z.contains("@ IN MX 10 mx1.cnx.email.")); + assert!(z.contains("_dmarc IN TXT \"v=DMARC1; p=quarantine\"")); + assert!(z.contains("mail._domainkey IN TXT ( \"")); + assert!(z.contains("_imaps._tcp IN SRV 0 1 993 mail.cnx.email.")); + } +} diff --git a/src/imap/client.rs b/src/imap/client.rs new file mode 100644 index 0000000..54a2eb4 --- /dev/null +++ b/src/imap/client.rs @@ -0,0 +1,677 @@ +//! Minimal async IMAP4rev1/rev2 client: just what a migration needs. + +use super::proto::{self, Arg, Kind, Part, Response, Status, Value}; +use super::utf7; +use crate::config::{Endpoint, TlsMode}; +use anyhow::{Context, Result, anyhow, bail}; +use base64::Engine; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufStream}; +use tokio::net::TcpStream; + +pub trait Stream: AsyncRead + AsyncWrite + Unpin + Send {} +impl Stream for T {} + +pub struct Client { + io: BufStream>, + tag: u32, + pub caps: HashSet, + pub label: String, + timeout: Duration, +} + +#[derive(Debug, Clone)] +pub struct Mailbox { + /// Name exactly as on the wire (modified UTF-7). + pub wire: String, + /// Decoded Unicode name. + pub name: String, + pub delim: Option, + /// Attributes, e.g. `\Noselect`, `\Sent` (as sent by the server). + pub attrs: Vec, +} + +impl Mailbox { + pub fn has_attr(&self, a: &str) -> bool { + self.attrs.iter().any(|x| x.eq_ignore_ascii_case(a)) + } + pub fn selectable(&self) -> bool { + !self.has_attr("\\Noselect") && !self.has_attr("\\NonExistent") + } + pub fn special_use(&self) -> Option<&'static str> { + ["\\Sent", "\\Trash", "\\Junk", "\\Drafts", "\\Archive", "\\All", "\\Flagged"] + .into_iter() + .find(|s| self.has_attr(s)) + } +} + +#[derive(Debug, Clone, Default)] +pub struct Namespaces { + /// (prefix, delimiter) of the personal namespace(s). + pub personal: Vec<(String, Option)>, + /// Prefixes of other-users and shared namespaces (to skip). + pub foreign: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SelectInfo { + pub exists: u32, + pub uidvalidity: Option, + pub permanent_flags: Option>, +} + +impl SelectInfo { + /// Whether the mailbox accepts this flag permanently. + pub fn accepts_flag(&self, f: &str) -> bool { + match &self.permanent_flags { + None => true, + Some(p) => { + p.iter().any(|x| x.eq_ignore_ascii_case(f)) || (!f.starts_with('\\') && p.iter().any(|x| x == "\\*")) + } + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct MsgMeta { + pub uid: u32, + pub flags: Vec, + pub internaldate: Option, + pub size: u64, + pub headers: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct Fetched { + pub uid: u32, + pub flags: Vec, + pub internaldate: Option, + pub body: Vec, +} + +pub struct CmdResult { + pub untagged: Vec, + pub status: Status, +} + +/// A NO/BAD completion, kept typed so callers can inspect the response code. +#[derive(Debug)] +pub struct ImapNo { + pub cmd: String, + pub status: Status, +} + +impl std::fmt::Display for ImapNo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} -> {}", self.cmd, self.status.word)?; + if let Some(c) = &self.status.code { + write!(f, " [{c}]")?; + } + write!(f, " {}", self.status.text) + } +} +impl std::error::Error for ImapNo {} + +pub enum Login<'a> { + Plain { user: &'a str, password: &'a str }, + Authzid { authzid: &'a str, authcid: &'a str, password: &'a str }, +} + +fn tls_config(ep: &Endpoint) -> Result> { + if let Some(ca) = &ep.ca_file { + use rustls_pki_types::pem::PemObject; + let mut roots = rustls::RootCertStore::empty(); + for cert in rustls_pki_types::CertificateDer::pem_file_iter(ca) + .with_context(|| format!("reading CA file {}", ca.display()))? + { + roots.add(cert?)?; + } + Ok(Arc::new(rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth())) + } else { + use rustls_platform_verifier::ConfigVerifierExt; + Ok(Arc::new(rustls::ClientConfig::with_platform_verifier()?)) + } +} + +async fn tls_wrap(ep: &Endpoint, tcp: TcpStream) -> Result> { + let name = ep.tls_server_name.clone().unwrap_or_else(|| ep.host.clone()); + let server_name = rustls_pki_types::ServerName::try_from(name.clone()) + .with_context(|| format!("invalid TLS server name {name}"))?; + let connector = tokio_rustls::TlsConnector::from(tls_config(ep)?); + let tls = connector.connect(server_name, tcp).await.with_context(|| format!("TLS handshake with {}", ep.host))?; + Ok(Box::new(tls)) +} + +impl Client { + pub async fn connect(ep: &Endpoint, label: &str) -> Result { + let addr = (ep.host.as_str(), ep.port()); + let timeout = Duration::from_secs(ep.timeout); + let tcp = tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr)) + .await + .map_err(|_| anyhow!("{label}: connect to {}:{} timed out", ep.host, ep.port()))? + .with_context(|| format!("{label}: connect to {}:{}", ep.host, ep.port()))?; + tcp.set_nodelay(true).ok(); + let stream: Box = match ep.tls { + TlsMode::Implicit => tls_wrap(ep, tcp).await?, + TlsMode::None => Box::new(tcp), + TlsMode::Starttls => { + let mut tcp = tcp; + starttls_preamble(&mut tcp, label, timeout).await?; + tls_wrap(ep, tcp).await? + } + }; + let mut c = + Client { io: BufStream::new(stream), tag: 0, caps: HashSet::new(), label: label.to_string(), timeout }; + if ep.tls != TlsMode::Starttls { + c.greeting().await?; + } + c.capability().await?; + Ok(c) + } + + async fn greeting(&mut self) -> Result<()> { + let r = self.read_response().await?; + match r.status() { + Some(s) if s.word == "OK" || s.word == "PREAUTH" => { + if let Some(code) = &s.code { + self.set_caps_from_code(code); + } + Ok(()) + } + _ => bail!("{}: unexpected greeting: {}", self.label, r.summary()), + } + } + + fn set_caps_from_code(&mut self, code: &str) { + let mut it = code.split_whitespace(); + if it.next().is_some_and(|w| w.eq_ignore_ascii_case("CAPABILITY")) { + self.caps = it.map(|c| c.to_ascii_uppercase()).collect(); + } + } + + pub fn has_cap(&self, c: &str) -> bool { + self.caps.contains(&c.to_ascii_uppercase()) + } + + async fn read_response(&mut self) -> Result { + let fut = async { + let mut parts = Vec::new(); + loop { + let mut line = Vec::new(); + let n = self.io.read_until(b'\n', &mut line).await?; + if n == 0 { + bail!("{}: connection closed by server", self.label); + } + while matches!(line.last(), Some(b'\n' | b'\r')) { + line.pop(); + } + if let Some(len) = proto::literal_len(&line) { + parts.push(Part::Text(line)); + let mut buf = vec![0u8; len]; + self.io.read_exact(&mut buf).await?; + parts.push(Part::Lit(buf)); + } else { + parts.push(Part::Text(line)); + return Ok(Response { parts }); + } + } + }; + let r: Result = tokio::time::timeout(self.timeout, fut) + .await + .map_err(|_| anyhow!("{}: timed out waiting for server", self.label))?; + let r = r?; + tracing::trace!("{} S: {}", self.label, r.summary()); + Ok(r) + } + + /// Send a command made of raw text and literals; collect untagged + /// responses until the tagged completion. + pub async fn run(&mut self, args: Vec, log_as: &str) -> Result { + self.tag += 1; + let tag = format!("m{}", self.tag); + tracing::trace!("{} C: {tag} {log_as}", self.label); + let mut untagged = Vec::new(); + self.io.write_all(tag.as_bytes()).await?; + self.io.write_all(b" ").await?; + for a in args { + match a { + Arg::Raw(s) => self.io.write_all(s.as_bytes()).await?, + Arg::Lit(data) => { + let nonsync = self.has_cap("LITERAL+") || (self.has_cap("LITERAL-") && data.len() <= 4096); + if nonsync { + self.io.write_all(format!("{{{}+}}\r\n", data.len()).as_bytes()).await?; + } else { + self.io.write_all(format!("{{{}}}\r\n", data.len()).as_bytes()).await?; + self.io.flush().await?; + loop { + let r = self.read_response().await?; + match r.kind() { + Kind::Continuation => break, + Kind::Tagged(t) if t == tag => { + let status = r.status().unwrap_or(Status { + word: "BAD".into(), + code: None, + text: r.summary(), + }); + return Err(ImapNo { cmd: log_as.to_string(), status }.into()); + } + _ => untagged.push(r), + } + } + } + self.io.write_all(&data).await?; + } + } + } + self.io.write_all(b"\r\n").await?; + self.io.flush().await?; + loop { + let r = self.read_response().await?; + match r.kind() { + Kind::Tagged(t) if t == tag => { + let status = + r.status().ok_or_else(|| anyhow!("{}: malformed completion: {}", self.label, r.summary()))?; + if status.word != "OK" { + return Err(ImapNo { cmd: log_as.to_string(), status }.into()); + } + return Ok(CmdResult { untagged, status }); + } + Kind::Continuation => bail!("{}: unexpected continuation for {log_as}", self.label), + _ => { + if r.status().is_some_and(|s| s.word == "BYE") && !log_as.starts_with("LOGOUT") { + tracing::debug!("{}: server said BYE: {}", self.label, r.summary()); + } + untagged.push(r) + } + } + } + } + + pub async fn simple(&mut self, cmd: &str) -> Result { + self.run(vec![Arg::Raw(cmd.to_string())], cmd).await + } + + pub async fn capability(&mut self) -> Result<()> { + let r = self.simple("CAPABILITY").await?; + for u in &r.untagged { + let v = u.values()?; + if v.get(1).is_some_and(|x| x.is_atom("CAPABILITY")) { + self.caps = v[2..].iter().filter_map(|x| x.text()).map(|c| c.to_ascii_uppercase()).collect(); + } + } + Ok(()) + } + + pub async fn login(&mut self, how: Login<'_>) -> Result<()> { + let res = match how { + Login::Plain { user, password } => { + if self.has_cap("LOGINDISABLED") { + bail!("{}: server has LOGINDISABLED (use TLS)", self.label); + } + let mut args = vec![Arg::Raw("LOGIN ".into())]; + args.push(proto::astring(user)); + args.push(Arg::Raw(" ".into())); + args.push(proto::astring(password)); + self.run(args, &format!("LOGIN {user} ****")).await + } + Login::Authzid { authzid, authcid, password } => { + let raw = format!("{authzid}\0{authcid}\0{password}"); + let b64 = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + if self.has_cap("SASL-IR") { + self.run( + vec![Arg::Raw(format!("AUTHENTICATE PLAIN {b64}"))], + &format!("AUTHENTICATE PLAIN {authzid}/{authcid} ****"), + ) + .await + } else { + self.authenticate_continuation(&b64, authzid, authcid).await + } + } + }; + let r = res.map_err(|e| anyhow!("{}: login failed: {e}", self.label))?; + match r.status.code.as_deref() { + Some(code) if code.to_ascii_uppercase().starts_with("CAPABILITY") => self.set_caps_from_code(code), + _ => self.capability().await?, + } + Ok(()) + } + + async fn authenticate_continuation(&mut self, b64: &str, authzid: &str, authcid: &str) -> Result { + self.tag += 1; + let tag = format!("m{}", self.tag); + self.io.write_all(format!("{tag} AUTHENTICATE PLAIN\r\n").as_bytes()).await?; + self.io.flush().await?; + let log_as = format!("AUTHENTICATE PLAIN {authzid}/{authcid} ****"); + let r = self.read_response().await?; + if r.kind() != Kind::Continuation { + bail!("{}: AUTHENTICATE PLAIN refused: {}", self.label, r.summary()); + } + self.io.write_all(format!("{b64}\r\n").as_bytes()).await?; + self.io.flush().await?; + let mut untagged = Vec::new(); + loop { + let r = self.read_response().await?; + match r.kind() { + Kind::Tagged(t) if t == tag => { + let status = r.status().ok_or_else(|| anyhow!("malformed completion"))?; + if status.word != "OK" { + return Err(ImapNo { cmd: log_as, status }.into()); + } + return Ok(CmdResult { untagged, status }); + } + _ => untagged.push(r), + } + } + } + + pub async fn namespace(&mut self) -> Result> { + if !self.has_cap("NAMESPACE") && !self.has_cap("IMAP4REV2") { + return Ok(None); + } + let r = self.simple("NAMESPACE").await?; + for u in &r.untagged { + let v = u.values()?; + if v.get(1).is_some_and(|x| x.is_atom("NAMESPACE")) && v.len() >= 5 { + let mut ns = Namespaces::default(); + let entries = |val: &Value| -> Vec<(String, Option)> { + val.list() + .unwrap_or(&[]) + .iter() + .filter_map(|e| { + let l = e.list()?; + let prefix = l.first()?.text()?; + let delim = l.get(1).and_then(|d| d.text()).and_then(|d| d.chars().next()); + Some((prefix, delim)) + }) + .collect() + }; + ns.personal = entries(&v[2]); + ns.foreign = entries(&v[3]).into_iter().chain(entries(&v[4])).map(|(p, _)| p).collect(); + return Ok(Some(ns)); + } + } + Ok(None) + } + + pub async fn list(&mut self) -> Result> { + let cmd = if self.has_cap("LIST-EXTENDED") && self.has_cap("SPECIAL-USE") { + r#"LIST "" "*" RETURN (SPECIAL-USE)"# + } else { + r#"LIST "" "*""# + }; + let r = self.simple(cmd).await?; + let mut out: Vec = Vec::new(); + for u in &r.untagged { + let v = u.values()?; + if !v.get(1).is_some_and(|x| x.is_atom("LIST")) || v.len() < 5 { + continue; + } + let attrs: Vec = v[2].list().unwrap_or(&[]).iter().filter_map(|a| a.text()).collect(); + let delim = v[3].text().and_then(|d| d.chars().next()); + let wire = v[4].text().unwrap_or_default(); + let wire = if wire.eq_ignore_ascii_case("INBOX") { "INBOX".to_string() } else { wire }; + if let Some(existing) = out.iter_mut().find(|m| m.wire == wire) { + existing.attrs.extend(attrs); + continue; + } + out.push(Mailbox { name: utf7::decode(&wire), wire, delim, attrs }); + } + Ok(out) + } + + pub async fn select(&mut self, wire: &str, readonly: bool) -> Result { + let verb = if readonly { "EXAMINE" } else { "SELECT" }; + let mut args = vec![Arg::Raw(format!("{verb} "))]; + args.push(mailbox_arg(wire)); + let r = self.run(args, &format!("{verb} {wire}")).await?; + let mut info = SelectInfo::default(); + for u in &r.untagged { + if let Some(s) = u.status() { + if let Some(code) = s.code { + let up = code.to_ascii_uppercase(); + if let Some(rest) = up.strip_prefix("UIDVALIDITY ") { + info.uidvalidity = rest.trim().parse().ok(); + } else if up.starts_with("PERMANENTFLAGS") { + let open = code.find('(').unwrap_or(code.len()); + let close = code.rfind(')').unwrap_or(code.len()); + if open < close { + info.permanent_flags = + Some(code[open + 1..close].split_whitespace().map(|s| s.to_string()).collect()); + } + } + } + continue; + } + let v = u.values()?; + if v.len() >= 3 && v[2].is_atom("EXISTS") { + info.exists = v[1].number().unwrap_or(0) as u32; + } + } + Ok(info) + } + + /// Run a UID FETCH and merge the items of all FETCH responses by UID. + async fn uid_fetch(&mut self, set: &str, items: &str) -> Result>> { + let cmd = format!("UID FETCH {set} ({items})"); + let r = self.simple(&cmd).await?; + let mut out: BTreeMap> = BTreeMap::new(); + for u in r.untagged { + if u.status().is_some() { + continue; + } + let v = u.values()?; + if v.len() < 4 || !v[2].is_atom("FETCH") { + continue; + } + let Some(list) = v[3].list() else { continue }; + let mut pairs = Vec::new(); + let mut uid = None; + for kv in list.chunks(2) { + if let [k, val] = kv { + let key = k.text().unwrap_or_default().to_ascii_uppercase(); + if key == "UID" { + uid = val.number().map(|n| n as u32); + } + pairs.push((key, val.clone())); + } + } + if let Some(uid) = uid { + out.entry(uid).or_default().extend(pairs); + } + } + Ok(out) + } + + /// UID, flags, date, size and identifying headers of every message in the + /// selected mailbox. + pub async fn fetch_meta(&mut self) -> Result> { + let items = "UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID DATE FROM SUBJECT)]"; + let map = self.uid_fetch("1:*", items).await?; + Ok(map + .into_iter() + .map(|(uid, pairs)| { + let mut m = MsgMeta { uid, ..Default::default() }; + for (k, v) in pairs { + match k.as_str() { + "FLAGS" => m.flags = flags_of(&v), + "INTERNALDATE" => m.internaldate = v.text(), + "RFC822.SIZE" => m.size = v.number().unwrap_or(0), + k if k.starts_with("BODY[") => m.headers = v.bytes().unwrap_or(&[]).to_vec(), + _ => {} + } + } + m + }) + .collect()) + } + + pub async fn fetch_bodies(&mut self, uids: &[u32]) -> Result> { + if uids.is_empty() { + return Ok(Vec::new()); + } + let set = uid_set(uids); + let map = self.uid_fetch(&set, "UID FLAGS INTERNALDATE BODY.PEEK[]").await?; + let mut out = Vec::new(); + for (uid, pairs) in map { + let mut f = Fetched { uid, ..Default::default() }; + let mut got_body = false; + for (k, v) in pairs { + match k.as_str() { + "FLAGS" => f.flags = flags_of(&v), + "INTERNALDATE" => f.internaldate = v.text(), + k if k.starts_with("BODY[") => { + f.body = v.bytes().unwrap_or(&[]).to_vec(); + got_body = true; + } + _ => {} + } + } + if got_body { + out.push(f); + } + } + Ok(out) + } + + pub async fn append(&mut self, wire: &str, flags: &[String], date: Option<&str>, body: &[u8]) -> Result<()> { + let mut head = String::from("APPEND "); + let mut args = Vec::new(); + args.push(Arg::Raw(std::mem::take(&mut head))); + args.push(mailbox_arg(wire)); + if !flags.is_empty() { + args.push(Arg::Raw(format!(" ({})", flags.join(" ")))); + } + if let Some(d) = date { + args.push(Arg::Raw(format!(" {}", proto::quote(d)))); + } + args.push(Arg::Raw(" ".into())); + args.push(Arg::Lit(body.to_vec())); + self.run(args, &format!("APPEND {wire} <{} bytes>", body.len())).await?; + Ok(()) + } + + /// CREATE, treating "already exists" as success. + pub async fn create(&mut self, wire: &str) -> Result<()> { + let args = vec![Arg::Raw("CREATE ".into()), mailbox_arg(wire)]; + match self.run(args, &format!("CREATE {wire}")).await { + Ok(_) => Ok(()), + Err(e) => match e.downcast_ref::() { + Some(no) + if no.status.code.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("ALREADYEXISTS")) + || no.status.text.to_ascii_lowercase().contains("exist") => + { + Ok(()) + } + _ => Err(e), + }, + } + } + + pub async fn subscribe(&mut self, wire: &str) -> Result<()> { + let args = vec![Arg::Raw("SUBSCRIBE ".into()), mailbox_arg(wire)]; + self.run(args, &format!("SUBSCRIBE {wire}")).await.map(|_| ()) + } + + pub async fn uid_store_flags(&mut self, uid: u32, flags: &[String]) -> Result<()> { + self.simple(&format!("UID STORE {uid} FLAGS.SILENT ({})", flags.join(" "))).await.map(|_| ()) + } + + pub async fn logout(mut self) { + let _ = tokio::time::timeout(Duration::from_secs(5), self.simple("LOGOUT")).await; + } +} + +fn mailbox_arg(wire: &str) -> Arg { + // Always quote mailbox names; INBOX and ASCII names are safe quoted. + if wire.bytes().all(|c| (0x20..0x7f).contains(&c)) { + Arg::Raw(proto::quote(wire)) + } else { + Arg::Lit(wire.as_bytes().to_vec()) + } +} + +fn flags_of(v: &Value) -> Vec { + v.list().unwrap_or(&[]).iter().filter_map(|f| f.text()).collect() +} + +/// Compress sorted UIDs into an IMAP sequence set ("1:3,7,9:10"). +pub fn uid_set(uids: &[u32]) -> String { + let mut sorted = uids.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + let mut out = String::new(); + let mut i = 0; + while i < sorted.len() { + let start = sorted[i]; + let mut end = start; + while i + 1 < sorted.len() && sorted[i + 1] == end + 1 { + i += 1; + end = sorted[i]; + } + if !out.is_empty() { + out.push(','); + } + if start == end { + out.push_str(&start.to_string()); + } else { + out.push_str(&format!("{start}:{end}")); + } + i += 1; + } + out +} + +/// Plain-text greeting + STARTTLS on the raw socket, before the TLS wrap. +async fn starttls_preamble(tcp: &mut TcpStream, label: &str, timeout: Duration) -> Result<()> { + let fut = async { + let mut r = tokio::io::BufReader::new(&mut *tcp); + let mut line = String::new(); + r.read_line(&mut line).await?; + if !line.to_ascii_uppercase().starts_with("* OK") { + bail!("{label}: unexpected greeting: {}", line.trim_end()); + } + r.write_all(b"s0 STARTTLS\r\n").await?; + r.flush().await?; + loop { + line.clear(); + if r.read_line(&mut line).await? == 0 { + bail!("{label}: connection closed during STARTTLS"); + } + if let Some(rest) = line.strip_prefix("s0 ") { + if !rest.to_ascii_uppercase().starts_with("OK") { + bail!("{label}: STARTTLS refused: {}", line.trim_end()); + } + break; + } + } + if !r.buffer().is_empty() { + bail!("{label}: server sent data after STARTTLS (possible injection)"); + } + Ok(()) + }; + tokio::time::timeout(timeout, fut).await.map_err(|_| anyhow!("{label}: STARTTLS timed out"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uid_sets() { + assert_eq!(uid_set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10"); + assert_eq!(uid_set(&[5]), "5"); + assert_eq!(uid_set(&[10, 1, 2]), "1:2,10"); + } + + #[test] + fn permanent_flags() { + let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into(), "\\*".into()]), ..Default::default() }; + assert!(s.accepts_flag("\\Seen")); + assert!(s.accepts_flag("$Forwarded")); + assert!(!s.accepts_flag("\\Flagged")); + let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into()]), ..Default::default() }; + assert!(!s.accepts_flag("$Forwarded")); + } +} diff --git a/src/imap/mod.rs b/src/imap/mod.rs new file mode 100644 index 0000000..7e20ff7 --- /dev/null +++ b/src/imap/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod proto; +pub mod utf7; + +pub use client::{Client, Login, Mailbox, MsgMeta, Namespaces, SelectInfo}; diff --git a/src/imap/proto.rs b/src/imap/proto.rs new file mode 100644 index 0000000..6c2af66 --- /dev/null +++ b/src/imap/proto.rs @@ -0,0 +1,388 @@ +//! IMAP response framing and a small tokenizer for data responses. +//! +//! A server response is one line, optionally interrupted by literals +//! (`{N}\r\n` followed by N raw bytes, then the rest of the line). We keep the +//! pieces as [`Part`]s so literal payloads (message bodies) are never scanned +//! as text. + +use anyhow::{Result, anyhow, bail}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Part { + Text(Vec), + Lit(Vec), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Nil, + Atom(String), + Str(Vec), + List(Vec), +} + +impl Value { + pub fn text(&self) -> Option { + match self { + Value::Atom(a) => Some(a.clone()), + Value::Str(s) => Some(String::from_utf8_lossy(s).into_owned()), + _ => None, + } + } + pub fn bytes(&self) -> Option<&[u8]> { + match self { + Value::Str(s) => Some(s), + Value::Atom(a) => Some(a.as_bytes()), + _ => None, + } + } + pub fn list(&self) -> Option<&[Value]> { + match self { + Value::List(l) => Some(l), + _ => None, + } + } + pub fn number(&self) -> Option { + match self { + Value::Atom(a) => a.parse().ok(), + _ => None, + } + } + pub fn is_atom(&self, s: &str) -> bool { + matches!(self, Value::Atom(a) if a.eq_ignore_ascii_case(s)) + } +} + +#[derive(Debug, Clone)] +pub struct Response { + pub parts: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Kind { + Untagged, + Continuation, + Tagged(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Status { + /// OK / NO / BAD / BYE / PREAUTH (uppercased) + pub word: String, + /// Response code inside `[...]`, without the brackets. + pub code: Option, + pub text: String, +} + +impl Response { + fn first(&self) -> &[u8] { + match self.parts.first() { + Some(Part::Text(t)) => t, + _ => &[], + } + } + + pub fn kind(&self) -> Kind { + let f = self.first(); + if f.starts_with(b"* ") || f == b"*" { + Kind::Untagged + } else if f.starts_with(b"+") { + Kind::Continuation + } else { + let end = f.iter().position(|&c| c == b' ').unwrap_or(f.len()); + Kind::Tagged(String::from_utf8_lossy(&f[..end]).into_owned()) + } + } + + /// Short printable form for logs (literals elided). + pub fn summary(&self) -> String { + let mut s = String::new(); + for p in &self.parts { + match p { + Part::Text(t) => s.push_str(&String::from_utf8_lossy(t)), + Part::Lit(l) => s.push_str(&format!("<{} bytes>", l.len())), + } + if s.len() > 300 { + s.truncate(300); + s.push('…'); + break; + } + } + s + } + + /// For untagged/tagged status responses (`* OK [..] text`, `a1 NO text`). + pub fn status(&self) -> Option { + let f = self.first(); + let sp = f.iter().position(|&c| c == b' ')?; + let rest = &f[sp + 1..]; + let wend = rest.iter().position(|&c| c == b' ').unwrap_or(rest.len()); + let word = String::from_utf8_lossy(&rest[..wend]).to_ascii_uppercase(); + if !matches!(word.as_str(), "OK" | "NO" | "BAD" | "BYE" | "PREAUTH") { + return None; + } + let mut tail = if wend < rest.len() { &rest[wend + 1..] } else { &[][..] }; + let mut code = None; + if tail.first() == Some(&b'[') { + let mut depth = 0; + for (i, &c) in tail.iter().enumerate() { + match c { + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + code = Some(String::from_utf8_lossy(&tail[1..i]).into_owned()); + tail = &tail[(i + 1).min(tail.len())..]; + break; + } + } + _ => {} + } + } + } + Some(Status { word, code, text: String::from_utf8_lossy(tail).trim().to_string() }) + } + + /// Tokenize the whole response (including the leading `*`). + pub fn values(&self) -> Result> { + let mut c = Cursor { parts: &self.parts, pi: 0, bi: 0 }; + let mut out = Vec::new(); + loop { + c.skip_spaces(); + if c.at_end() { + break; + } + out.push(c.value()?); + } + Ok(out) + } +} + +struct Cursor<'a> { + parts: &'a [Part], + pi: usize, + bi: usize, +} + +impl<'a> Cursor<'a> { + fn text(&self) -> Option<&'a [u8]> { + match self.parts.get(self.pi) { + Some(Part::Text(t)) => Some(t.as_slice()), + _ => None, + } + } + fn peek(&self) -> Option { + self.text().and_then(|t| t.get(self.bi).copied()) + } + fn at_end(&self) -> bool { + match self.text() { + Some(t) => self.bi >= t.len() && self.pi + 1 >= self.parts.len(), + None => true, + } + } + fn skip_spaces(&mut self) { + while self.peek() == Some(b' ') { + self.bi += 1; + } + } + + fn value(&mut self) -> Result { + self.skip_spaces(); + match self.peek() { + None => bail!("unexpected end of response"), + Some(b'(') => { + self.bi += 1; + let mut items = Vec::new(); + loop { + self.skip_spaces(); + match self.peek() { + Some(b')') => { + self.bi += 1; + return Ok(Value::List(items)); + } + None => bail!("unterminated list"), + _ => items.push(self.value()?), + } + } + } + Some(b'"') => { + self.bi += 1; + let t = self.text().unwrap(); + let mut s = Vec::new(); + loop { + let c = *t.get(self.bi).ok_or_else(|| anyhow!("unterminated quoted string"))?; + self.bi += 1; + match c { + b'\\' => { + let n = *t.get(self.bi).ok_or_else(|| anyhow!("bad escape"))?; + self.bi += 1; + s.push(n); + } + b'"' => return Ok(Value::Str(s)), + _ => s.push(c), + } + } + } + Some(b'{') => { + let t = self.text().unwrap(); + let close = + t[self.bi..].iter().position(|&c| c == b'}').ok_or_else(|| anyhow!("bad literal marker"))?; + if self.bi + close + 1 != t.len() { + bail!("literal marker not at end of line"); + } + let lit = match self.parts.get(self.pi + 1) { + Some(Part::Lit(l)) => l.clone(), + _ => bail!("literal payload missing"), + }; + self.pi += 2; + self.bi = 0; + Ok(Value::Str(lit)) + } + Some(_) => { + let t = self.text().unwrap(); + let start = self.bi; + let mut depth = 0usize; + while let Some(&c) = t.get(self.bi) { + match c { + b'[' => depth += 1, + b']' => depth = depth.saturating_sub(1), + b' ' | b'(' | b')' if depth == 0 => break, + _ => {} + } + self.bi += 1; + } + let a = String::from_utf8_lossy(&t[start..self.bi]).into_owned(); + if a.eq_ignore_ascii_case("NIL") { Ok(Value::Nil) } else { Ok(Value::Atom(a)) } + } + } + } +} + +/// If a line (CRLF already stripped) ends with a literal marker `{N}` or +/// `{N+}`, return N. +pub fn literal_len(line: &[u8]) -> Option { + if line.last() != Some(&b'}') { + return None; + } + let open = line.iter().rposition(|&c| c == b'{')?; + let inner = &line[open + 1..line.len() - 1]; + let digits = inner.strip_suffix(b"+").unwrap_or(inner); + if digits.is_empty() || !digits.iter().all(|c| c.is_ascii_digit()) { + return None; + } + std::str::from_utf8(digits).ok()?.parse().ok() +} + +/// Encode a string as an IMAP astring argument (atom, quoted or literal). +pub enum Arg { + Raw(String), + Lit(Vec), +} + +pub fn astring(s: &str) -> Arg { + let atom_ok = !s.is_empty() && s.bytes().all(|c| c > 0x20 && c < 0x7f && !b"(){%*\"\\]".contains(&c)); + if atom_ok { + Arg::Raw(s.to_string()) + } else if s.bytes().all(|c| (0x20..0x7f).contains(&c)) { + Arg::Raw(quote(s)) + } else { + Arg::Lit(s.as_bytes().to_vec()) + } +} + +pub fn quote(s: &str) -> String { + let mut q = String::with_capacity(s.len() + 2); + q.push('"'); + for c in s.chars() { + if c == '"' || c == '\\' { + q.push('\\'); + } + q.push(c); + } + q.push('"'); + q +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resp(parts: Vec) -> Response { + Response { parts } + } + fn t(s: &str) -> Part { + Part::Text(s.as_bytes().to_vec()) + } + + #[test] + fn list_response() { + let r = resp(vec![t(r#"* LIST (\HasNoChildren \Sent) "/" "Sent Items""#)]); + let v = r.values().unwrap(); + assert_eq!(v[1], Value::Atom("LIST".into())); + assert_eq!(v[2], Value::List(vec![Value::Atom("\\HasNoChildren".into()), Value::Atom("\\Sent".into())])); + assert_eq!(v[3], Value::Str(b"/".to_vec())); + assert_eq!(v[4], Value::Str(b"Sent Items".to_vec())); + } + + #[test] + fn fetch_with_literal_and_section() { + let r = resp(vec![ + t( + r#"* 3 FETCH (UID 17 FLAGS (\Seen $Forwarded) INTERNALDATE "17-Jul-1996 02:44:25 -0700" RFC822.SIZE 4286 BODY[HEADER.FIELDS (MESSAGE-ID)] {26}"#, + ), + // payload contains parens and quotes that must not be tokenized + Part::Lit(b"Message-ID: \r\n\r\n(x)\")".to_vec()), + t(")"), + ]); + let v = r.values().unwrap(); + let items = v[3].list().unwrap(); + assert!(items[0].is_atom("UID")); + assert_eq!(items[1].number(), Some(17)); + assert_eq!(items[6], Value::Atom("RFC822.SIZE".into())); + assert_eq!(items[7].number(), Some(4286)); + assert_eq!(items[8], Value::Atom("BODY[HEADER.FIELDS (MESSAGE-ID)]".into())); + assert_eq!(items[9].bytes().unwrap().len(), 26); + assert_eq!(items.len(), 10); + } + + #[test] + fn status_codes() { + let r = resp(vec![t("a5 OK [APPENDUID 38505 3955] APPEND completed")]); + assert_eq!(r.kind(), Kind::Tagged("a5".into())); + let s = r.status().unwrap(); + assert_eq!(s.word, "OK"); + assert_eq!(s.code.as_deref(), Some("APPENDUID 38505 3955")); + assert_eq!(s.text, "APPEND completed"); + let r = resp(vec![t("* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited")]); + assert_eq!(r.kind(), Kind::Untagged); + assert_eq!(r.status().unwrap().code.as_deref(), Some("PERMANENTFLAGS (\\Deleted \\Seen \\*)")); + // free text with stray quote must not break status parsing + let r = resp(vec![t("* OK Stalwart \"IMAP ready")]); + assert_eq!(r.status().unwrap().text, "Stalwart \"IMAP ready"); + } + + #[test] + fn literal_marker() { + assert_eq!(literal_len(b"* 1 FETCH (BODY[] {123}"), Some(123)); + assert_eq!(literal_len(b"foo {12+}"), Some(12)); + assert_eq!(literal_len(b"* OK {bar}"), None); + assert_eq!(literal_len(b"* OK"), None); + } + + #[test] + fn astring_encoding() { + assert!(matches!(astring("INBOX"), Arg::Raw(s) if s == "INBOX")); + assert!(matches!(astring("Sent Items"), Arg::Raw(s) if s == "\"Sent Items\"")); + assert!(matches!(astring("a\"b"), Arg::Raw(s) if s == "\"a\\\"b\"")); + assert!(matches!(astring("pässword"), Arg::Lit(_))); + assert!(matches!(astring(""), Arg::Raw(s) if s == "\"\"")); + } + + #[test] + fn namespace_nil() { + let r = resp(vec![t(r#"* NAMESPACE (("" "/")) NIL (("Shared Folders/" "/"))"#)]); + let v = r.values().unwrap(); + assert_eq!(v[2].list().unwrap()[0].list().unwrap()[0], Value::Str(vec![])); + assert_eq!(v[3], Value::Nil); + } +} diff --git a/src/imap/utf7.rs b/src/imap/utf7.rs new file mode 100644 index 0000000..641726b --- /dev/null +++ b/src/imap/utf7.rs @@ -0,0 +1,119 @@ +//! IMAP modified UTF-7 (RFC 3501 §5.1.3) for mailbox names on the wire. + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,"; + +fn b64_val(c: u8) -> Option { + B64.iter().position(|&x| x == c).map(|p| p as u32) +} + +/// Encode a Unicode mailbox name into modified UTF-7. +pub fn encode(s: &str) -> String { + let mut out = String::new(); + let mut pending: Vec = Vec::new(); + let flush = |pending: &mut Vec, out: &mut String| { + if pending.is_empty() { + return; + } + let bytes: Vec = pending.iter().flat_map(|u| u.to_be_bytes()).collect(); + out.push('&'); + let mut bits: u32 = 0; + let mut nbits = 0; + for b in bytes { + bits = (bits << 8) | b as u32; + nbits += 8; + while nbits >= 6 { + nbits -= 6; + out.push(B64[((bits >> nbits) & 0x3f) as usize] as char); + } + } + if nbits > 0 { + out.push(B64[((bits << (6 - nbits)) & 0x3f) as usize] as char); + } + out.push('-'); + pending.clear(); + }; + for ch in s.chars() { + if ('\u{20}'..='\u{7e}').contains(&ch) { + flush(&mut pending, &mut out); + if ch == '&' { + out.push_str("&-"); + } else { + out.push(ch); + } + } else { + let mut buf = [0u16; 2]; + pending.extend_from_slice(ch.encode_utf16(&mut buf)); + } + } + flush(&mut pending, &mut out); + out +} + +/// Decode a modified UTF-7 mailbox name. Invalid input is returned unchanged +/// (some servers send raw UTF-8 names; we pass those through). +pub fn decode(s: &str) -> String { + try_decode(s).unwrap_or_else(|| s.to_string()) +} + +fn try_decode(s: &str) -> Option { + let b = s.as_bytes(); + let mut out = String::new(); + let mut i = 0; + while i < b.len() { + if b[i] == b'&' { + let end = i + 1 + b[i + 1..].iter().position(|&c| c == b'-')?; + if end == i + 1 { + out.push('&'); + } else { + let mut bits: u32 = 0; + let mut nbits = 0; + let mut units = Vec::new(); + let mut bytes = Vec::new(); + for &c in &b[i + 1..end] { + bits = (bits << 6) | b64_val(c)?; + nbits += 6; + if nbits >= 8 { + nbits -= 8; + bytes.push(((bits >> nbits) & 0xff) as u8); + } + } + if bytes.len() % 2 != 0 { + return None; + } + for pair in bytes.chunks(2) { + units.push(u16::from_be_bytes([pair[0], pair[1]])); + } + out.push_str(&String::from_utf16(&units).ok()?); + } + i = end + 1; + } else { + // Raw non-ASCII means the server is not speaking mUTF-7. + if b[i] >= 0x80 { + return None; + } + out.push(b[i] as char); + i += 1; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + for s in ["INBOX", "Sent Items", "Café", "日本語/テスト", "A&B", "~peter/mail/台北/日本語", "😀 emoji"] + { + assert_eq!(decode(&encode(s)), s, "{s}"); + } + } + + #[test] + fn rfc_examples() { + assert_eq!(encode("~peter/mail/台北/日本語"), "~peter/mail/&U,BTFw-/&ZeVnLIqe-"); + assert_eq!(encode("A&B"), "A&-B"); + assert_eq!(decode("&ZeVnLIqe-"), "日本語"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cb18d96 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,544 @@ +//! mailmig: move mailboxes from Stalwart to Simple NixOS Mailserver. +//! +//! Order of operations (see README): +//! 1. `inventory` read accounts/aliases/lists from Stalwart -> plan.toml + secrets.json +//! 2. `users` render SNM accounts (Nix), password hashes, master-user module +//! 3. `check` log in to both sides for every account +//! 4. `sync` copy mail (idempotent; repeat for delta passes) +//! 5. `dns` plan / apply / verify the mail DNS records +//! 6. `verify` final comparison, then remove the master user + +mod config; +mod dns; +mod imap; +mod plan; +mod snm; +mod stalwart; +mod sync; + +use anyhow::{Context, Result, anyhow, bail}; +use clap::{Parser, Subcommand}; +use config::Config; +use plan::{Plan, Secrets}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Parser)] +#[command(version, about = "Migrate mail from Stalwart to Simple NixOS Mailserver over IMAP")] +struct Cli { + /// Config file. + #[arg(short, long, global = true, default_value = "mailmig.toml")] + config: PathBuf, + /// Plan file (accounts to migrate). + #[arg(long, global = true, default_value = "plan.toml")] + plan: PathBuf, + /// Secrets file (password hashes from the source; keep private). + #[arg(long, global = true, default_value = "secrets.json")] + secrets: PathBuf, + /// More output (-v debug, -vv IMAP protocol trace without passwords). + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + verbose: u8, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Write an example mailmig.toml. + Init { + #[arg(long)] + force: bool, + }, + /// Step 1: read the account directory from Stalwart into plan.toml + secrets.json. + Inventory { + /// Overwrite an existing plan.toml (it is meant to be hand-edited). + #[arg(long)] + force: bool, + /// Also save the raw API response (contains hashes!). + #[arg(long)] + raw: Option, + /// Build the plan from a saved raw response instead of the API. + #[arg(long)] + from_raw: Option, + }, + /// Step 2: render SNM accounts (Nix), password hashes and the master-user module. + Users { + #[arg(long, default_value = "out")] + out: PathBuf, + /// Generate random passwords for accounts whose hash cannot be carried over. + #[arg(long)] + generate_missing: bool, + }, + /// Step 3: log in to source and destination for every account (no changes). + Check { + #[arg(short, long)] + account: Vec, + }, + /// Step 4: copy mail. Safe to repeat: only missing messages are copied. + Sync { + #[arg(short, long)] + account: Vec, + /// Accounts in parallel (overrides [sync] jobs). + #[arg(short, long)] + jobs: Option, + /// Show what would be copied without changing anything. + #[arg(long)] + dry_run: bool, + /// Also copy flags (read/answered/flagged) of already-copied messages + /// from the source. Use before the DNS switch only: afterwards users + /// work on the new server and this would undo their changes. + #[arg(long)] + sync_flags: bool, + /// JSON report path (default reports/sync-