commit dceecaf2f4c2489e814c17219211327595c43916 Author: anonsaber Date: Fri Sep 25 09:14:04 2026 +0800 feat: initial MessageWeave release diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a53fec4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target/ +.git/ +.github/ +.env +.env.* +*.pem +*.key +Dockerfile* +docs/ +AGENTS.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..670f7da --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# stalwart-bot production bootstrap (C-REDIS-ONLY-STATE) +# +# The process requires only this environment variable. Redis ACL credentials are +# both the application connection credential and the one-shot bootstrap trust root. +# Never commit or log a real URL/password. +REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0 +# Required 32-byte random hex key (64 hex characters). Never store in Redis or logs. +CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY + +# PORT/RUN_MODE are optional platform overrides (defaults: 8080/webhook). Business +# secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap; +# they are then persisted in Redis and are never echoed by the API. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7abbbb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + container: rust:1-slim-bookworm + steps: + - uses: actions/checkout@v4 + # rust:1-slim-bookworm exposes rustup under /usr/local/cargo/bin but does + # not guarantee that directory is in a non-login shell's PATH. + - name: Install Rust toolchain components + run: | + export PATH="/usr/local/cargo/bin:$PATH" + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add rustfmt clippy + - name: Format check + run: export PATH="/usr/local/cargo/bin:$PATH"; cargo fmt --all -- --check + - name: Clippy + run: export PATH="/usr/local/cargo/bin:$PATH"; cargo clippy --all-targets -- -D warnings + - name: Tests + run: export PATH="/usr/local/cargo/bin:$PATH"; cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b899e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target/ +/Cargo.lock +.env +.env.* +!.env.example +*.pem +*.key +*.p12 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0ef1848 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2267 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[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 = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[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 = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[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", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[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 = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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.6", +] + +[[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-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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +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 = "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 = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +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 = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jmap-client" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f" +dependencies = [ + "ahash", + "async-stream", + "base64 0.22.1", + "chrono", + "futures-util", + "maybe-async", + "parking_lot", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", +] + +[[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 2.0.21", + "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 = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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 = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +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-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[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 = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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 = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "tokio", + "tokio-util", + "url", +] + +[[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-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", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "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-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +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 = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +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 = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + +[[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.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "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 = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[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 = "stalwart-bot" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "jmap-client", + "redis", + "reqwest", + "ring", + "secrecy", + "serde", + "serde_json", + "subtle", + "thiserror 1.0.69", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "url", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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.6", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "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.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[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", + "tracing", +] + +[[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 = [ + "log", + "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 = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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 = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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..c51c621 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "stalwart-bot" +version = "0.1.0" +edition = "2021" + +[dependencies] +async-trait = "0.1" +axum = "0.8" +# Read-only HTTP JMAP only; keep WebSocket/EventSource out of the dependency graph. +jmap-client = { version = "=0.4.2", default-features = false, features = ["async", "rustls"] } +redis = { version = "0.27", default-features = false, features = ["aio", "tokio-comp", "streams", "script"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +ring = "0.17.14" +secrecy = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +subtle = "2" +thiserror = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..92c11c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# C-DEBIAN-SLIM: Debian builder and runtime; no Alpine/musl dependencies. +FROM rust:1-slim-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY web ./web +RUN cargo build --release --locked + +FROM debian:bookworm-slim AS runtime +RUN apt-get update \ + && apt-get install --no-install-recommends -y ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --create-home --home-dir /nonroot --shell /usr/sbin/nologin stalwart-bot +COPY --from=builder /build/target/release/stalwart-bot /usr/local/bin/stalwart-bot +USER stalwart-bot +ENV PORT=8080 \ + RUN_MODE=webhook +EXPOSE 8080 +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/stalwart-bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8364a09 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# stalwart-bot + +一个把 **Stalwart 邮箱**接入 **Telegram** 的个人邮件助手:新邮件来了在 Telegram 提醒你,还能按需查看/总结邮件内容。 + +> ⚠️ **当前仍属早期阶段,还不能真正使用。** +> 目前只有底层功能骨架;**真实邮箱的联调还没验证**。仓库已提供 Debian Dockerfile,但仍不建议生产部署。 + +--- + +## 这个项目是什么 + +- 你在自己的服务器上运行 [Stalwart](https://stalwart.dev) 邮箱。 +- 用本工具把邮箱接到 Telegram 机器人:收到邮件时推送通知(只含发件人/主题/时间,不含正文)。 +- 你可以在 Telegram 里要求查看某封邮件的原文、或让 AI 帮忙总结/翻译。 + +### 隐私原则 +- **查看原文不会经过任何 AI**,直接读邮箱。 +- **只有在你明确要求并确认后**,才把正文发给 AI 分析。 +- 分析结果不会被保存。 +- AI 可以完全不配置、完全关闭。 + +--- + +## 部署前,你需要准备这几样东西 + +1. **一个 Stalwart 邮箱账户**,并为其生成一个 **应用专用密码(App Password)**,而不是账户主密码。 +2. **一个 Telegram 机器人**(通过 @BotFather 创建),拿到它的 **Bot Token**。 +3. **一个 Redis 数据库**(你托管,建议开启 AOF 持久化)。机器人所有状态都放在这里。 +4. **一个公网 HTTPS 地址**。平台会把这个地址指向机器人容器(HTTPS 由平台自动提供)。 +5. **一个定时任务(外部 Cron / 调度器)**:每隔 5–10 分钟访问一次 `/reconcile` 地址,让机器人定期和邮箱对账。机器人自己不会设置定时器。 + +### 需要登记的地址 +- 告诉 Telegram:把 webhook 指向 `你的公网地址/webhook/tg` +- 告诉 Stalwart:把 Push 回调指向 `你的公网地址/push/jmap` + +> 如果你使用「多实例 + Cloudflare Worker 负载均衡」(可选,见 `docs/deployment.md` §10),则把上面三个 URL 指向 **Worker 的稳定域名**(`https://.workers.dev/webhook/tg` 等),不再指向各后端的平台地址。 + +--- + +## 在 Docker 管理器中创建容器 + +创建一个新容器,填好镜像、端口、内存等常规项后,只需注入 Redis 连接凭据: + +| 环境变量 | 必须? | 填什么 | +|---|---|---| +| `REDIS_URL` | ✔ | Redis 连接串(含密码),例如 `redis://:密码@redis.example.com:6379/0` | + +首次启动后,通过受保护的 HTTPS `/api/bootstrap` 提交 Telegram/JMAP/LLM 和业务鉴权配置。配置密钥写入 Redis,API 永不回显;管理员 PUT 成功后会校验并原子热重建客户端,后续请求即时生效,失败保留旧实例。 + +AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary `;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。 + +> Redis 未初始化时服务进入 configuration-only 模式,仅提供 SPA、bootstrap 和探针。 + +--- + +## 启动后怎么看它是否正常 + +- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。 +- 目前 `/ready` 只是占位,**还不能用它判断邮箱/Redis 是否正常**(这个检查以后才会补上)。 + +## 配置管理 + +打开服务根地址 **`你的公网地址/`**,输入 `REDIS_URL` 中 Redis ACL 用户的密码,创建有效期 15 分钟的管理会话。会话与表单中的密钥只保存在当前页面内存;刷新或关闭页面后需要重新授权。页面不使用浏览器存储或 Cookie 保存会话、密钥和配置。 + +页面分开管理两类配置:运行参数(JMAP、Telegram、LLM 请求超时与最大重试次数)可通过 API 读取和保存;业务配置(JMAP、Telegram、LLM、chat allowlist 与各鉴权 token)由 `PUT /api/business-config` 完整替换。为避免密钥回显,服务端没有业务配置读取接口,因此每次完整替换都必须重新输入必填项。保存收到 HTTP 204 后页面会提示新配置已热加载,并清空密钥输入框。 + +Redis 尚未连接或初始化时,管理会话或配置 API 会返回 `503`;页面会说明不可用状态,并在运行参数读取成功前禁用其保存。经 Cloudflare Worker 访问时使用 Worker 根地址;管理 API 路径也已纳入受限路由白名单。 + +## 隐私与运维红线(对用户透明) +- **无数据库、无本地文件**:机器人所有状态都放在你的 Redis 里;容器自身不写任何本地文件、不挂本地卷,重启后由 Redis + 邮箱对账恢复。 +- **日志只走标准输出**:容器只往 stdout/stderr 打印日志,由你的平台/采集器负责收集与落盘;机器人自己**不写日志文件**。 +- **日志里不会写敏感内容**:邮箱正文、密钥、AI 请求/响应、附件内容**都不会**出现在日志或 Redis 里,只记录结构化事件、计数、时间戳、脱敏摘要。 +- 详见 `docs/deployment.md` §0 与 §8.2 的检查清单。 + +--- + +## 目前能做到什么 / 还差什么 + +**已经有了** +- 入口框架、三处安全鉴权、配置加载。 +- 配置管理 SPA:使用短期 Redis admin session;可编辑完整业务配置并热加载,也可读取和保存 `/api/config` 运行参数。 +- 读取 Stalwart 邮箱的基础代码(列文件夹、列邮件、读原文)。 +- **可选**:一个免费 Cloudflare Worker 前置负载均衡(多实例 HA),见 `docs/deployment.md` §10 与 `cloudflare-worker/` 子目录。 + +**还差 / 尚未验证** +- ⚠️ **真实邮箱联调没做**:连真实 Stalwart 的测试因还没有测试环境而跳过,**没有宣称“真机通过”**。 +- **Dockerfile 已提供**(Debian slim 多阶段构建);真实邮箱联调仍未验证,不能按上面的“创建容器”用于生产。 +- Telegram 通知推送、AI 总结、发送邮件等功能**都还没做**。 + +所以:**现在还没有可以下载使用的镜像,请自行构建并等待后续版本。** + +--- + +## 给开发/技术人员 + +架构、设计、部署细节见: + +- [架构与设计](docs/design.md) +- [部署与运维](docs/deployment.md) +- [给 AI 编码助手的规则](AGENTS.md) + +## 许可证 + +(待定) diff --git a/cloudflare-worker/.gitignore b/cloudflare-worker/.gitignore new file mode 100644 index 0000000..db3503c --- /dev/null +++ b/cloudflare-worker/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.build-check/ +.wrangler/ diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md new file mode 100644 index 0000000..f09ff94 --- /dev/null +++ b/cloudflare-worker/README.md @@ -0,0 +1,100 @@ +# Stalwart-bot Cloudflare Worker(ARCH-LB-WORKER) + +统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 +**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 + +> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。 +> 安全基线:`AGENTS.md §2/§4`。 + +## 目录 + +- `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`) +- `src/index.js` — Worker 入口(路由分发、/healthz 聚合、safelist 校验) +- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND) +- `src/proxy.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU) +- `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB) +- `test/*.test.js` — 单元/集成测试(零依赖,`node --test`) +- `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node) + +## 零依赖设计 + +运行时仅依赖 ES2022 标准 + 平台/Node 的 `fetch`/`Headers`/`Request`/`Response`/`URL`。 +`npm run test` 直接 `node --test test/`,**无需安装**任何生产依赖; +`wrangler` 仅在 `check`/`deploy`/`dev` 中作 devDependency(CI 可选装)。 + +## 语义 + +### 透传(A,SAF-LB-PASSTHRU) +- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。 +- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、 + `JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 §10 仅用于多实例互斥,非鉴权) + + 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。 + +### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) +- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。 +- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 +- method 不符 → **405**。 + +### 有界故障转移(§10.4,deployment.md) +- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。 +- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。 +- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。 +- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。 +- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。 + +### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE) +- `GET /healthz`(由 Worker 自身承载):按 TTL 缓存(默认 30s)探测各后端 `/healthz`, + 返回 `{status: ok|down, available, total, backends:[{origin, up, status}]}`; + 有 ≥1 后端 up → 200;全部 down → 503。 +- **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端 `GATE-READY-DEPS` 由 `/ready`(透传)承担。 + +### 无长连接 / 无密钥日志(§4 红线) +- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。 +- 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。 + +## 环境变量 + +| 变量 | 必填 | 说明 | +|---|---|---| +| `BACKEND_ORIGINS_JSON` | 是(secret) | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。 | +| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 | +| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。 | +| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 | + +> secret 通过 `wrangler secret put BACKEND_ORIGINS_JSON` 注入;`wrangler.toml` 里 `vars` 保持为空,不写明文。 + +## 本地 / CI 验证 + +```bash +cd cloudflare-worker + +# 单元/集成测试(零依赖): +npm run test +# 等价:node --test test/ + +# 语法检查 + wrangler dry-run(需 npx wrangler,会拉 devDependency): +npm run check +# 等价:node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run +``` + +## 部署示例 + +```bash +cd cloudflare-worker +# 安装 dev dep(仅 wrangler): +npm install +# 注入 secret: +npx wrangler secret put BACKEND_ORIGINS_JSON # 粘贴 ["https://a.example","https://b.example"] +# 可选调参: +npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 建议 10000 +npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2 +# 部署: +npx wrangler deploy +``` + +健康探针: + +```bash +curl https://lb.stalwart-bot.example/healthz +# => {"status":"ok","available":2,"total":2,"backends":[{"origin":"https://a.example","up":true,"status":200},...]} +``` diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json new file mode 100644 index 0000000..96ff488 --- /dev/null +++ b/cloudflare-worker/package.json @@ -0,0 +1,16 @@ +{ + "name": "@stalwart-bot/lb-worker", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Stalwart-bot Cloudflare Worker: 统一 HTTPS 入口 + 多后端 origin 故障转移(透传模型,SAF-LB-PASSTHRU)。零运行时依赖(仅 ES2022 + Fetch API),可直接 node --test。", + "scripts": { + "test": "node --test test/", + "check": "node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run --outdir=.build-check", + "deploy": "wrangler deploy", + "dev": "wrangler dev --local" + }, + "devDependencies": { + "wrangler": "^3.57.0" + } +} diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js new file mode 100644 index 0000000..45b5cc7 --- /dev/null +++ b/cloudflare-worker/src/backends.js @@ -0,0 +1,118 @@ +/** + * origin 配置解析 + 校验(稳定 ID:C-HTTPS-INBOUND / SAF-LB-PASSTHRU / C-NO-DB)。 + * 后端 origin 仅允许 https(C-HTTPS-INBOUND);不代理 Redis/JMAP(C-NO-DB)。 + * 仅 ES2022,零依赖,可 node 直接执行。 + */ + +/** 允许透传的入站路由 safelist(C-LB-SINGLE-REG-URL;/healthz 由 Worker 自身聚合,不在列):未知路径 → 404。 + * SPA 静态资源为公开 GET;所有管理 API 仍由后端校验 Redis bootstrap 凭据或 admin session。 */ +export const SAFE_ROUTES = Object.freeze([ + "/", + "/assets/config.js", + "/assets/styles.css", + "/api/config", + "/api/business-config", + "/api/admin/session", + "/api/admin/session/revoke", + "/webhook/tg", + "/push/jmap", + "/reconcile", + "/ready", +]); + +/** 需要透传到后端 origin 的路由(不含 Worker 聚合端点)。 */ +export const PROXIED_ROUTES = Object.freeze([...SAFE_ROUTES]); + +export class BackendConfigError extends Error { + constructor(message) { + super(message); + this.name = "BackendConfigError"; + } +} + +const HTTP_STATUS = 80; +const HTTPS_STATUS = 443; + +/** + * 校验并归一化单个 backend origin。 + * 规则(C-HTTPS-INBOUND,对齐 §10 的 fail-closed): + * - 必须是 https; + * - 必须无内嵌凭据(禁 user:pass@host); + * - 必须无 query / fragment(origin 不应带这些); + * - 路径必须为空(origin 根),禁止 /path 形式(防止把路由写进 origin)。 + * @param {string} raw + * @returns {{ url: string, host: string, port: number }} + */ +export function parseBackendOrigin(raw) { + if (typeof raw !== "string" || raw.trim() === "") { + throw new BackendConfigError("backend origin must be a non-empty https URL"); + } + let parsed; + try { + parsed = new URL(raw.trim()); + } catch { + throw new BackendConfigError(`backend origin is not a valid URL: ${raw}`); + } + if (parsed.protocol !== "https:") { + throw new BackendConfigError( + `backend origin must use https (C-HTTPS-INBOUND): ${raw}`, + ); + } + if (parsed.username !== "" || parsed.password !== "") { + throw new BackendConfigError( + `backend origin must not embed credentials (C-HTTPS-INBOUND): ${parsed.host}`, + ); + } + if (parsed.search !== "" || parsed.hash !== "") { + throw new BackendConfigError( + `backend origin must not contain query/fragment: ${parsed.host}`, + ); + } + if (parsed.pathname !== "" && parsed.pathname !== "/") { + throw new BackendConfigError( + `backend origin must be a bare origin (no path): ${raw}`, + ); + } + // 记录端口以便健康探测/请求拼 URL;缺省 443。 + const port = parsed.port !== "" ? Number(parsed.port) : HTTPS_STATUS; + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new BackendConfigError(`backend origin has an invalid port: ${raw}`); + } + return { url: parsed.origin, host: parsed.hostname, port }; +} + +/** + * 解析 `BACKEND_ORIGINS_JSON` 环境变量:JSON 字符串数组。 + * 全部校验通过后返回去重(保持顺序)的 origins;无有效条目则报错。 + * @param {unknown} raw 可能为 JSON 字符串或已解析数组 + * @returns {Array<{url:string, host:string, port:number, source:string}>} + */ +export function parseBackendOrigins(raw) { + let list; + if (Array.isArray(raw)) { + list = raw; + } else if (typeof raw === "string") { + try { + list = JSON.parse(raw); + } catch { + throw new BackendConfigError("BACKEND_ORIGINS_JSON is not valid JSON"); + } + } else { + throw new BackendConfigError("BACKEND_ORIGINS_JSON must be a JSON array of https origins"); + } + if (!Array.isArray(list) || list.length === 0) { + throw new BackendConfigError("BACKEND_ORIGINS_JSON must be a non-empty array"); + } + const seen = new Set(); + const out = []; + for (const entry of list) { + const origin = parseBackendOrigin(entry); + if (!seen.has(origin.url)) { + seen.add(origin.url); + out.push(origin); + } + } + return out; +} + +export { HTTP_STATUS, HTTPS_STATUS }; diff --git a/cloudflare-worker/src/health.js b/cloudflare-worker/src/health.js new file mode 100644 index 0000000..7ed53f1 --- /dev/null +++ b/cloudflare-worker/src/health.js @@ -0,0 +1,80 @@ +/** + * 健康聚合探针(稳定 ID:MOD-HEALTH-AGG │ C-NO-DB │ C-REDIS-ONLY-STATE)。 + * + * 语义: + * - Worker 暴露 LB 级 `GET /healthz`,聚合探测各后端 origin 的 `/healthz`(后端公开探针,无敏感信息)。 + * - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。 + * - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。 + * - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE), + * 就绪依赖检查仍由后端 `/ready`(GATE-READY-DEPS)承担。 + * + * 可测性:fetch / 计时器 / 时钟均依赖注入。 + */ + +export const DEFAULT_HEALTH_TTL_MS = 30_000; + +/** + * 聚合探测所有后端 origin 的 `/healthz`。 + * @param {string[]} origins 已校验的 origin 列表 + * @param {object} ctx { fetch, timeoutMs, ttlMs?, now?, cache?, setTimeout?, clearTimeout? } + * @returns {Promise<{available:number,total:number,backends:Array<{origin:string,up:boolean,status:number|null}>}>} + */ +export async function aggregateHealth(origins, ctx) { + const { + fetch, + timeoutMs, + ttlMs = DEFAULT_HEALTH_TTL_MS, + now = () => Date.now(), + cache, + setTimeout: st = globalThis.setTimeout, + clearTimeout: ct = globalThis.clearTimeout, + } = ctx; + + const backends = []; + let available = 0; + + for (const origin of origins) { + const key = `${origin}/healthz`; + const cached = cache ? cache.get(key) : undefined; + let up = false; + let status = null; + + if (cached && ttlMs > 0 && now() - cached.at < ttlMs) { + up = cached.up; + status = cached.status; + } else { + try { + const controller = new AbortController(); + const timer = st(() => controller.abort(), timeoutMs); + const resp = await fetch(key, { method: "GET", signal: controller.signal }); + ct(timer); + status = resp.status; + up = resp.status < 500; + } catch { + up = false; + status = null; + } + if (cache) cache.set(key, { at: now(), up, status }); + } + + if (up) available += 1; + backends.push({ origin, up, status }); + } + + return { available, total: origins.length, backends }; +} + +/** + * 组装 LB 级 `/healthz` 响应:有 ≥1 后端存活 → 200;全 down → 503;无配置 → 200 + 明确标记。 + * @param {{available:number,total:number,backends:unknown[]}} summary + * @returns {Response} + */ +export function healthResponse(summary) { + const healthy = summary.total > 0 && summary.available > 0; + const status = + summary.total === 0 ? "no-backends" : healthy ? "ok" : "down"; + return new Response(JSON.stringify({ status, ...summary }), { + status: summary.total === 0 ? 200 : healthy ? 200 : 503, + headers: { "content-type": "application/json" }, + }); +} diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js new file mode 100644 index 0000000..0a52962 --- /dev/null +++ b/cloudflare-worker/src/index.js @@ -0,0 +1,150 @@ +/** + * Cloudflare Worker 入口 — Stalwart-bot 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。 + * + * 稳定 ID(AGENTS.md §5/§7): + * - SAF-LB-PASSTHRU:透传 headers/body(含鉴权头),不做鉴权改写。 + * - C-HTTPS-INBOUND:仅 https 后端 origin。 + * - ARCH-LB-WORKER / C-LB-SINGLE-REG-URL:safelist 路由,未知路径 404、method 不符 405。 + * - MOD-HEALTH-AGG:/healthz 由 Worker 聚合,不落后端。 + * - C-NO-DB / C-REDIS-ONLY-STATE:不代理 Redis/JMAP;不检查数据库侧可用性。 + * - C-NO-LONG-CONN:纯请求-响应,body 一次性读入回灌;无 WS/SSE/长轮询。 + * - C-NO-SECRET-IN-IMAGE:日志仅「方法/路径/origin/失败类别」,不含 secret;origin 清单走 wrangler secret。 + * + * 路由模型: + * - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传 + * - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke] + * → 透传至后端;后端校验 bootstrap 凭据或短期 admin session + * - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG) + * - POST /webhook/tg|/push/jmap|/reconcile、GET /ready → 透传 + 有界故障转移 + * - 其它 → 404 / 405 + * + * 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。 + */ + +import { parseBackendOrigins, PROXIED_ROUTES } from "./backends.js"; +import { proxyWithFailover } from "./proxy.js"; +import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js"; + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议) + +/** + * 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。 + * 仅存「origin → {at,up,status}」,不含任何 header/body/secret(SAF-LOG-PURITY 精神)。 + * TTL 由 LB_HEALTH_TTL_MS 控制;isolate 冷启时为空,首个 /healthz 全量探测。 + */ +const healthCache = new Map(); + +/** 各透传路由允许的 method(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL;其余 405,不透传至后端)。 */ +const ROUTE_METHODS = Object.freeze({ + "/": ["GET"], + "/assets/config.js": ["GET"], + "/assets/styles.css": ["GET"], + "/api/config": ["GET", "PUT"], + "/api/business-config": ["PUT"], + "/api/admin/session": ["POST"], + "/api/admin/session/revoke": ["POST"], + "/webhook/tg": ["POST"], + "/push/jmap": ["POST"], + "/reconcile": ["POST"], + "/ready": ["GET"], +}); + +/** + * Worker 主处理器(纯函数,便于单测)。 + * @param {Request} request + * @param {object} env { BACKEND_ORIGINS_JSON, LB_REQUEST_TIMEOUT_MS?, LB_MAX_ATTEMPTS? } + * @returns {Promise} + */ +export async function handleFetch(request, env) { + const path = new URL(request.url).pathname; + + // LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。 + if (path === "/healthz" && request.method === "GET") { + return handleHealth(env); + } + + // safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。 + if (!PROXIED_ROUTES.includes(path)) { + return text(`route not proxied: ${path}`, 404); + } + const allowed = ROUTE_METHODS[path]; + if (!allowed || !allowed.includes(request.method)) { + return text(`method not allowed for ${path}`, 405, { allow: allowed?.join(", ") }); + } + + let origins; + try { + origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url); + } catch { + return text("misconfigured backends", 503); // fail-closed,不泄漏 secret + } + if (origins.length === 0) { + return text("no backends configured", 503); + } + + const timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS); + const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS); + + // 透传转发 + 有界故障转移(仅超时/5xx)。 + return proxyWithFailover(origins, request, { + maxAttempts, + timeoutMs, + fetch: lazyFetch(), + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + rng: Math.random, + log: (msg) => { + try { + console.error(`[lb] ${request.method} ${path}: ${msg}`); + } catch { + /* 日志不可用不致命 */ + } + }, + }); +} + +/** 透传路由的统一错误/兜底日志不打印任何 header/body。 */ +async function handleHealth(env) { + let origins; + try { + origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url); + } catch { + return healthResponse({ available: 0, total: 0, backends: [] }); + } + if (origins.length === 0) { + return healthResponse({ available: 0, total: 0, backends: [] }); + } + const summary = await aggregateHealth(origins, { + fetch: lazyFetch(), + timeoutMs: intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS), + ttlMs: intFromEnv(env.LB_HEALTH_TTL_MS, DEFAULT_HEALTH_TTL_MS), + cache: healthCache, + }); + return healthResponse(summary); +} + +/** 惰性绑定 globalThis.fetch,避免解构时丢失 this。 */ +function lazyFetch() { + return (...args) => globalThis.fetch(...args); +} + +function text(body, status, extraHeaders = {}) { + return new Response(body, { + status, + headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders }, + }); +} + +function intFromEnv(raw, fallback) { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} + +/** Cloudflare Workers module 入口。 */ +export default { + async fetch(request, env, _ctx) { + return handleFetch(request, env); + }, +}; diff --git a/cloudflare-worker/src/proxy.js b/cloudflare-worker/src/proxy.js new file mode 100644 index 0000000..63b87c8 --- /dev/null +++ b/cloudflare-worker/src/proxy.js @@ -0,0 +1,146 @@ +/** + * 请求转发与有界故障转移(稳定 ID:SAF-LB-PASSTHRU / C-NO-LONG-CONN / C-NO-SECRET-IN-IMAGE / deployment.md §10.4)。 + * + * 语义: + * - 透传(A):原样转发 method / headers / body(含鉴权头),不做鉴权改写(SAF-LB-PASSTHRU)。 + * - 仅对「超时 / 5xx」做有界重试:换下一个 origin 再试(deployment.md §10.4 建议 1 次); + * 非 5xx / 非超时直接返回。 + * - 全失败 → 返回 5xx(交由 Telegram 自动重投,不丢消息)。 + * - 无 WebSocket/SSE/长轮询(C-NO-LONG-CONN):纯请求-响应,body 一次性读入后回灌。 + * + * 可测性:所有 I/O(fetch/计时器/随机)通过依赖注入传入,便于 mock。 + * 「无密钥日志」红线(SAF-LOG-PURITY):只打错误类别与 origin(已校验、非敏感), + * **不打印** header/body/secret;不产生任何文件写入,日志仅 stdout。 + */ + +/** + * @typedef {Request} Req + * @typedef {Response} Res + */ + +/** + * 拼接后端 URL:origin + path + query(保留 query,丢弃 fragment)。 + * @param {string} origin 已校验的 https origin + * @param {Req} request + * @returns {string} + */ +export function joinUrl(origin, request) { + const u = new URL(request.url); + return `${origin}${u.pathname}${u.search}`; +} + +/** + * 一次性读取请求 body 为可复用的 Uint8Array(供每个故障转移尝试回灌, + * 避免 Request.body 流在第二次尝试时被“已消费”而抛 TypeError)。 + * @param {Req} request + * @returns {Promise<{headers: Headers, body: Uint8Array | undefined}>} + */ +export async function snapshotBody(request) { + const headers = new Headers(request.headers); + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const body = hasBody ? new Uint8Array(await request.arrayBuffer()) : undefined; + return { headers, body }; +} + +/** 是否「可故障转移」:5xx 状态码、超时/网络类错误(err)。 */ +export function isRetryableError(err, status) { + if (err) { + return isRetryableNetworkError(err); + } + return status >= 500 && status < 600; +} + +/** + * 是否「可故障转移」的网络错误: + * 超时(AbortError)或 fetch/连接层网络错误(Node: `fetch failed`/`ECONNREFUSED` 等)。 + * 其它错误(如 body 读取失败、配置错误)**不**触发故障转移,避免无谓重试。 + */ +function isRetryableNetworkError(err) { + if (!err) return false; + const name = String(err.name || ""); + const msg = String(err.message || ""); + if (name === "AbortError") return true; + if (/fetch failed|network error|socket hang up|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return true; + // 递归 cause 链(Node 的 fetch 网络错误把 errno 藏在 cause)。 + if (err.cause && err.cause !== err) return isRetryableNetworkError(err.cause); + return false; +} + +/** + * 单次 origin 尝试:透传转发。超时/网络错误会 throw(由调用方处理)。 + * 使用预读的 body 快照(Uint8Array),避免 Request.body 流在第二次尝试时“已消费”。 + * @param {string} origin + * @param {Req} request + * @param {object} io { fetch, setTimeout, clearTimeout, timeoutMs } + * @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出 + * @returns {Promise} + */ +export async function proxyRequest(origin, request, io, snapshot) { + const { fetch, setTimeout: st, clearTimeout: ct, timeoutMs } = io; + const url = joinUrl(origin, request); + const controller = new AbortController(); + const timer = st(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(url, { + method: request.method, + headers: snapshot.headers, + body: snapshot.body ? new Uint8Array(snapshot.body) : undefined, + redirect: "follow", + signal: controller.signal, + }); + return resp; + } finally { + ct(timer); + } +} + +/** + * 有界故障转移:按序尝试 origins(起点随机化实现双活分摊,确定性可注入); + * 仅超时/5xx 才换下一个实例;全失败 → 5xx 聚合响应。 + * @param {string[]} origins 有序 origin 列表(≥1) + * @param {Req} request 入站请求 + * @param {object} opts { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng?, log? } + * @returns {Promise} + */ +export async function proxyWithFailover(origins, request, opts) { + const { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng, log } = opts; + const start = origins.length > 1 && rng ? Math.floor(rng() * origins.length) : 0; + const queue = origins.slice(start).concat(origins.slice(0, start)); + const attempts = Math.max(1, Math.min(maxAttempts, queue.length)); + let lastFailure = "no backends configured"; + // 一次性读 body 快照:让每次故障转移尝试可回灌同一请求体(body 流只能读一次)。 + const snapshot = await snapshotBody(request); + + for (let i = 0; i < attempts; i += 1) { + const origin = queue[i]; + const io = { fetch, setTimeout, clearTimeout, timeoutMs }; + try { + const resp = await proxyRequest(origin, request, io, snapshot); + // 非 5xx / 非网络错误:直接返回(4xx、2xx、3xx 均原样透传,不故障转移)。 + if (!isRetryableError(null, resp.status)) { + return mark(resp, origin); + } + // 5xx:释放 body,换下一个实例。 + resp.body?.cancel?.().catch(() => {}); + lastFailure = `backend ${origin} returned HTTP ${resp.status}`; + if (log) log(`retrying ${request.method} ${new URL(request.url).pathname}: ${lastFailure}`); + } catch (err) { + lastFailure = `backend ${origin} unavailable (${err && err.name ? err.name : "error"})`; + if (log) log(lastFailure); + // 仅「可故障转移」的网络类失败(超时/连接错误)才换实例;其它错误 fail-fast,避免无限重试。 + if (!isRetryableError(err, 0)) break; + } + } + // 全部 origin 失败 → 5xx(交由 Telegram/Stalwart 自动重投兜底,不丢消息)。 + return new Response(`all backends failed: ${lastFailure}`, { + status: 503, + statusText: "All Backends Unavailable", + headers: { "content-type": "text/plain; charset=utf-8" }, + }); +} + +/** 在响应上加轻量可观测头(不改 body/状态语义;origin 非敏感,含在响应头中便于排障)。 */ +function mark(resp, origin) { + resp.headers.set("x-lb-backend", origin); + return resp; +} diff --git a/cloudflare-worker/test/backends.test.js b/cloudflare-worker/test/backends.test.js new file mode 100644 index 0000000..d3a3b85 --- /dev/null +++ b/cloudflare-worker/test/backends.test.js @@ -0,0 +1,61 @@ +/** + * 单元测试:origin configuration 校验(C-HTTPS-INBOUND / C-LB-SINGLE-REG-URL)。 + * 运行:cd cloudflare-worker && node --test + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseBackendOrigin, parseBackendOrigins, SAFE_ROUTES, PROXIED_ROUTES } from "../src/backends.js"; + +test("http origin is rejected (https-only, fail-closed)", () => { + assert.throws(() => parseBackendOrigin("http://bot.example"), /https/); +}); + +test("non-https / invalid origins are rejected", () => { + for (const bad of ["", " ", "not a url", "://x", "ws://bot.example", "wss://bot.example", "http://x"]) { + assert.throws(() => parseBackendOrigin(bad), Error, `must reject: ${deep(bad)}`); + } +}); + +test("embedded credentials / query / fragment / path are rejected", () => { + assert.throws(() => parseBackendOrigin("https://user:pass@bot.example"), /credentials/); + assert.throws(() => parseBackendOrigin("https://bot.example?x=1"), /query|fragment/); + assert.throws(() => parseBackendOrigin("https://bot.example#frag"), /query|fragment/); + assert.throws(() => parseBackendOrigin("https://bot.example/api"), /bare origin/); +}); + +test("valid https origins normalize to origin form", () => { + const a = parseBackendOrigin("https://bot.example/"); + assert.equal(a.url, "https://bot.example"); + assert.equal(a.host, "bot.example"); + assert.equal(a.port, 443); + const b = parseBackendOrigin("https://bot.example:8443"); + assert.equal(b.url, "https://bot.example:8443"); + assert.equal(b.port, 8443); +}); + +test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => { + const raw = JSON.stringify(["https://a.example", "https://b.example", "https://a.example"]); + const list = parseBackendOrigins(raw); + assert.deepEqual(list.map((o) => o.url), ["https://a.example", "https://b.example"]); + assert.throws(() => parseBackendOrigins(JSON.stringify(["https://a.example", "http://c.example"])), /https/); + assert.throws(() => parseBackendOrigins("nope"), /valid JSON/); + assert.throws(() => parseBackendOrigins("[]"), /non-empty/); +}); + +test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => { + for (const r of [ + "/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", + "/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", + ]) { + assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`); + } + for (const r of ["/healthz", "/admin", "/debug", "/jmap/session", "/ready/extra"]) { + assert.ok(!PROXIED_ROUTES.includes(r), `must be blocked: ${r}`); + assert.ok(!SAFE_ROUTES.includes(r), `must not be a safe route: ${r}`); + } +}); + +/** 让断言失败信息可读,且不泄漏敏感值。 */ +function deep(v) { + return typeof v === "string" && v.length > 48 ? `${v.slice(0, 12)}…(${v.length})` : String(v); +} diff --git a/cloudflare-worker/test/health.test.js b/cloudflare-worker/test/health.test.js new file mode 100644 index 0000000..04cd3af --- /dev/null +++ b/cloudflare-worker/test/health.test.js @@ -0,0 +1,97 @@ +/** + * 单元测试:健康聚合(MOD-HEALTH-AGG / C-NO-DB / C-REDIS-ONLY-STATE)。 + * 运行:cd cloudflare-worker && node --test + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { aggregateHealth, healthResponse } from "../src/health.js"; + +function mockCtx(plan, opts = {}) { + const calls = []; + const cache = opts.cache ?? new Map(); + let time = 1_000_000; + const fetch = async (url) => { + calls.push(url); + const step = plan[url] ?? { status: 500 }; + if (step.error) throw step.error; + return new Response(step.body ?? "ok", { status: step.status }); + }; + return { + fetch, + timeoutMs: 1000, + ttlMs: opts.ttlMs ?? 30_000, + now: () => time, + advance: (ms) => { + time += ms; + }, + setTimeout: () => 0, + clearTimeout: () => {}, + cache, + calls, + }; +} + +test("aggregateHealth: all up => 200 ok summary", async () => { + const ctx = mockCtx({ + "https://a.example/healthz": { status: 200 }, + "https://b.example/healthz": { status: 200 }, + }); + const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx); + assert.equal(res.available, 2); + assert.equal(res.total, 2); + const resp = healthResponse(res); + assert.equal(resp.status, 200); + const body = await resp.json(); + assert.equal(body.status, "ok"); + assert.equal(body.available, 2); + assert.equal(body.total, 2); +}); + +test("aggregateHealth: partial down => still reports status (partial ok)", async () => { + const ctx = mockCtx({ + "https://a.example/healthz": { status: 200 }, + "https://b.example/healthz": { error: new Error("conn refused") }, + }); + const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx); + assert.equal(res.available, 1); + assert.equal(res.total, 2); + const resp = healthResponse(res); + assert.equal(resp.status, 200, "at least one up => 200 ok"); + const body = await resp.json(); + assert.equal(body.status, "ok"); + assert.equal(body.available, 1); +}); + +test("aggregateHealth: all down => 503", async () => { + const ctx = mockCtx({ + "https://a.example/healthz": { status: 503 }, + "https://b.example/healthz": { error: new Error("conn refused") }, + }); + const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx); + assert.equal(res.available, 0); + const resp = healthResponse(res); + assert.equal(resp.status, 503); + const body = await resp.json(); + assert.equal(body.status, "down"); +}); + +test("aggregateHealth: respects cache TTL to avoid probe storm", async () => { + const ctx = mockCtx( + { + "https://a.example/healthz": { status: 200 }, + }, + { ttlMs: 10_000 }, + ); + await aggregateHealth(["https://a.example"], ctx); + assert.equal(ctx.calls.length, 1); + + // 未过 TTL:命中缓存,不再发起 fetch。 + ctx.advance(5_000); + await aggregateHealth(["https://a.example"], ctx); + assert.equal(ctx.calls.length, 1); + + // 超过 TTL:发起新 fetch。 + ctx.advance(6_000); + await aggregateHealth(["https://a.example"], ctx); + assert.equal(ctx.calls.length, 2); +}); diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js new file mode 100644 index 0000000..8ed389f --- /dev/null +++ b/cloudflare-worker/test/index.test.js @@ -0,0 +1,109 @@ +/** + * 集成测试:Cloudflare Worker 主入口 handle fetch(ARCH-LB-WORKER / SAF-LB-PASSTHRU)。 + * 验证:路由分发、/healthz 聚合、未白名单 404、method 拦截、配置异常 fail-closed、鉴权头透传。 + * 运行:cd cloudflare-worker && node --test + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { handleFetch } from "../src/index.js"; + +function makeEnv(overrides = {}) { + return { + BACKEND_ORIGINS_JSON: JSON.stringify(["https://a.example", "https://b.example"]), + LB_REQUEST_TIMEOUT_MS: "5000", + LB_MAX_ATTEMPTS: "2", + ...overrides, + }; +} + +/** 安装/卸载 globalThis.fetch mock。 */ +function stubFetch(impl) { + const orig = globalThis.fetch; + globalThis.fetch = impl; + return () => { + globalThis.fetch = orig; + }; +} + +test("Worker GET /healthz aggregates backend probes without touching proxied routes", async () => { + const restore = stubFetch(async (url) => new Response("ok", { status: 200 })); + try { + const res = await handleFetch(new Request("https://lb.example/healthz", { method: "GET" }), makeEnv()); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.status, "ok"); + assert.equal(body.available, 2); + assert.equal(body.total, 2); + assert.ok(Array.isArray(body.backends)); + } finally { + restore(); + } +}); + +test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => { + const res = await handleFetch( + new Request("https://lb.example/unknown", { method: "POST", body: "{}" }), + makeEnv(), + ); + assert.equal(res.status, 404); + assert.match(await res.text(), /route not proxied/); +}); + +test("Worker: wrong method on safelist route => 405", async () => { + const res = await handleFetch( + new Request("https://lb.example/webhook/tg", { method: "GET" }), + makeEnv(), + ); + assert.equal(res.status, 405); +}); + +test("Worker: business config PUT is proxied with the session bearer and empty 204 response", async () => { + const restore = stubFetch(async (url, init) => { + assert.match(url, /^https:\/\/(a|b)\.example\/api\/business-config$/); + assert.equal(init.method, "PUT"); + assert.equal(init.headers.get("authorization"), "Bearer opaque-session"); + assert.equal(await new Response(init.body).text(), "{\"llm_enabled\":false}"); + return new Response(null, { status: 204 }); + }); + try { + const res = await handleFetch( + new Request("https://lb.example/api/business-config", { + method: "PUT", + headers: { authorization: "Bearer opaque-session", "content-type": "application/json" }, + body: "{\"llm_enabled\":false}", + }), + makeEnv(), + ); + assert.equal(res.status, 204); + } finally { + restore(); + } +}); + +test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => { + const res = await handleFetch( + new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }), + makeEnv({ BACKEND_ORIGINS_JSON: "not-json" }), + ); + assert.equal(res.status, 503); + assert.equal(await res.text(), "misconfigured backends"); +}); + +test("Worker: valid env, GET /ready proxied to backend with auth header intact", async () => { + const restore = stubFetch(async (url, init) => { + assert.match(url, /^https:\/\/(a|b)\.example\/ready$/); + return new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); + }); + try { + const res = await handleFetch( + new Request("https://lb.example/ready", { + method: "GET", + headers: { authorization: "Bearer lb-test" }, + }), + makeEnv(), + ); + assert.equal(res.status, 200); + } finally { + restore(); + } +}); diff --git a/cloudflare-worker/test/proxy.test.js b/cloudflare-worker/test/proxy.test.js new file mode 100644 index 0000000..988e508 --- /dev/null +++ b/cloudflare-worker/test/proxy.test.js @@ -0,0 +1,172 @@ +/** + * 单元测试:透传转发 + 有界故障转移 + 全失败 5xx(SAF-LB-PASSTHRU / SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE)。 + * 运行:cd cloudflare-worker && node --test + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { proxyWithFailover, joinUrl, isRetryableError } from "../src/proxy.js"; + +/** + * 可控 fetch mock:steps[i] 每次调用消费一个; + * - {status, body?} → 返回该 Response; + * - {error: Error} → 抛出。 + */ +function mockFetch(steps) { + const calls = []; + const fetch = async (url, init) => { + const i = calls.length; + calls.push({ url, init }); + const step = steps[Math.min(i, steps.length - 1)]; + if (step.error) throw step.error; + return new Response(step.body ?? "ok", { status: step.status, headers: { "content-type": "text/plain" } }); + }; + return { fetch, calls }; +} + +/** 测试用计时器:不真触发超时。 */ +const noOpTimers = { setTimeout: () => 0, clearTimeout: () => {} }; + +function req(path = "/reconcile") { + return new Request(`https://lb.example${path}`, { + method: "POST", + headers: { authorization: "Bearer secret-token", "content-type": "application/json" }, + body: JSON.stringify({ id: 42 }), + }); +} + +function abortError() { + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + return err; +} + +test("isRetryableError: only 5xx (status) and timeout/network (err) are retryable", () => { + assert.equal(isRetryableError(new Error("boom"), 0), false, "generic error not retryable"); + assert.equal(isRetryableError(abortError(), 0), true, "AbortError retryable"); + assert.equal(isRetryableError(new Error("fetch failed"), 0), true, "network-class message retryable"); + assert.equal(isRetryableError(new Error("bad inner state"), 0), false, "non-network error not retryable"); + assert.equal(isRetryableError(null, 503), true, "5xx retryable"); + assert.equal(isRetryableError(null, 404), false, "4xx not retryable"); + assert.equal(isRetryableError(null, 200), false, "2xx not retryable"); +}); + +test("transparent proxy forwards headers + body + method (SAF-LB-PASSTHRU)", async () => { + const { fetch, calls } = mockFetch([{ status: 202, body: "accepted" }]); + const res = await proxyWithFailover(["https://a.example"], req("/webhook/tg"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0.5, + }); + assert.equal(res.status, 202); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://a.example/webhook/tg"); + assert.equal(calls[0].init.method, "POST"); + assert.equal(calls[0].init.headers.get("authorization"), "Bearer secret-token"); + assert.equal(calls[0].init.headers.get("content-type"), "application/json"); + const body = await new Response(new Uint8Array(calls[0].init.body)).text(); + assert.equal(body, JSON.stringify({ id: 42 })); + assert.equal(res.headers.get("x-lb-backend"), "https://a.example"); +}); + +test("GET/HEAD do not forward a body", async () => { + const { fetch, calls } = mockFetch([{ status: 200 }]); + const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } }); + await proxyWithFailover(["https://a.example"], get, { + ...noOpTimers, + fetch, + maxAttempts: 1, + timeoutMs: 5000, + rng: () => 0, + }); + assert.equal(calls[0].init.body, undefined, "GET must not carry a body"); +}); + +test("4xx is returned as-is WITHOUT retry and without failing over", async () => { + const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]); + const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0, + }); + assert.equal(res.status, 401); + assert.equal(calls.length, 1, "must NOT retry/share next backend on 4xx"); + assert.equal(calls[0].url, "https://a.example/push/jmap"); +}); + +test("5xx triggers failover to next origin, then returns success", async () => { + const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]); + const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0, // 确定性起点 a + }); + assert.equal(res.status, 200); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, "https://a.example/push/jmap"); + assert.equal(calls[1].url, "https://b.example/push/jmap"); + assert.equal(res.headers.get("x-lb-backend"), "https://b.example"); +}); + +test("timeout(AbortError) triggers failover and recovery", async () => { + const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]); + const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0, + }); + assert.equal(res.status, 200); + assert.equal(calls.length, 2); +}); + +test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)", async () => { + const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]); + const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0, + }); + assert.equal(res.status, 503); + assert.equal(calls.length, 2); + assert.match(await res.text(), /all backends failed/); +}); + +test("bounded attempts: maxAttempts caps the number of origin tries", async () => { + const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移) + const res = await proxyWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0, + }); + assert.equal(res.status, 503); + assert.equal(calls.length, 2, "must stop after maxAttempts=2 (bounded, no infinite retry)"); +}); + +test("non-retryable (non-network) error fails fast without burning other origins", async () => { + const { fetch, calls } = mockFetch([{ error: new Error("invalid inner state") }, { status: 200 }]); + const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), { + ...noOpTimers, + fetch, + maxAttempts: 2, + timeoutMs: 5000, + rng: () => 0, + }); + assert.equal(res.status, 503); + assert.equal(calls.length, 1, "non-network error must not failover"); +}); + +test("joinUrl preserves path + query, discards fragment", () => { + const r = new Request("https://lb.example/reconcile?cursor=abc#frag", { method: "POST" }); + assert.equal(joinUrl("https://a.example", r), "https://a.example/reconcile?cursor=abc"); +}); diff --git a/cloudflare-worker/tsconfig.json b/cloudflare-worker/tsconfig.json new file mode 100644 index 0000000..6be21f7 --- /dev/null +++ b/cloudflare-worker/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "noUnusedLocals": true + }, + "include": ["src/**/*.js"] +} diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000..9188e8c --- /dev/null +++ b/cloudflare-worker/wrangler.toml @@ -0,0 +1,31 @@ +/** + * Wrangler 示例配置 — Stalwart-bot 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。 + * + * 安全约束(AGENTS.md §2/§4,稳定 ID): + * - 后端 origin 仅允许 https(C-HTTPS-INBOUND / C-HTTPS-URL);env 校验时拒绝 + * http/非 https/内嵌凭据。 + * - 不代理 Redis/JMAP(C-NO-DB / C-REDIS-ONLY-STATE):Worker 只做请求转发,后端连自己的 Redis。 + * - 不使用 WebSocket/SSE/长轮询(C-NO-LONG-CONN):纯请求-响应转发。 + * - 后端 secret 走 `wrangler secret`(见 README),不进代码/日志(SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE; + * C-LB-SHARED-SECRETS:所有后端实例共享同一组 secret)。 + * - 日志无密钥(SAF-LOG-PURITY):只打「方法/路径/origin/失败类别」,origin 清单走 secret。 + * - 仅允许转发 safelist 路由(C-LB-SINGLE-REG-URL);未知路径 404。 + * + * 部署: + * cd cloudflare-worker + * npx wrangler secret put BACKEND_ORIGINS_JSON # 见下方示例格式 + * npx wrangler deploy + */ +name = "stalwart-bot-lb" +main = "src/index.js" +compatibility_date = "2025-01-01" +compatibility_flags = ["nodejs_compat_v2"] + +# 统一 HTTPS 入站(C-HTTPS-URL;平台代 TLS,Worker 内为短请求模型)。 +# 后端 origin 为 secret(不经明文 env 流转): +# BACKEND_ORIGINS_JSON 形如 ["https://a.platform1.example","https://b.platform2.example"] +# LB_HEALTH_TTL_MS 可选,健康缓存 TTL(默认 30000) +# LB_REQUEST_TIMEOUT_MS 可选,单 origin 超时(默认 10000,建议 > 最坏 cold start) +# LB_MAX_ATTEMPTS 可选,每请求最多 origin 尝试次数(默认 2:首次 + 1 次故障转移, +# 对应 deployment.md §10.4「有界重试其它实例(建议 1 次)」) +vars = {} diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..22f0ed5 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,428 @@ +# Stalwart-bot 部署方案(通用 HTTPS-only Docker) + +> 部署目标:**通用 Docker 容器平台**(任何能跑 Docker 的 VPS / 云主机 / k8s / 自托管容器平台), +> **不绑定** Cloud Run / Lambda / CF Workers / Deno Deploy 等任何具体平台(`NG-SERVERLESS-BIND`)。 +> +> 保持 Docker health / PORT 等通用 HTTP 约定(`C-PORT`)。 +> 部署形态 = **短请求模型**:Telegram Webhook + Stalwart JMAP Push HTTPS 回调 + 外部 HTTPS Cron 对账。 +> 硬约束见 [§0](#0-硬约束);非目标见 [§1](#1-非目标non-goals); +> 架构 / 产品行为以 [design.md](design.md) 为准,本文只写部署/运维。 + +--- + +## 0. 硬约束 + +> 下述约束均为**不可违反**项(对应 AGENTS.md 硬边界)。跨文档引用一律用稳定 ID,索引见 AGENTS.md「跨文档引用索引表」。 + +| ID | 约束 | +|---|---| +| **C-DOCKER** | 必须是 Docker 容器;禁止非 Docker 直装 | +| **C-DEBIAN-SLIM** | Debian 系 slim,禁止 Alpine/musl | +| **C-NO-SECRET-IN-IMAGE** | secrets 不进镜像(构建 ARG/ENV 不得含 token/密码) | +| **C-RUSTLS** | rustls + rustls-native-roots,不依赖系统 OpenSSL | +| **C-HTTPS-INBOUND** | 只提供 HTTPS 入站:容器内仅监听明文 HTTP,TLS 由平台入口/反向代理统一终止;无代理场景才允许容器内自服务 TLS | +| **C-HTTPS-URL** | **公网 HTTPS 入口由平台提供**:平台/反代给 bot 一个公网 HTTPS URL(如 `https://bot.example.com`),Telegram Webhook 与 Stalwart Push 回调都指向该 URL 的路径;bot 自身不申请证书、不监听 443 | +| **C-NO-TCP-EXPOSE** | 不依赖 TCP 端口暴露:全容器**单监听端口 `PORT`(默认 8080)**;对外只走 HTTP 路由(webhook / push / reconcile / health);不暴露任何附加 TCP 端口(如 admin 端口) | +| **C-NO-LONG-CONN** | 不使用任何长连接:无 JMAP EventSource/SSE、无 WebSocket、无 Telegram 长轮询。实时性仅由 **Push HTTPS 回调 + 外部 HTTPS Cron 对账** 保证 | +| **C-REDIS-ONLY-STATE** | 无状态形态只依赖**外部 Redis**:会话、去重、Redis Streams、熔断计数、**sinceState 游标** 全部走 Redis;**不用 SQLite、不用本地卷**(`NG-SQLITE-PERSIST` / `NG-LOCAL-VOLUME`) | +| **C-REDIS-MANAGED-AOF** | Redis 由**用户托管**(自建或托管服务)并**开启 AOF 持久化**:Bot 不自建、不管理 Redis 进程;AOF 保证 Streams 队列 / 去重表 / sinceState 重启不丢 | +| **C-NO-DB** | **生产不使用任何数据库**:无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一的状态存储(`C-REDIS-ONLY-STATE`)。应用不得自带、不自建、不连接第二个数据库实例 | +| **C-NO-LOCAL-WRITE** | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷(`NG-LOCAL-VOLUME`/`NG-SQLITE-PERSIST`) | +| **C-LOG-STDOUT-ONLY** | **日志只写 stdout/stderr**(容器平台/运行时负责采集落盘);禁用 `rolling-file`、`FileAppender` 等文件日志后端;日志中**不得**出现密钥、邮件正文、AI 请求/响应内容(`SAF-LOG-PURITY`) | +| **SAF-LOG-PURITY** | 日志/Redis 写入内容**仅限**:结构化事件 ID、状态机转移、计数、时间戳、脱敏后的请求摘要(request id、状态码、耗时);禁止 `password`/`token`/`secret`/`Authorization` 原文、JMAP 正文、LLM prompt/completion、附件内容 | +| **C-NO-STATEFUL-RECOVERY** | **禁止依赖进程内状态做生产恢复**:任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账(`FLOW-RECONCILE`/`C-REDIS-ONLY-STATE`)实现;进程内缓存仅为性能优化,丢失必须安全可重入 | + +--- + +## 1. 非目标(Non-Goals) + +> 以下均为**历史早期参考**,**非目标、不再支持**。不得在实现中复活为运行模式。 + +| ID | 非目标 | 说明 | +|---|---|---| +| **NG-SERVER-MODE** | `RUN_MODE=server` 常驻运行模式 | 早期 EventSource+长轮询 常驻设计,已删除 | +| **NG-POLLING-SSE** | JMAP EventSource/SSE 长连接订阅 | 与 `C-NO-LONG-CONN` 冲突;实时通道只用 Push 回调 | +| **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook | +| **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) | +| **NG-LOCAL-VOLUME** | `/app/data` 等本地卷持久化 | 无本地持久层(`C-REDIS-ONLY-STATE`) | +| **NG-SERVERLESS-BIND** | 绑定 Cloud Run / Lambda / CF Workers / Deno 等具体平台 | 部署目标是通用 Docker 容器平台;不绑定平台 | + +--- + +## 2. Debian Slim 选型 + +(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定) + +--- + +## 3. Rust 多阶段构建与运行 + +### 3.1 构建阶段 +(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 `stalwart-bot`) + +> **实现现状**(阶段0 + 阶段1,`ARCH-STAGE0`/`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):自动依赖含 `axum 0.8`(单端口入口)/`serde`/`serde_json`/`thiserror`/`secrecy`/`subtle`(常数时间比较)/`url`/`tokio`/`tracing` 等;**`jmap-client =0.4.2` 已引入**(阶段1,`default-features=false, features=["async","rustls"]`,**禁用 WebSocket feature**,`C-NO-LONG-CONN`);三条入口路由的鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`),**JMAP 只读 adapter(G1/D-G1-1)代码已实现、待真实 `cargo test -- --ignored jmap::` 验证**,其余业务体仍为**占位**;`teloxide`(阶段2)/`redis`(阶段3/4)/`reqwest`(阶段3.5) 尚未引入;配置为环境变量手工解析(`ARCH-CONFIG-ENV`,无 figment/TOML)。CI 门禁见 §8(`cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test`,在 Debian 容器内,`GATE-P0`)。 + +### 3.2 运行阶段(收敛为单端口) +- `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口) +- `ENV PORT=8080`、`RUN_MODE=webhook`(默认) +- 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口 +- **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`) + +--- + +## 4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口 + +- 非 root 用户运行(保留既有约定) +- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、/push/jmap、/reconcile 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。 +- 需要在 Stalwart 与 Telegram 两侧登记这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;Stalwart PushSubscription 的 `url` 指向 `/push/jmap`。 +- **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。 +- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接) + +--- + +## 5. Secret 管理与环境变量 + +> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;下表中的业务环境变量是历史阶段说明,不应再注入生产容器。业务密钥通过受保护的 `/api/bootstrap` 或管理员 PUT 写入 Redis,并在成功后热重建客户端。 + +(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽;新增运行模式相关变量) + +| 变量 | 必填 | 说明 | +|---|---|---| +| `BOT_TOKEN` | ✅ | Telegram Bot Token | +| `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) | +| `JMAP_SESSION_URL` | ✅ | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):填**服务基地址**(`https://mail.example.com`)或**完整** `…/.well-known/jmap` 均可;代码归一化为 origin/base 后再交 `jmap-client`,**不产生重复路径**。仅 HTTPS;**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) | +| `JMAP_USERNAME` / `JMAP_PASSWORD` | ✅ | **Stalwart 认证 = App Password + Basic**(已确认 `C-AUTH-APP-BASIC`):账号填邮箱,密码填在 Stalwart 生成的**应用专用密码**(可独立吊销/设到期);不用主密码、不用 OAuth | +| `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 | +| `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;Redis 进程不在本 compose 内 | +| `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) | +| `RUN_MODE` | 默认 `webhook` | `webhook` / `reconcile` 二选一(`NG-SERVER-MODE` 已删除) | +| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer ` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) | +| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session | +| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) | +| `JMAP_PUSH_VERIFICATION` | ✅ | `/push/jmap` 校验请求体 JSON 字段 `verificationCode`(`SAF-AUTH-JMAP-PUSH`)。须与 Stalwart PushSubscription 的 verification code **完全一致**(`SecretString`) | +| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) | + +AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。 +| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 | + +> **三个入口鉴权变量均为必填(fail-closed,`SAF-AUTH-*`)**:`RECONCILE_TOKEN` / `TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` 缺失即**启动失败**(`Config::from_env()` 报缺失),不存在"未配置则放行"的降级路径。三个入口分别校验:`/reconcile`(Bearer 头)、`/webhook/tg`(secret 头)、`/push/jmap`(Body `verificationCode`);校验失败一律返回 `401`,且在鉴权通过前**不产生任何副作用**。`/healthz` 与 `/ready` 为公开探针,不含敏感信息。 +> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。 + +> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。 + +--- + +## 6. 运行模式与短请求模型 + +### 6.0 Redis-only 配置迁移与 bootstrap 威胁模型(`C-REDIS-ONLY-STATE`) + +生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机 +高熵 hex,仅应用运行时持有);其中 Redis ACL 认证是唯一 bootstrap 信任根;业务 +密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap +会话:请求必须以 Redis ACL 密码作为 Bearer 凭据,服务端只做常数时间比较,绝不在 +响应、日志或配置值中回显该密码。bootstrap 使用原子 `SET NX` 写入完整业务配置, +竞争请求只有一个成功;无密码 Redis 或认证失败时拒绝初始化。 + +该 ACL 身份必须具备 `config:business`、`config:outbound` 及 admin-session 键的读写权限, +因为它同时是应用 Redis 连接凭据与空库 bootstrap 根信任。bootstrap 完成后公网业务配置 +接口只接受 900 秒 admin session;持有 ACL 密码者仍等同根信任,必须按生产 Redis 凭据 +同等级保护,不得把该身份误配置为禁止配置键访问。 + +成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销 +或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存 +不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显 +任何密钥,配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。 + +`config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中) +写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应 +或浏览器存储。该保护只防 Redis 内容泄露,不替代 `rediss`、VPN 或受信网络对 `redis://` +窃听/篡改的防护;公网 Redis 必须使用 TLS/VPN/隧道。 + +bootstrap 或管理员 PUT 修改 `config:business` 后,服务会先校验并构建全部 JMAP/Telegram/LLM +客户端,再原子替换 worker;构建失败保留旧配置与任务。后续请求即时使用新快照,多实例在 +请求边界按 revision 有界检查并尝试刷新;失败时保留旧实例,避免忙循环。 + +TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链路只能位于受控私网, +并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面 +内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。 + +> 仅两种运行模式;无长连接(`C-NO-LONG-CONN`)。 + +| RUN_MODE | 命令 | 说明 | +|---|---|---| +| `webhook`(默认) | `stalwart-bot webhook`(或单一入口按 env 分派) | 单进程:TG Webhook handler + JMAP Push 回调 handler + Redis Streams worker + `/reconcile` + health | +| `reconcile` | `stalwart-bot reconcile` | 一次性对账补差后 exit,由外部调度器/容器任务触发 | + +### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站) +``` +# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080 +GET / 业务配置与运行参数管理 SPA +GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式 +POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session +POST /api/admin/session/revoke Bearer admin session;成功返回 204 +GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 +PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204 +POST /webhook/tg TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token] + → 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径) +POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body verificationCode] + → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步) +POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN] + → 对账补差 FLOW-RECONCILE +GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息) +GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER)——**不执行** Redis PING / JMAP session / TG getMe;真实 readiness 属后续门禁 GATE-READY-DEPS(公开探针,无鉴权、无敏感信息) +``` +> **三入口鉴权(fail-closed,`SAF-AUTH-*`)**:三条写路径必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。 +> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**;**`/ready` 现阶段不检查任何依赖**,不得据此判断 Redis/JMAP/TG 可用性(见 §7 与 design.md §10.0 `GATE-READY-DEPS`)。 +> **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。 + +### 6.2 Redis Streams / worker(MOD-STREAMS) +- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 +- worker(同容器后台 task 或独立 worker 容器,2 选 1 均由 compose/编排决定): + `XREADGROUP → Email/changes → 推送 TG → 推进 sinceState → XACK` +- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) + +### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE) +- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。 + 由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`),触发一次对账。 +- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。 +- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。 +- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 +- **Redis 丢失恢复**:sinceState 存 Redis(`MOD-SINCESTATE`,AOF 持久化 `C-REDIS-MANAGED-AOF`);即便 Redis 全丢,对账扫描 JMAP(`Email/query` 最近 N 封 + changes)也能重建游标并补发——**事实源在 JMAP,Redis 只是加速层**。 + +### 6.4 可靠性策略(Reliability) + +> 目标:**至少 99.9% 通知可用性**(`NFR-NOTIFY-SLA`),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。 + +| 机制 | ID | 做法 | +|---|---|---| +| **Streams ACK / 重试** | `MOD-STREAMS` | worker 用消费组 `XREADGROUP`;处理成功才 `XACK`;未 ACK 消息在被认领后重投(at-least-once)。处理失败时**不 ACK**,自然重试;设最大投递次数上限,超限转死信(`XADD` 到 `dlq`)并告警,避免毒丸阻塞 | +| **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` | +| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 | +| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟拉 `Email/changes` 补差,覆盖 Push 丢失/Redis 抖动/冷启动窗口;同时用于 Redis 全丢后的游标重建 | +| **指标 / 告警** | — | 导出指标:Push 到达数、去重命中率、Streams 积压深度(pending)、DLQ 条数、对账补发条数、JMAP 延迟、TG 推送失败率。对"积压持续增长 / DLQ 非空 / 对账补发异常升高"告警。**注**:`/ready` 现为占位 200(`ARCH-READY-PLACEHOLDER`),**尚不反映依赖健康**;待 `GATE-READY-DEPS` 落地后才可用于依赖告警 | + +### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA) + +- **定义**:在外部依赖(Stalwart / Telegram / Redis)可用的前提下,通知链路的可用性目标 ≥ **99.9%**(约每月 ≤43 分钟不可用)。 +- **允许的延迟**:用户已确认**接受少量通知延迟**——正常情况下 Push 回调应为秒级;依赖抖动/需对账兜底时,延迟上界为**对账间隔(≤10 分钟)**。 +- **非目标边界**:99.9% 是**通知可用性**目标,不含 Stalwart/Telegram/Redis 自身故障时间;三者任一长时间不可用属外部依赖故障,不计入本 bot 的可用性预算(但 bot 应在恢复后经对账自动补齐)。 +- **降级行为**:Redis 不可用时,去重/会话退化为进程内短窗口、Push 回调返回非 2xx 交 Stalwart 重试;恢复后由对账补齐。**不因 Redis 抖动而漏发最终通知**(以对账为准)。 + +--- + +## 7. 健康检查(通用 HTTP 约定) + +- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义) +- `GET /ready` → **阶段0 为占位 `200`**(`ARCH-READY-PLACEHOLDER`),**不做** Redis PING / JMAP session / TG getMe。真实 readiness 检查(依赖不可用 → `503`)为**后续门禁 `GATE-READY-DEPS`**,随 `redis` 接入且 JMAP 只读 adapter 可用后实现。 +- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。 +- ⚠️ 在 `GATE-READY-DEPS` 落地前,**不得**把 `/ready` 描述/当作"依赖健康检查",编排探针亦不得依赖其探测依赖。 +- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`) +- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`) + +--- + +## 8. CI 发布与镜像验收 + +### 8.0 代码门禁(`GATE-P0`,进入阶段1前必须通过) +- `cargo fmt --check` +- `cargo clippy --all-targets -- -D warnings` +- `cargo test` + +以上三条**在 Debian `rust:1-slim-bookworm` 容器内执行**(`C-DEBIAN-SLIM`);详见 design.md §10.0。 + +(其余 CI:镜像构建 → 镜像扫描 → 非 root → health 端点到容器验证;compose 示例收敛为单端口 + 外部 Redis URL + 无 volume) + +### 8.1 docker-compose 示例(收敛) +```yaml +services: + bot: + image: stalwart-bot:latest + ports: + - "8080:8080" # 平台 ingress/反代映射 HTTPS → 8080(C-HTTPS-INBOUND) + environment: + PORT: "8080" + RUN_MODE: webhook + # BOT_TOKEN / JMAP_* / CHAT_ALLOWLIST / REDIS_URL + # RECONCILE_TOKEN / TG_WEBHOOK_SECRET / JMAP_PUSH_VERIFICATION(三入口鉴权,均为必填 SAF-AUTH-*) + healthcheck: { test: ["CMD", "stalwart-bot", "health", "--addr", "127.0.0.1:8080"], interval: 30s } + # REDIS_URL 指向外部、已认证的 Redis;不在此 compose 中运行 Redis。 +``` + +> 注意:本示例对接 Compose;真实部署可去掉 `ports` 直接挂到平台 ingress(HTTPS-only)。 + +### 8.2 镜像与部署验证检查项(红线自检) + +以下检查在每次发布前/CI 中执行,用于固化 §0 新增红线: + +| # | 检查项 | 关联 ID | 命令/方法 | +|---|---|---|---| +| 1 | **镜像内无数据库引擎**:不装/不含 SQLite、Postgres、MySQL 二进制或数据文件 | `C-NO-DB` | `docker run --rm sh -c 'command -v sqlite3 psql mysql 2>/dev/null; [ -z "$(find / -maxdepth 4 -name "*.db" -o -name "*.sqlite*" 2>/dev/null)" ]'` 应为空 | +| 2 | **容器内不出现本地可写挂载**:compose/k8s 不声明 `volumes:` 用于数据/日志目录 | `C-NO-LOCAL-WRITE` / `NG-LOCAL-VOLUME` | 人工/CI lint 检查 compose 文件、k8s manifest 不含本地 volume 声明 | +| 3 | **日志仅 stdout/stderr**:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 `stdout` | `C-LOG-STDOUT-ONLY` | 检查代码:`grep -rEn 'rolling\|FileAppender\|RollingFileAppender' src/` 应为空;`RUST_LOG` 目标不含 `file:` | +| 4 | **日志/Redis 无敏感数据**:日志字段白名单 + CI 扫描 | `SAF-LOG-PURITY` | 代码评审 + 静态扫描(如 `gitleaks` 扫描 `Redis` 写入调用,验证 `SETEX`/`SET` 参数无密钥/正文/AI 内容) | +| 5 | **重启恢复不依赖进程内状态**:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 | `C-NO-STATEFUL-RECOVERY` | 代码评审:`grep -rn 'static mut\|lazy_static' src/` 不得用于生产恢复路径 | +| 6 | **Redis 由外部提供**:容器不运行 `redis-server`;compose 不启动 Redis 服务 | `C-REDIS-MANAGED-AOF` | `docker run --rm sh -c 'command -v redis-server && exit 1 || exit 0'` 应为 0 | + +> 任一检查失败,禁止发布。 + +--- + +## 9. 已确认决策与少量待办 + +### 9.1 已确认决策(不再作为待确认问题) + +| ID | 决策 | +|---|---| +| `REQ-SINGLE-ACCOUNT` | **单账户实现**;多账户暂用**多个 bot 实例**(各自 token/配置),不做多账户单实例 | +| `C-AUTH-APP-BASIC` | Stalwart 认证 = **App Password + Basic**(不用主密码、不用 OAuth) | +| `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) | +| `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) | +| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443) | +| `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器 | +| `NFR-NOTIFY-SLA` | **允许少量通知延迟;通知可用性目标 ≥ 99.9%**(边界见 §6.5) | +| `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) | +| `SAF-LB-PASSTHRU` | 信任模型 = **透传(A)**:Worker 不改写鉴权信息,**后端必须继续 fail-closed 校验**(小平台无防火墙/ACL,"后端不对公网暴露"不可行) | +| `SAF-RECONCILE-LOCK` | `/reconcile` **不扇出**:用 **Redis 锁**保证同一时刻仅一个实例执行 | +| `MOD-HEALTH-AGG` | Worker 暴露**聚合健康视图**,供外部监控 | +| `NFR-HA-MULTI-INSTANCE` | 双活或主备**均可**;**Redis 单点故障不在本方案范围**(用户外部解决,短暂不可用可接受) | +| `C-NO-DB` | **生产不使用任何数据库**:Redis 是唯一状态存储;应用不自建/不连接第二个数据库 | +| `C-NO-LOCAL-WRITE` | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷 | +| `C-LOG-STDOUT-ONLY` | **日志只写 stdout/stderr**(平台负责采集落盘);禁用文件日志后端 | +| `SAF-LOG-PURITY` | **日志与 Redis 写入不得包含**:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 | +| `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 | + +### 9.2 仍需确认(部署相关,仅此) + +| # | 待确认 | 关联 ID | +|---|---|---| +| **Q-DEP-A** | 平台 URL / 域名与证书由谁配置(平台自动证书 or 自管反代),以及 4 条路径的可达性验证方式 | `C-HTTPS-URL` | +| **Q-DEP-B** | 外部调度器具体选型(系统 crontab / k8s CronJob / CI scheduled / 第三方 cron)—— 仅影响运维方式,不改变架构 | `NFR-RECONCILE-INTERVAL` | + +> 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。 + +--- + +## 10. 多实例高可用与 Worker 前置负载均衡(`ARCH-LB-WORKER`) + +> 目标:在**多个 serverless 平台各部署一份相同镜像**(各自不同的 HTTPS 入口),最前面用一个**免费 Cloudflare Worker** 作为**唯一对外入口 + 故障转移**;所有实例共享**同一个外部 Redis**。双活(round-robin)或主备(active-standby)**均可**。业务代码无需改动——之所以可行,是因为既有设计已满足前提(见下)。 + +### 10.1 拓扑(`ARCH-LB-WORKER`) + +``` +Telegram setWebhook ─┐ +Stalwart PushSub ────┼─▶ https://lb..workers.dev ← 唯一登记 URL(C-LB-SINGLE-REG-URL) +外部 Cron ───────────┘ │ Cloudflare Worker(免费) + ├─▶ https://a./… ─┐ + └─▶ https://b./… ─┼─▶ 同一个外部 Redis(C-REDIS-ONLY-STATE) + (同一镜像、同一配置、同一组 secret)─┘ +``` + +- **只登记 Worker 的稳定 URL**(`C-LB-SINGLE-REG-URL`):Telegram `setWebhook`、Stalwart `PushSubscription.url`、外部 Cron **都指向 Worker**(`https://lb.example.com/webhook/tg`、`/push/jmap`、`/reconcile`)。后端易变的平台入口**不对外登记**。 +- 后端 = 与 §3 相同的镜像、相同 env、相同 Redis;**不引入长连接**(Worker 纯请求-响应,`C-NO-LONG-CONN`)。 + +### 10.2 为什么可直接支持多实例(既有前提) + +| 前提 | ID | 作用 | +|---|---|---| +| 状态仅外部 Redis | `C-REDIS-ONLY-STATE` | 任意实例可服务任意请求,**无需粘性会话** | +| 幂等去重 | `MOD-DEDUP` | 重复/重试投递**不会重复通知**,故"多实例 + 重试"安全 | +| 对账补差 | `FLOW-RECONCILE` | 兜住 Push 丢失 / cold-start 窗口 | + +> 因此 LB/HA 是**纯运维拓扑**变化;领域逻辑与渠道层无需改动。 + +### 10.3 信任模型 = **透传(A)**(`SAF-LB-PASSTHRU`) + +- Worker **原样转发** header / body / `verificationCode`,**不做鉴权改写**。 +- **后端必须继续 fail-closed 校验**三条入口(`SAF-AUTH-*`)——**不可省**。原因:小平台通常**不提供防火墙/ACL**,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景**不可行**)。 +- **多实例必须共享同一组 secret**(`C-LB-SHARED-SECRETS`):`TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` / `RECONCILE_TOKEN` 在所有实例上**完全一致**,否则请求落到不同实例会随机 `401`(对端发来的值只有一个)。 + +### 10.4 路由与故障转移 + +- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 +- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。 +- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。 +- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 +- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。 +- **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。 +- **重复兜底**:Worker 重试 + Telegram 重投造成的重复由 `MOD-DEDUP` 吸收;**幂等键 TTL 必须覆盖** Worker 重试与 Telegram 重投窗口。 +- **不代理 Redis/JMAP**(`C-NO-DB` / `C-REDIS-ONLY-STATE`):Worker 只做请求转发,后端各连自己的 Redis;Worker 不做数据库侧检查。 +- **无长连接**(`C-NO-LONG-CONN`):纯请求-响应,body 一次性 `arrayBuffer` 回灌;无 WS/SSE/长轮询。 + +### 10.5 各入口差异(重要) + +| 入口 | 是否可扇出/重复 | 处理 | +|---|---|---| +| `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 | +| `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 | +| `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 | +| Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 | + +### 10.6 聚合健康视图(`MOD-HEALTH-AGG`) + +- Worker 额外暴露**聚合端点**,报告各后端存活数与整体可用性,供外部监控/告警使用;比单实例探针更有意义。 + +### 10.7 边界与不在本方案范围 + +- **Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`):由用户在外部解决;其短暂不可用**可接受**(Redis 全丢可由 `FLOW-RECONCILE` 从 JMAP 重建 sinceState;dedup/会话丢失只导致**少量重复通知**,符合 `NFR-NOTIFY-SLA`)。 +- **Cloudflare Worker 免费额度**:预期足够(requests/day、subrequest、CPU/超时上限需上线后实测确认)。 +- 不引入长连接、不引入平台特定绑定(`C-NO-LONG-CONN`/`NG-SERVERLESS-BIND`)。 + +### 10.8 部署 Cloudflare Worker(`ARCH-LB-WORKER` 具体步骤) + +代码位于仓库子目录 [`cloudflare-worker/`](../cloudflare-worker/),**非本 Rust 二进制的一部分**;Worker 只做请求转发,不运行 Redis/JMAP,不引入数据库(`C-NO-DB`)。 + +**前置** +- 已按 §3 部署 ≥1 个后端镜像(多实例则部署 2+ 个),各后端 `PORT`/路由同 §6.1。 +- 后端各实例 env 必须共享同一组 secret(`C-LB-SHARED-SECRETS`)。 +- 已注册 Cloudflare 账号与 Workers 计划。 + +**配置**(所有值通过 `wrangler secret` 注入,**禁止**写入 `wrangler.toml` 或 `git` 明文) + +```bash +cd cloudflare-worker +npm install --no-audit --no-fund +npx wrangler secret put BACKEND_ORIGINS_JSON # JSON 数组,见下方示例 +# 可选(带默认值) +npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 默认 10000ms +npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移) +npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms +``` + +`BACKEND_ORIGINS_JSON` 示例(**仅允许 `https://` origin**,其它会被 Worker 启动即 503,`C-HTTPS-INBOUND`): + +```json +[ + {"url":"https://stalwart-bot-a.example.com","weight":100}, + {"url":"https://stalwart-bot-b.example.com","weight":100} +] +``` + +**部署与验证** + +```bash +cd cloudflare-worker +npx wrangler deploy --env production +# 验证 LB 健康聚合 +curl https://.workers.dev/healthz +``` + +**Telegram / Stalwart / Cron 登记的唯一 URL** 改为 `https://.workers.dev/webhook/tg` 等(`C-LB-SINGLE-REG-URL`);**各后端平台的 HTTPS 入口不再对外登记**(小平台入口可能被公网直连,故**后端鉴权不可省**,`SAF-LB-PASSTHRU`)。 + +**验证(发布前必做)** +1. `cd cloudflare-worker && npm test`(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。 +2. `npx wrangler deploy --env production` 后 `curl /healthz` 返回 200 且 `available ≥ 1`。 +3. `curl -X POST https:///webhook/tg -H "Content-Type: application/json" -d '{"test":1}'` 应得到后端返回(非 503)。 +4. `curl -X POST https:///unknown` 应得到 404;`curl -X GET https:///reconcile` 应得到 405。 +5. 日志仅出现 `method/path/origin/失败类别`,**绝不**含 header/body/secret(`SAF-LOG-PURITY`)。 + +**边界说明** +- Worker **未**实现 `/reconcile` 的 Redis 锁(`SAF-RECONCILE-LOCK`)——该锁由**后端**在 `/reconcile` 处理器内部执行;Worker 只做透传。详见 `SAF-RECONCILE-LOCK` 条目。 +- Worker **不代理** Redis、不检查数据库侧可用性(`C-NO-DB` / `C-REDIS-ONLY-STATE`)。 +- Worker **不落地** JMAP/Telegram 的 secret(`TELEGRAM_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION` 等属于**后端**容器,见 `deployment.md §5` 与 `.env.example`)。 + +--- + +## 附:稳定 ID 引用索引 + +> 跨文档稳定 ID 的**唯一权威索引表**在 `AGENTS.md` §7「跨文档引用索引表」。 +> 本文使用到的 ID(`C-*` / `NG-*` / `MOD-*` / `FLOW-*` 等)均在其中登记定义点位置与一句话说明;内容搬家时**只改 AGENTS.md 索引表**的"文件/锚点"列,所有引用本身零改动。 diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..1478a15 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,808 @@ +# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计 + +> 状态:**调研与方案设计**(未写代码) +> 作者:Cowork(team: stalwart-bot) +> 日期:2026-09-21 +> 目标读者:Codex CLI(lead)、后续 AI coding agent、最终用户评审 +> +> **相关文档**(职责分离,避免重复堆砌): +> - [AGENTS.md](../AGENTS.md) — 给后续 AI coding agent 的硬性安全边界、实现顺序、禁止事项、测试验收与文档引用关系 +> - [docs/deployment.md](deployment.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与 4 项待确认 +> +> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维、Docker/通用容器平台细节与给 coding agent 的通用操作指令已分别移至 `deployment.md` 与 `AGENTS.md`。 + +--- + +## 0. 文档导航 + +1. [任务与范围](#1-任务与范围) +2. [jmap-client 能力分析](#2-jmap-client-能力分析) +3. [认证与邮箱操作适配](#3-认证与邮箱操作适配) +4. [Telegram Rust 框架选型](#4-telegram-rust-框架选型) +5. [整体架构与数据流](#5-整体架构与数据流) +6. [模块划分](#6-模块划分) +7. [配置与安全](#7-配置与安全) +8. [错误处理与可观测性](#8-错误处理与可观测性) +9. [测试策略](#9-测试策略) +10. [分阶段实施计划](#10-分阶段实施计划) +11. [待确认问题(产品/架构类)](#11-待确认问题产品架构类) +12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退) + +> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并收敛;剩余待办仅 `Q-DEP-A`/`Q-DEP-B`,见 deployment.md §9。 + +--- + +## 1. 任务与范围 + +### 1.1 目标 +构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**: + +- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。 +- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。) +- 单用户或多账户部署(默认面向单账户自托管场景)。 + +### 1.2 范围(本阶段) +- ✅ 调研 + 方案设计(本文档) +- ❌ 不创建/修改项目代码、不初始化 cargo 工程 +- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项 + +### 1.3 调研依据 +- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研) +- 项目目录:`/home/okabe/Repo/stalwart-bot/`(greenfield,首次构建需装 Rust 工具链,见 §10.0 与 AGENTS.md) + +--- + +## 2. jmap-client 能力分析 + +来源:直接阅读 `stalwartlabs/jmap-client` 仓库 `src/lib.rs`、`src/client.rs`、`src/email/`、`src/email_submission/`、`src/event_source/`、`Cargo.toml`、`README.md`、`examples/`。 + +### 2.1 crate 概况 +| 项 | 值 | +|---|---| +| crate 名 | `jmap-client`(crates.io) | +| 协议覆盖 | JMAP Core (RFC 8620)、Mail (RFC 8621)、WebSocket (RFC 8887)、Sieve (draft-12) | +| 异步运行时 | tokio + reqwest | +| 许可证 | Apache-2.0 OR MIT | +| `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ | +| 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 | + +### 2.2 模块结构(`src/`) +``` +lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举 +client.rs Client:认证、connect、session、send_request、event_source、ws +core/ request / response / get / set / query / query_changes / changes / error / session +email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…) +email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询) +mailbox/ helpers(mailbox_create/query/get/set/destroy…) +thread/ thread/get +identity/ Identity(发件身份)get/set +blob/ blob 上传/复制(附件) +sieve/ SieveScript(服务端过滤脚本) +vacation_response/ +principal/ Principal(账户/共享) +push_subscription/ PushSubscription(HTTP push 回调注册) +event_source/ SSE 流:mod / parser / stream +client_ws/ WebSocket 客户端(feature = "websockets") +``` + +### 2.3 关键能力映射到本 Bot +| Bot 需求 | jmap-client API | 备注 | +|---|---|---| +| 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities | +| 列文件夹 | `mailbox_query` / `mailbox_get` | 带 `role` 可识别 INBOX/重要/草稿等 | +| 列邮件 | `email_query`(Filter + Comparator + anchor 分页) | Filter: `subject`/`from`/`to`/`in_mailbox`/`has_keyword`/`after`/`before`… | +| 读邮件正文 | `email_get` + `Property` 选择 | `BodyStructure`/`BodyValues`/`Preview`/`TextBody`/`HtmlBody` | +| 读附件 | `Blob/get`(blobId)或 `email_parse` | 大附件需分片/流式下载 | +| 发邮件(2 步) | ① `email_set`/`email_import` 建 draft ② `email_submission_set` 发送 | submission 关联 identityId | +| 删除/归档 | `email_set`(keywords `$seen`/`$flagged`)、`mailbox_destroy` | JMAP 无真"删除",靠 keyword/搬家 | +| 搜索 | `email_query` Filter + `SearchSnippet/get` 高亮 | | +| 实时通知(**唯一**通道) | `push_subscription` 注册 HTTPS 回调 → `StateChange` | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);+ 外部 Cron 对账兜底 | +| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 | + +### 2.4 认证机制(`client.rs`) +- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。 +- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。 +- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。 +- 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。 +- 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。 + +### 2.5 错误模型(`Error` 枚举) +``` +Transport(reqwest::Error) 网络/TLS/超时 +Parse(serde_json::Error) 序列化 +Internal(String) 客户端内部 +Problem(Box) JMAP problem-details +Server(String) +Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…) +Set(SetError) /set 级别错误(每条记录的 creation/update/destroy 失败) +WebSocket(...) ws 错误(feature 开启时) +``` +- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。 + +### 2.6 实时通道决策(已定,见 deployment.md) + +> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。 +> 据此**唯一通道 = JMAP Push HTTPS 回调**(`push_subscription` + verificationCode 握手)+ 外部 Cron 对账兜底。 +> EventSource/SSE 与 WebSocket 均标记为**非目标**(`NG-POLLING-SSE`),仅保留下表作 crate 能力调研参考。 + +| 通道 | 是否采用 | 备注 | +|---|---|---| +| **Push Subscription(HTTP 回调)** | ✅ **唯一采用** | 短请求模型,契合无状态 + 无长连接;`jmap-client` 提供 create/verify/update/destroy | +| EventSource/SSE | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突(`NG-POLLING-SSE`) | +| WebSocket(RFC 8887) | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突;且需服务端支持 | + +对账兜底:外部 HTTPS Cron 周期调 `/reconcile`(deployment.md `FLOW-RECONCILE`),用 `Email/changes` + Redis `sinceState` 补差,兼做 Redis 丢失后的游标重建。 + +--- + +## 3. 认证与邮箱操作适配 + +### 3.1 认证策略(已确认:App Password + Basic) +- **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。 +- **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。 +- **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。 +- 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 +- **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。 + +### 3.2 邮箱操作适配层(JMAP ↔ Bot 语义) +建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词: + +| Bot 动词 | 封装函数 | 内部 JMAP | +|---|---|---| +| `list_folders()` | → `Vec` | `mailbox_query` + `mailbox_get`(缓存 role→id) | +| `list_emails(folder, page)` | → `Vec` | `email_query`(anchor 分页) + `email_get`([Subject, From, Preview, ReceivedAt, HasAttachment]) | +| `read_email(id, want_body)` | → `EmailBody { text, html, attachments }` | `email_get`([BodyStructure, BodyValues, BlobIds]);**多 part 原文**(`REQ-JMAP-RAW-MULTIPART`):按 `text_body` 顺序筛选"有 `part_id` 且 `bodyValue`"的 part 后**拼接**为 `text`;若无可用部分 → 返回**明确错误**(不静默返回空串)。附件用 `Blob/get` | +| `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` 建 draft ② `email_submission_set`(onSend) | +| `set_flag(id, keyword)` | → () | `email_set` keywords | +| `search(query)` | → `Vec` | `email_query`(Filter::and[…]) + `SearchSnippet/get` | + +> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。 + +--- + +## 4. Telegram Rust 框架选型 + +### 4.1 候选评估 +| 框架 | crate | 维护状态 | 特性 | 适配度 | 结论 | +|---|---|---|---|---|---| +| **teloxide** | `teloxide` | 活跃,最新 0.17,下载量大 | dptree 分发、对话 FSM、`webhooks`+`webhooks-axum`、Redis 会话存储、`throttle`、`macros`、`tracing`、`rustls` | ★★★★★ | **首选** | +| grammers | `grammers` / `grammerslib` | 维护一般 | MTProto(非 Bot API),无需 Telegram Bot Token | ★★ | 仅在不能用 Bot API 时 | +| telegram-bot (旧) | `telegram-bot` | 基本停更 | reqwest + futures | ★ | 不推荐 | + +### 4.2 选定 teloxide 的理由 +1. 与 jmap-client 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。 +2. 内建 Dispatcher + UpdateKind 枚举匹配命令,与 Bot 的命令路由天然契合。 +3. 支持 `webhooks-axum`(生产 Webhook 形态;**不使用长轮询** `NG-LONG-POLLING`)与 Redis 会话存储(记住用户上下文:当前选中的文件夹/分页游标;不用 SQLite)。 +4. `throttle` feature 天然契合 Telegram 的 30 msg/s 速率限制。 +5. `tracing` feature 与本项目观测性统一。 +6. `macros` feature 可用 `#[teloxide::command]` 自动解析命令参数,减少样板。 + +### 4.3 teloxide 关键 feature 选择(**计划,阶段2 引入**) + +> ⚠️ 阶段0 **未引入 teloxide**(`ARCH-DEPS-STAGE0`);下方为阶段2 引入时的目标 feature 集,不得据此认为 `Cargo.toml` 已含 teloxide。 + +``` +# 阶段2 计划(尚未加入 Cargo.toml) +teloxide = { version = "0.17", features = [ + "macros", "rustls", "rustls-native-roots", # TLS 与 jmap-client 一致 + "redis-session", # 无状态会话:走外部 Redis(deployment.md C-REDIS-ONLY-STATE) + "throttle", # 遵守 TG 速率限制 + "tracing", +] } +# 部署形态 = Webhook(非长轮询,deployment.md NG-LONG-POLLING);按需启用 "webhooks-axum" +``` + +### 4.4 备选/降级方案 +- 若 teloxide 升级/破坏性改动 → 可退到直接基于 `teloxide-core`(更薄、API 稳定)。 +- 若需更高吞吐(多账户)→ 用 `webhooks-axum` + 共享 `axum::Router`(与阶段0 的单端口 axum 入口叠加)。 + +--- + +## 5. 整体架构与数据流 + +### 5.1 高层拓扑 +``` + ┌───────────────────────────────────────────────────┐ + │ Telegram │ + └───────────────────────┬───────────────────────────┘ + Webhook(HTTPS) │ + ┌───────────────────────▼───────────────────────────┐ + │ HTTP 入口 (axum/teloxide-webhooks,单端口 PORT) │ + │ POST /webhook/tg · POST /push/jmap · │ + │ POST /reconcile · GET /healthz /ready │ + │ GET / + admin session + config APIs (管理 SPA) │ + └───────────────────────┬───────────────────────────┘ + 命令 │ 通知 ↓(Redis Streams worker) + ┌───────────────────────▼───────────────────────────┐ + │ bot 层 (handlers) │ + │ /list /read /send /search /folders /flag ... │ + └───────────────────────┬───────────────────────────┘ + 领域 │ + ┌───────────────────────▼───────────────────────────┐ + │ jmap 适配层 (JmapService) │ + │ list_folders/list_emails/read_email/send_email… │ + │ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │ + └───────────────────────┬───────────────────────────┘ + JMAP │ HTTPS(短请求) + ┌───────────────────────▼───────────────────────────┐ + │ Stalwart JMAP Server │ + │ session_url / email/* / mailbox/* / submission/* │ + └───────────────────────▲───────────────────────────┘ + Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE) + ┌───────────────────────┴───────────────────────────┐ + │ push handler → Redis Streams(MOD-STREAMS) │ + │ worker: Email/changes → 通知 → 发往 TG → 推进游标│ + └───────────────────────────────────────────────────┘ +``` +> 部署形态 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`): +> Telegram 仅 Webhook;JMAP 实时仅 Push 回调;慢任务异步入 Redis Streams;对账由**外部 HTTPS Cron** 触发。 +> EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。 +> 渠道解耦(见 §5.2):领域层(`domain/`)与渠道层(`channel/`)以领域 Command / Notification 交互,不出现 Telegram/teloxide 类型;钉钉/飞书仅保留扩展位,不提前实现。 + +### 5.2 渠道抽象与多通道扩展策略 +- **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。 +- **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。 +- **抽象(不过度设计)**: + + | 抽象 | 职责 | 首个实现 | + |---|---|---| + | `Channel` | 渠道生命周期:接收输入、分发命令、配置端点 | `TelegramChannel`(teloxide Dispatcher) | + | `Notifier` | 主动推送:把领域 `Notification` 发送到用户 | `TelegramNotifier`(teloxide requester) | + | `MessageAdapter` | 领域数据 ↔ 渠道消息渲染(文本/按钮/转义) | `TelegramMessageAdapter`(`render.rs`) | + + - 领域与渠道之间用**领域 Command / 领域 Notification** 数据结构传递,渠道只在边缘做适配(解析→领域 Command;领域 Notification→渲染)。 +- **约束**: + - `jmap` / `llm` / 意图状态机 / `notify::core` 的公开接口只接受/返回领域类型; + - `teloxide` 类型只允许出现在 `channel/telegram/` 内; + - 新增渠道 = 新 adapter + 装配,不改领域层。 +- **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。 + +### 5.3 运行模型(短请求,无长连接) +- 单一二进制 `stalwart-bot`,`#[tokio::main]`。 +- 启动时: + 1. 加载配置(`config::Config`)。 + 2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。 + 3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开探针(`SAF-PROBE-PUBLIC`;**`/ready` 现阶段为占位 200**,`ARCH-READY-PLACEHOLDER`)。 + 4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。 + 5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。 +- 通知发送与命令处理共享 `Arc`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。 +- **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 deployment.md §10。**领域/渠道逻辑无需改动。** +- **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 deployment.md §0 / §8.2)**: + - **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。 + - **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。 + - **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。 + - **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;**禁止**密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。 + - **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。 +- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。 +- **阶段0 现状**(`ARCH-STAGE0`):完成 ①加载配置(env)、③HTTP 入口路由,并**已实现三入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed)**;业务体仍为无副作用占位。②④(JmapService / Redis Streams worker)为阶段1/4 目标,尚未实现。`GATE-P0` 未全项通过前**不应暴露公网**。 + +### 5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL) +``` +Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state}) + → 校验 verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK + → worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id + → email_get([From, Subject, Preview, ReceivedAt, HasAttachment]) + → notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文) + → channel::Notifier.send(chat, notification)(Telegram adapter 渲染并发送) + → 更新 sinceState(写外部 Redis)→ XACK + → 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发 +``` +> 关键:`Email/changes` 在 `created` 列出新建邮件 id,避免全量 `email_query`;`sinceState` 存外部 Redis(`C-REDIS-ONLY-STATE`),Redis 丢失时由对账从 JMAP 重建,**事实源在 JMAP**。 +> 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 AGENTS.md 安全边界 `SAF-NOTIFY-META`)。 +> 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。 + +### 5.5 数据流:命令 `/read `(长邮件分支) +``` +TG /read 3 → handler 取会话里的 folder+page 游标 + → JmapService.list_emails(folder, page=3) + → 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM + → 格式化: + 正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape) + 正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项: + "[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]" + → reply +("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3) +``` + +### 5.6 会话状态机(对话 FSM) +命令路由基于 teloxide `Dialogue`/对话 FSM(多步交互如 `/send`、`/summarize` 确认)。AI 与附件相关流程必须经过显式确认态: + +| 状态 | 含义 | 进入 | 离开 | +|---|---|---|---| +| `Idle` | 空闲 | 任意完成态 | 收到消息 | +| `AwaitClarify` | 目标/意图不明,等用户选择 | 意图或邮件目标不唯一 | 用户给出明确选择 | +| `AwaitConfirm` | 等确认(AI 分析 / 附件下载) | 用户发起分析/下载但未确认 | 确认 / 取消 | +| `Analyzing` | AI 请求 in-flight | 用户确认分析 | 成功 / 失败 | +| `AwaitFallback` | AI 失败,等确认回退 | 连续 3 次失败熔断 | 用户确认 / 取消 | + +不变量:`AwaitConfirm`/`Analyzing`/`AwaitFallback` 涉及 AI 或附件下载,**未到确认态不得调用 LLM 或拉取附件**。会话状态为短期状态,统一走**外部 Redis 短期 TTL**(`C-REDIS-ONLY-STATE`;不使用 SQLite,丢失可接受),运维 TTL 见 deployment.md(`C-REDIS-ONLY-STATE`)。 +> 渠道中立:FSM 状态与事件用领域类型(`domain::state`),不依赖 teloxide;teloxide `Dialogue` 仅作为 Telegram adapter 的会话存储实现。 + +--- + +## 6. 模块划分 + +目标 cargo 工程结构(**阶段0 已落地** `main.rs`/`config.rs`/`error.rs`/`domain.rs`/`channel.rs`/`notify.rs`;下方 `jmap/`、`llm/`、`channel/telegram/`、`notify/*` 为**后续阶段目标**,详见 §10): +``` +stalwart-bot/ +├── Cargo.toml +├── .gitignore # 排除 target/ 等(阶段0 P0 门禁 GATE-P0) +├── src/ +│ ├── main.rs # tokio main:装配置;按 RUN_MODE 分派 webhook / reconcile +│ ├── config.rs # 环境变量解析(ARCH-CONFIG-ENV:std::env,非 figment/TOML) +│ ├── error.rs # BotError 统一错误(见 §8) +│ ├── domain/ # 领域层(不依赖任何渠道类型,见 §5.2) +│ │ ├── command.rs # 领域 Command(List/Read/Analyze/Confirm…,渠道无关) +│ │ ├── notification.rs # 领域 Notification(NewEmail/ResultMsg/ConfirmRequest…) +│ │ ├── state.rs # 会话状态机(§5.6:Idle/AwaitClarify/AwaitConfirm/Analyzing/AwaitFallback) +│ │ ├── jmap/ # JmapService(领域封装,§3.2)—— 阶段1+ +│ │ │ ├── client.rs # 包装 jmap_client::Client(构造、重连、自定义 reqwest) +│ │ │ ├── model.rs # Folder / EmailSummary / EmailBody 领域模型 +│ │ │ └── state.rs # mailbox role→id 缓存、sinceState 游标(写外部 Redis,MOD-SINCESTATE;不用 SQLite/本地卷) +│ │ └── llm/ # LlmService + policy + fallback + audit(§12.1)—— 阶段3.5 +│ ├── channel/ # 渠道层(每渠道一个 submodule,见 §5.2) +│ │ ├── mod.rs # Channel / Notifier / MessageAdapter traits(thin)—— 阶段0 边界已落地 +│ │ └── telegram/ # 首个也是当前唯一渠道(adapter #1)—— 阶段2+ +│ │ ├── mod.rs # TelegramChannel / TelegramNotifier 装配(teloxide Dispatcher) +│ │ ├── commands.rs # 领域 Command ↔ teloxide 命令映射(/folders /list /read /send /search /flag) +│ │ ├── render.rs # TelegramMessageAdapter:领域 Notification → TG 消息/行内按钮(HTML escape!) +│ │ └── session.rs # teloxide 会话键(当前 folder/page/last_email_id) +│ │ # 预留扩展位:dingtalk/、feishu/(不实现,见 §5.2) +│ ├── notify/ +│ │ ├── core.rs # 新邮件处理:JMAP 增量 → 领域 Notification(渠道无关) +│ │ ├── push_handler.rs # POST /push/jmap:校验 verificationCode → 去重 → 入 Redis Streams → 2xx +│ │ ├── worker.rs # Redis Streams 消费:Email/changes → 通知 → 推进 sinceState → XACK +│ │ └── reconcile.rs # /reconcile:外部 Cron 触发对账补差(FLOW-RECONCILE,Redis 丢失恢复) +│ └── util/ +│ ├── retry.rs # 指数退避(Transport/RateLimit/ServerUnavailable) +│ └── shutdown.rs # CancellationToken 协调 +└── tests/ + ├── jmap_mock.rs # 对 JmapService 的契约测试(mock Push 回调 + changes 响应) + ├── channel_mock.rs # MockChannel/Notifier 契约:领域流程不依赖 Telegram + └── telegram_dispatch.rs # teloxide 测试模式(mock Bot,仅 channel::telegram) +``` + +### 6.1 模块职责矩阵 + +> 阶段0 已落地:`main` / `config` / `error` / `domain` / `channel` / `notify`(axum 入口,见 §10.0)。阶段1 起 `jmap-client 0.4.2` **已引入**(`ARCH-DEPS-STAGE1`)。下表含**后续阶段**将引入的模块与依赖(teloxide / redis / reqwest 等**尚未加入 Cargo.toml**,标注「计划」)。 + +| 模块 | 依赖 | 输出 | 可测性 | 现状 | +|---|---|---|---|---| +| `config` | 标准库 `env`(`ARCH-CONFIG-ENV`) | `Config` 结构 | 纯函数,易测 | 阶段0 已实现(手工 `from_env`,无 figment) | +| `domain::jmap`(含 `client`) | **jmap-client 0.4.2(已引入)** | 领域动词(§3.2);`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 阶段1(G1/D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**) | +| `domain::llm` | reqwest/axum(计划) | 摘要/意图/草稿 | mock OpenAI 端点 | 阶段3.5 | +| `domain::state` | — | 意图路由 + 会话 FSM(零渠道依赖) | 表驱动纯单测 | 阶段3+ | +| `channel`(traits) | — | Channel/Notifier/MessageAdapter | 契约测试(MockChannel) | 阶段0 边界已实现 | +| `channel::telegram` | teloxide(计划) | TG adapter:事件→领域 Command;领域 Notification→渲染 | teloxide mock Bot / MockChannel | 阶段2+ | +| `notify`(入口 + Push/worker/reconcile) | axum(已实现占位);Redis Streams(计划) | 路由占位 → 增量 → 领域 Notification | 驱动假 StateChange 流 | 阶段0 路由占位;阶段4 实现 | +| `util::retry` | — | 重试策略 | 纯逻辑 | 阶段1+ | + +--- + +## 7. 配置与安全 + +### 7.1 历史配置读取(已迁移至 Redis) + +> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis +> 的首次配置必须通过 Redis ACL 密码认证的一次性 bootstrap/admin 会话完成;不得 +> 提供未鉴权写入口。ACL 密码仅用于常数时间 bootstrap 校验,不回显、不记录、不写入 +> 业务配置。bootstrap 成功后管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务 +> token 从 Redis 在启动时装载,密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子 +> 替换客户端,后续请求即时使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单, +> 阶段0列表仅作为历史兼容说明。 + +> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。 + +| 变量 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) | +| `RUN_MODE` | 否 | `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除) | +| `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) | +| `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) | +| `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) | +| `JMAP_SESSION_URL` | 是 | — | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):可填**服务基地址** `https://host[:port]` 或**完整** `…/.well-known/jmap`;代码归一化为 origin/base 后再交 `jmap-client`(其自动追加 `.well-known/jmap`),**不得重复路径**。仅 HTTPS、**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) | +| `JMAP_USERNAME` | 是 | — | Stalwart 账户(邮箱) | +| `JMAP_PASSWORD` | 是 | — | **App Password**(`C-AUTH-APP-BASIC`;`SecretString`) | +| `JMAP_PUSH_VERIFICATION` | 是 | — | `/push/jmap` 鉴权:请求体 `verificationCode`(`SAF-AUTH-JMAP-PUSH`;`SecretString`) | +| `REDIS_URL` | 是 | — | 外部 Redis(用户托管 + AOF,`C-REDIS-MANAGED-AOF`;`SecretString`) | +| `RECONCILE_TOKEN` | 是 | — | `/reconcile` 鉴权:`Authorization: Bearer`(`SAF-AUTH-RECONCILE`;`SecretString`)。`/reconcile` 路由始终挂载,故**必填** | +| `ACCOUNT_ID` | 否 | 空 | 单账户(`REQ-SINGLE-ACCOUNT`);留空则取 session 主账户 | +| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) | +| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) | +| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible | +| `LLM_MAX_RETRIES` | 否 | `3` | 熔断阈值(`REQ-AI-FUSE`) | +| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) | + +> 阶段0 `config.rs` 已建立上述字段骨架(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`),未使用的占位以局部 `#[expect(dead_code, reason="稳定ID+阶段0占位")]` 标注。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。 +> **入口鉴权三密钥(`TG_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION`/`RECONCILE_TOKEN`)均为必填**(fail-closed,`SAF-AUTH-*`):缺失即 `Config::from_env()` 报错启动失败,无"未配置则放行"降级。示例占位见仓库根目录 `.env.example`(仅占位符,禁放真实密钥)。 +> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。(状态:D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**。) + +### 7.2 密钥管理 +- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置走环境变量,`ARCH-CONFIG-ENV`)。 +- 环境变量由 `config` 层直接读取(`std::env::var`,`ARCH-CONFIG-ENV`);缺失必填项即启动失败(**无** `${VAR}` 插值/figment)。 +- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。 +- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。 +- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 + +### 7.3 访问控制 +- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**—— + - `/reconcile`:请求头 `Authorization: Bearer `; + - `/webhook/tg`:请求头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`; + - `/push/jmap`:请求体 `verificationCode == JMAP_PUSH_VERIFICATION`。 + 比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。三密钥必填,缺失即启动失败(§7.1)。 +- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready`(`ARCH-READY-PLACEHOLDER`)为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。 + - `/healthz` = liveness(进程存活),语义长期稳定。 + - `/ready` **当前阶段为占位**:固定返回 `200`,**不执行** Redis PING / JMAP session / Telegram getMe 等依赖探测(`ARCH-READY-PLACEHOLDER`)。真实依赖探测属后续门禁 `GATE-READY-DEPS`(`redis` 接入且 JMAP session 可用后实现,见 §10.0)。 +- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;**强制拒绝逻辑随阶段2 渠道接入落地**。 +- **命令最小化**:仅暴露必要命令;`/send` 需二次确认。 +- **速率**:teloxide `throttle` + `notify` 自有令牌桶(推送不能挤占用户命令回复)。 + +### 7.3.1 配置管理 API +- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 +- `POST /api/admin/session` 接受 `Authorization: Bearer `,返回 `{ "session": "", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 +- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 +- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: + ```json + { + "jmap_timeout_ms": 15000, + "telegram_timeout_ms": 10000, + "llm_timeout_ms": 30000, + "max_retries": 3 + } + ``` +- 三个 timeout 单位均为毫秒,范围 `100..=300000`;`max_retries` 范围 `0..=5`,表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值 `15000 / 10000 / 30000 / 3`;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 +- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;成功返回 `204 No Content`,不会返回配置或密钥。Wire 字段为 `bot_token: string`、`telegram_chat_id: i64`、`chat_allowlist: i64[]`、`telegram_webhook_secret: string`、`jmap_session_url: string`、`jmap_username: string`、`jmap_password: string`、`jmap_push_verification: string`、`account_id: string | null`、`llm_enabled: boolean`、`llm_allow_net: boolean`、`llm_api_key: string | null`、`llm_base_url: string | null`、`llm_model: string | null`、`reconcile_token: string`、`worker_token: string`。该 API 故意没有 GET;SPA 不回填业务配置,每次完整替换需重输必填密钥。 +- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 + +### 7.4 TLS(代码/依赖行为) +- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。 +- 证书校验**默认强制**,仅测试可关。 +- 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。 + +### 7.5 生产红线:无数据库 / 无本地写入 / 标准输出日志(deployment.md §0/§8.2) + +本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态: + +| 红线 ID | 含义 | 违反示例(禁止) | +|---|---|---| +| `C-NO-DB` | 生产不使用任何数据库;Redis 是唯一生产状态存储 | 加 SQLite 去重表、加 Postgres 会话 | +| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写 `./logs/app.log`、写 `./tmp/…`、写本地 `sinceState.json` | +| `C-LOG-STDOUT-ONLY` | 日志只写 stdout/stderr;平台负责采集 | 引入 `tracing-appender`、`rolling-file`、`FileAppender` | +| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 | +| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 | + +**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。 + +--- + +## 8. 错误处理与可观测性 + +### 8.1 统一错误枚举 +```rust +#[derive(thiserror::Error, Debug)] +pub enum BotError { + #[error("jmap: {0}")] + Jmap(#[from] jmap_client::Error), + #[error("telegram: {0}")] + Telegram(#[from] teloxide::errors::RequestError), + #[error("config: {0}")] + Config(String), + #[error("storage: {0}")] + Storage(#[from] redis::RedisError), + #[error("rate limited, retry later")] + RateLimited, + #[error("unauthorized chat {0}")] + Unauthorized(i64), + #[error("llm: {0}")] + Llm(LlmErr), // 见 §12.7 +} +``` + +### 8.2 错误 → 用户消息 映射 +| 底层错误 | Bot 行为 | +|---|---| +| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" | +| `Forbidden` / `Unauthorized chat` | 静默拒绝;日志告警 | +| `Method(InvalidArguments)` | 回复"参数有误 + 正确用法" | +| `Set(creation/update/destroy)` | 回复具体记录级失败原因 | +| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) | +| `Llm(*)` | 见 §12.7(熔断/回退/徽标) | + +### 8.3 可观测性 +- `tracing`(teloxide feature 已开启)+ `tracing-subscriber`(fmt + EnvFilter)。 +- 关键 span:`jmap.request`、`notify.push_received`、`notify.reconcile`、`telegram.command`、`llm.call`(记 prompt 哈希/response 长度/latency/status,**不记 prompt 明文**)。 +- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。 +- 优雅退出:信号处理 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(不涉及本地文件)。 +- **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。 + +--- + +## 9. 测试策略 + +### 9.1 层次 +| 层 | 工具 | 内容 | +|---|---|---| +| 单元 | `cargo test` | `config` 解析、`util::retry` 退避、`formatting` HTML escape | +| 契约 | mock | `jmap::JmapService` 用 mock Push 回调 + changes 响应 + 预置 JMAP JSON 验证领域动词;`llm` 用 mock OpenAI 端点 | +| 集成 | teloxide mock Bot + mock JMAP | 命令端到端:发 `/list` → 验证回复来自 mock 数据 | +| 真实(手动) | Stalwart 实例 | 标 `#[ignore]`,`cargo test -- --ignored` 按需跑 | + +### 9.2 mock 策略 +- `JmapService` 持有 trait `JmapBackend`(`list_emails/read_email/send_email/changes…`),生产实现包装 `jmap_client::Client`,测试用 `MockBackend`(基于 `Vec`)。 +- Push 回调 payload 用 `futures::stream::iter` 造假 `StateChange`,验证 worker 的 `Email/changes` 增量逻辑与去重(`mod_dedup` + `mod_streams`)。 +- teloxide 提供 `Requester` mock,验证 `send_message` 被以期望内容调用(仅 `channel::telegram` 测试使用)。 +- 渠道契约:领域流程用 `MockChannel`(实现 `Channel/Notifier` trait)驱动,验证领域输出与渠道渲染解耦、领域层无 teloxide 依赖。 +- `llm::client` 注入 mock OpenAI `/chat/completions` 端点。 + +### 9.3 关键不变量测试 +- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。 +- 通知去重:同一 `email_id` 不重复推送。 +- 正文转义:含 ` + + + + + +
+ + + stalwart bot业务配置 + + 安全配置通道 +
+ +
+
+

服务管理 · CONFIGURATION

+

管理你的服务连接

+

配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。

+
+ +
+ 使用 Redis 管理凭据创建短期管理会话。 +
+ +
+
+ +

需要授权

创建管理会话

+
+

输入部署时 REDIS_URL 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。

+
+ +
+ + +
+

不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。

+
+
+ + + + +
+ + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..187a745 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,182 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #202239; + background: #f7f7fb; + font-synthesis: none; + text-rendering: optimizeLegibility; + --ink: #202239; + --muted: #73758a; + --line: #ececf3; + --purple: #6656d8; + --purple-dark: #5544c6; +} + +* { box-sizing: border-box; } +body { min-width: 320px; min-height: 100vh; margin: 0; overflow-x: hidden; background: linear-gradient(148deg, #fafaff 0%, #f7f7fb 48%, #f5f6fa 100%); } +button, input, textarea { font: inherit; } +button { cursor: pointer; } +button:disabled, input:disabled, textarea:disabled { cursor: not-allowed; } +button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid #9f93fa; outline-offset: 3px; } +[hidden] { display: none !important; } + +.ambient { position: fixed; z-index: -1; pointer-events: none; border-radius: 50%; filter: blur(2px); } +.ambient-one { top: 7rem; right: -13rem; width: 31rem; height: 31rem; background: radial-gradient(circle, rgba(185, 177, 255, .2), rgba(185, 177, 255, 0) 68%); } +.ambient-two { top: 44rem; left: -17rem; width: 36rem; height: 36rem; background: radial-gradient(circle, rgba(125, 210, 196, .13), rgba(125, 210, 196, 0) 70%); } + +.topbar { width: min(1100px, calc(100% - 48px)); height: 86px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(224, 224, 238, .72); } +.brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; } +.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: linear-gradient(145deg, #7466e5, #5443c5); color: white; font-size: 21px; font-weight: 760; box-shadow: 0 5px 12px rgba(93, 75, 203, .2); } +.brand-copy { display: grid; gap: 1px; } +.brand-copy strong { font-size: 14px; letter-spacing: -.2px; } +.brand-copy > span { color: #85869a; font-size: 11px; letter-spacing: .05em; } +.topbar-note { display: inline-flex; align-items: center; gap: 8px; color: #77798e; font-size: 12px; } +.secure-dot, .persist-dot { width: 7px; height: 7px; border-radius: 50%; background: #48ae86; box-shadow: 0 0 0 3px rgba(72, 174, 134, .12); } + +.layout { width: min(960px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 56px; } +.intro { margin: 0 0 26px; } +.eyebrow { margin: 0 0 9px; color: #817cbe; font-size: 10px; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; } +.eyebrow span { padding: 0 4px; color: #c8c5e6; } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16; letter-spacing: -.045em; font-weight: 720; } +.intro-copy { max-width: 700px; margin-bottom: 0; color: var(--muted); font-size: 14px; line-height: 1.75; } + +.notice { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 17px; padding: 14px 16px; border: 1px solid; border-radius: 11px; font-size: 13px; line-height: 1.6; } +.notice::before { width: 18px; height: 18px; flex: 0 0 18px; display: grid; place-items: center; margin-top: 1px; border-radius: 50%; font-weight: 750; } +.notice-info { color: #5d5e7b; background: #f0efff; border-color: #e3e0ff; } +.notice-info::before { content: "i"; color: white; background: #8a7de4; font-family: Georgia, serif; font-style: italic; } +.notice-success { color: #2a745b; background: #eff9f4; border-color: #d8f0e4; } +.notice-success::before { content: "✓"; color: white; background: #52ad85; font-size: 11px; } +.notice-error { color: #965345; background: #fff5f1; border-color: #f6dfd7; } +.notice-error::before { content: "!"; color: white; background: #dc806e; font-size: 12px; } + +.panel { border: 1px solid rgba(228, 228, 239, .92); border-radius: 16px; background: rgba(255, 255, 255, .94); box-shadow: 0 12px 38px rgba(41, 42, 79, .055), 0 2px 5px rgba(41, 42, 79, .025); } +.auth-panel { padding: 27px 29px 25px; } +.panel-heading { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; } +.icon-tile { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 13px; } +.icon-tile svg, .privacy-icon svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.icon-violet { color: #6455ce; background: #f0efff; } +.panel-heading .eyebrow { margin-bottom: 3px; } +h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; } +h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; } +.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; } +code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; } +.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; } +.token-row { display: flex; gap: 10px; } +.token-row input, .field input, .field textarea { width: 100%; min-width: 0; min-height: 42px; padding: 10px 12px; border: 1px solid #dedeea; border-radius: 8px; background: #fff; color: var(--ink); font-size: 13px; transition: border-color .15s, box-shadow .15s; } +.token-row input { height: 44px; flex: 1; } +.field textarea { min-height: 82px; resize: vertical; line-height: 1.55; } +.token-row input::placeholder, .field input::placeholder, .field textarea::placeholder { color: #a5a5b4; } +.token-row input:focus, .field input:focus, .field textarea:focus { outline: none; border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .12); } +.field-hint, .field small, .setting-card small { display: block; margin: 7px 0 0; color: #9293a4; font-size: 11px; line-height: 1.5; } + +.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 15px; border: 1px solid transparent; border-radius: 8px; font-size: 12px; font-weight: 680; transition: color .16s, background .16s, border-color .16s, box-shadow .16s, transform .16s; white-space: nowrap; } +.button:active:not(:disabled) { transform: translateY(1px); } +.button-primary { color: white; background: var(--purple); box-shadow: 0 4px 9px rgba(98, 81, 211, .19); } +.button-primary:hover:not(:disabled) { background: var(--purple-dark); box-shadow: 0 6px 13px rgba(98, 81, 211, .23); } +.button-primary:disabled { color: #fff; background: #b7b2d9; box-shadow: none; } +.button-quiet { color: #77788e; background: white; border-color: #e4e4ee; } +.button-quiet:hover:not(:disabled) { color: #4f48a1; background: #f8f7ff; border-color: #d8d4f2; } +.button-quiet:disabled { color: #b6b6c2; background: #fbfbfd; } +.button-spinner { display: none; width: 13px; height: 13px; border: 2px solid rgba(255, 255, 255, .4); border-top-color: white; border-radius: 50%; animation: spin .8s linear infinite; } +.is-busy .button-spinner { display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } + +.config-panel { display: grid; gap: 16px; } +.config-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 20px; } +.session-state { display: inline-flex; align-items: center; gap: 10px; } +.status-pulse { width: 8px; height: 8px; border-radius: 50%; background: #48ad83; box-shadow: 0 0 0 4px rgba(72, 173, 131, .11); } +.session-state div { display: grid; gap: 2px; } +.session-state strong { color: #4b4d61; font-size: 11px; } +.session-state div span { color: #9999a9; font-size: 10px; } +.toolbar-actions { display: flex; gap: 7px; } +.toolbar-actions .button { min-height: 34px; padding: 0 10px; font-size: 11px; } +.config-section { overflow: hidden; } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 23px 25px 17px; } +.section-heading .eyebrow { margin-bottom: 4px; } +.section-heading h2 { font-size: 20px; } +.persist-label { display: inline-flex; align-items: center; gap: 8px; padding-bottom: 3px; color: #7a7b8c; font-size: 11px; white-space: nowrap; } +.persist-dot { width: 6px; height: 6px; box-shadow: none; } +.callout { margin: 0 25px 5px; padding: 13px 15px; border: 1px solid; border-radius: 9px; font-size: 12px; line-height: 1.65; } +.callout-warning { color: #796132; background: #fffbef; border-color: #f2e9c8; } +.callout strong { color: #654d1e; } +.section-copy { margin: -3px 25px 18px; color: var(--muted); font-size: 12px; line-height: 1.65; } + +fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } +fieldset:disabled .field input, fieldset:disabled .field textarea { background: #fafafd; color: #858697; } +.form-section { padding: 22px 25px 24px; border-top: 1px solid #efeff5; } +.form-section-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 17px; } +.form-section-heading > div { min-width: 0; } +.form-section-heading p { margin-bottom: 0; color: #858697; font-size: 11px; line-height: 1.55; } +.section-number { width: 31px; height: 31px; flex: 0 0 31px; display: grid; place-items: center; border-radius: 9px; background: #f2f0ff; color: #6b5fd1; font-size: 10px; font-weight: 750; letter-spacing: .06em; } +.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 13px; } +.field { min-width: 0; } +.field-wide { grid-column: 1 / -1; } +.required-mark, .optional-mark { margin-left: 4px; color: #a6685f; font-size: 10px; font-weight: 600; } +.optional-mark { color: #9293a4; } +.toggle-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; margin-bottom: 17px; } +.toggle-card { min-width: 0; display: flex; align-items: flex-start; gap: 10px; padding: 13px; border: 1px solid #ececf3; border-radius: 9px; background: #fcfcfe; cursor: pointer; } +.toggle-card input { width: 16px; height: 16px; flex: 0 0 16px; margin: 2px 0 0; accent-color: var(--purple); } +.toggle-card span { display: grid; gap: 3px; } +.toggle-card strong { color: #47485f; font-size: 12px; } +.toggle-card small { color: #8a8b9c; font-size: 10px; line-height: 1.5; } +.form-footer { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 25px; border-top: 1px solid #efeff5; background: #fcfcfe; } +.save-state { margin: 0; color: #8a8b9c; font-size: 11px; line-height: 1.5; } +.save-state.is-dirty { color: #9c712e; } +.form-actions { display: flex; gap: 8px; } +.runtime-section .settings-grid { padding: 0 25px 22px; } +.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.setting-card { min-width: 0; padding: 16px; border: 1px solid #eeeef4; border-radius: 11px; background: #fff; } +.setting-card > label { margin-bottom: 5px; color: #35364e; font-size: 13px; } +.setting-description { min-height: 32px; margin-bottom: 10px; color: #858697; font-size: 11px; line-height: 1.5; } +.number-control { height: 40px; display: flex; align-items: center; border: 1px solid #e2e2eb; border-radius: 7px; background: #fff; transition: border-color .15s, box-shadow .15s; } +.number-control:focus-within { border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .1); } +.number-control input { width: 100%; min-width: 0; height: 100%; padding: 0 11px; border: 0; border-radius: inherit; outline: none; background: transparent; color: #34354c; font-size: 14px; font-weight: 650; font-variant-numeric: tabular-nums; } +.number-control input:focus { box-shadow: none; } +.number-control > span { padding-right: 11px; color: #999aa9; font-size: 10px; white-space: nowrap; } +.privacy-note { display: flex; gap: 11px; align-items: flex-start; margin-top: 18px; padding: 0 4px; color: #858697; } +.privacy-icon { width: 20px; flex: 0 0 20px; color: #8b84c8; } +.privacy-icon svg { width: 18px; height: 18px; } +.privacy-note p { margin: 0; font-size: 11px; line-height: 1.65; } +.privacy-note strong { color: #64657a; font-weight: 680; } +.footer { width: min(1100px, calc(100% - 48px)); display: flex; justify-content: space-between; margin: 0 auto; padding: 18px 0 23px; border-top: 1px solid rgba(224, 224, 238, .72); color: #a5a5b2; font-size: 9px; letter-spacing: .08em; } +.footer span:last-child { letter-spacing: 0; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); border: 0; } + +@media (max-width: 720px) { + .topbar { width: calc(100% - 34px); height: 72px; } + .topbar-note { font-size: 10px; } + .layout { width: calc(100% - 32px); padding-top: 40px; } + .intro { margin-bottom: 21px; } + .intro-copy { font-size: 13px; } + .auth-panel { padding: 22px 19px 20px; } + .token-row { flex-direction: column; } + .token-row .button { width: 100%; } + .config-toolbar { align-items: flex-start; padding: 13px 15px; } + .session-state div span { max-width: 155px; line-height: 1.4; } + .toolbar-actions { flex-direction: column; } + .toolbar-actions .button { min-height: 30px; } + .section-heading { align-items: flex-start; padding: 20px 17px 15px; } + .section-heading h2 { font-size: 18px; } + .persist-label { gap: 6px; font-size: 10px; } + .callout { margin: 0 17px 5px; } + .form-section { padding: 19px 17px 21px; } + .field-grid, .toggle-grid, .settings-grid { grid-template-columns: minmax(0, 1fr); } + .field-wide { grid-column: auto; } + .form-footer { align-items: flex-start; flex-direction: column; padding: 14px 17px; } + .form-footer > .button, .form-actions { width: 100%; } + .form-actions .button { flex: 1; } + .runtime-section .settings-grid { padding: 0 14px 17px; } + .section-copy { margin: -3px 17px 16px; } + .footer { width: calc(100% - 34px); } +} + +@media (max-width: 420px) { + .config-toolbar { flex-direction: column; } + .toolbar-actions { width: 100%; flex-direction: row; } + .toolbar-actions .button { flex: 1; white-space: normal; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } +}