commit 19dfc5136065bf41c82a0dfc82c1899d44e0b511 Author: Mateusz Gruszczyński Date: Thu Nov 5 12:52:32 2026 +0100 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd3be34 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Optional. Copy to .env - dev.sh sources it as a shell file; use trusted content only. +ACTIVE_PUSH_DATA_DIR=./data +# Device facing listener (nginx / RustProxy proxies /Microsoft-Server-ActiveSync and autodiscover here) +ACTIVE_PUSH_EAS_LISTEN=127.0.0.1:8081 +# Administration panel (keep on loopback; use SSH tunnel or a reverse proxy with auth) +ACTIVE_PUSH_ADMIN_LISTEN=127.0.0.1:9091 +# ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true +# ACTIVE_PUSH_ADMIN_TLS_CERT=/etc/active-push/tls/fullchain.pem +# ACTIVE_PUSH_ADMIN_TLS_KEY=/etc/active-push/tls/privkey.pem +# ACTIVE_PUSH_LOG_JSON=true +RUST_LOG=active_push=info,warn +# Lines shown in the panel log viewer (tracing filter) +# ACTIVE_PUSH_PANEL_LOG=active_push=info,warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ae292e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +data/ +dist/ +.env +*.db +*.db-wal +*.db-shm diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..51a2f48 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## 0.1.1 - 2026-11-05 + +- Fix: devices migrated from Z-Push looping on unknown collection ids (`collection ... not found`) - such ids are + now removed from the device with a FolderHierarchy Delete on the next FolderSync. +- Counting: devices are physical phones (DeviceId); mailboxes are counted separately; partnerships shown too. + Devices page groups all mailboxes of one phone in one row; Users page renamed to Mailboxes with + "same device as" column. +- Panel: English by default, spacing between cards, styled select dropdowns, non-overlapping live stream + columns, chart axis labels no longer clipped, database size on Overview. +- Port 8081: plain text on `/`, plain 404 elsewhere, `/healthz` also checks the database. +- Retention: hourly WAL checkpoint/truncate, incremental auto-vacuum, pruning of id maps and very old states, + inactive devices removed after 180 days by default, bounded in-memory caches, unknown command names + normalised. +- `probe`: `--device-id`, `--device-type`, `--model`. +- Documentation in English with screenshots (`docs/PANEL.md`), new `LOGS.md` and `TROUBLESHOOTING.md`. + +## 0.1.0 - 2026-11-05 + +First release: complete rewrite of Z-Push (combined backend: IMAP/SMTP + CalDAV + CardDAV) in Rust. + +- ActiveSync 2.5-14.1, every command used by iOS, Android and Outlook. +- Push (Ping, hanging Sync) with cheap change detection. +- Administration panel: overview, live traffic, devices (remote wipe, blocks, approval), mailboxes, backends with + connection tests, ActiveSync settings, policies, security, logs and audit. +- Login lockouts, access lists, audit, Prometheus. +- .deb package, sandboxed systemd unit, Z-Push config importer, `admin/config/device/probe` CLI. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..23ba78e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3132 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "active-push" +version = "0.1.1" +dependencies = [ + "anyhow", + "arc-swap", + "argon2", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "chrono-tz", + "clap", + "futures-util", + "hex", + "hmac", + "http", + "http-body-util", + "hyper", + "hyper-util", + "lettre", + "mail-parser", + "md-5", + "percent-encoding", + "quick-xml", + "rand 0.8.8", + "regex", + "reqwest", + "rpassword", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "tempfile", + "thiserror", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "webpki-roots 0.26.11", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[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.9", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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 = "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", +] + +[[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", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "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 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "rustls-native-certs", + "socket2", + "tokio", + "tokio-rustls", + "url", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mail-parser" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86" +dependencies = [ + "hashify", +] + +[[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 = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[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-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[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-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[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", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +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 = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[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.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[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 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[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-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_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" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +dependencies = [ + "serde", +] + +[[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 = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "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", + "tokio-util", + "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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + +[[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.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +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-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +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.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..24ffd81 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "active-push" +version = "0.1.1" +edition = "2021" +rust-version = "1.85" +description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel" +license = "AGPL-3.0-only" +publish = false + +[[bin]] +name = "active-push" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +thiserror = "2" +argon2 = "0.5" +arc-swap = "1.7" +axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "query", "tokio", "form"] } +base64 = "0.22" +bytes = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] } +chrono-tz = "0.10" +clap = { version = "4.5", features = ["derive", "env"] } +futures-util = "0.3" +hex = "0.4" +hmac = "0.12" +http = "1" +http-body-util = "0.1" +hyper = { version = "1", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["server-auto", "tokio", "http1", "http2"] } +lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls", "rustls-native-certs", "ring"] } +mail-parser = "0.11" +md-5 = "0.10" +percent-encoding = "2" +quick-xml = "0.37" +rand = "0.8" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "http2"] } +rpassword = "7" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-native-certs = "0.8" +rustls-pemfile = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] } +subtle = "2.6" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "sync", "fs", "io-util", "process"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } +tokio-util = { version = "0.7", features = ["rt"] } +tower = { version = "0.5", features = ["util", "limit"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +url = "2.5" +uuid = { version = "1", features = ["v4"] } +webpki-roots = "0.26" +zeroize = "1.8" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "debuginfo" +panic = "unwind" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2cd4e92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,696 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + + +--- + +Copyright 2007 - 2016 Zarafa Deutschland GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License, version 3, +as published by the Free Software Foundation with the following additional +term according to sec. 7: + +According to sec. 7 of the GNU Affero General Public License, version 3, +the terms of the AGPL are supplemented with the following terms: + +"Zarafa" is a registered trademark of Zarafa B.V. +"Z-Push" is a registered trademark of Zarafa Deutschland GmbH +The licensing of the Program under the AGPL does not imply a trademark license. +Therefore any rights, title and interest in our trademarks remain entirely with us. + +Our trademark policy (see TRADEMARKS) allows you to use our trademarks +in connection with Propagation and certain other acts regarding the Program. +In any case, if you propagate an unmodified version of the Program you are +allowed to use the term "Z-Push" to indicate that you distribute the Program. +Furthermore you may use our trademarks where it is necessary to indicate the +intended purpose of a product or service provided you use it in accordance +with honest business practices. For questions please contact Zarafa at +trademark@zarafa.com. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee2d6c9 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# ActivePush + +**Exchange ActiveSync server written in Rust** - a from-scratch successor of **Z-Push** (PHP) for classic mail +stacks: **IMAP + SMTP** (e.g. Dovecot + Postfix), **CalDAV** and **CardDAV** (Radicale, SOGo, Baikal, +Nextcloud, DAViCal). One binary, no PHP-FPM, with a built-in administration panel in the RustProxy style: +configuration, devices, remote wipe, policies, statistics, charts and live traffic. + +![Overview](docs/images/overview.png) + +``` +phone / Outlook --HTTPS--> nginx | RustProxy --> ActivePush :8081 --> IMAP / SMTP / CalDAV / CardDAV +administrator --(SSH tunnel / VPN)-------------> panel :9091 +``` + +## Features + +| Area | What you get | +|---|---| +| Protocol | ActiveSync 2.5, 12.0, 12.1, 14.0, 14.1; WBXML; plain and base64 query strings; multipart responses | +| Commands | Sync (incl. empty/partial and hanging Sync), FolderSync, FolderCreate/Update/Delete, GetHierarchy, Ping (push), Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations (Fetch, byte ranges, EmptyFolderContents), GetItemEstimate, MoveItems, MeetingResponse, Search (GAL and mailbox), ResolveRecipients, Settings, ValidateCert | +| Mail | IMAP folder hierarchy and SPECIAL-USE, read/flag/answered/forwarded, move, trash, attachments, text/HTML/MIME bodies with truncation and preview, S/MIME passthrough, iMIP invitations as MeetingRequest | +| Calendar | VEVENT <-> EAS: time zones (TIME_ZONE_INFORMATION <-> IANA), all-day events, RRULE recurrences, exceptions (EXDATE + RECURRENCE-ID), attendees and responses, reminders, busy status, categories | +| Tasks | VTODO <-> EAS Tasks (due, start, completion, priority, reminder, recurrence) | +| Contacts | vCard 2.1/3.0/4.0 <-> EAS Contacts with **lossless merging** of fields ActiveSync cannot represent | +| Push | Ping with cheap change detection (IMAP STATUS / DAV CTag every N seconds, full diff only when a signature changes) | +| Security | per-account and per-IP lockout, fail2ban friendly log line, user/domain/network/device-type allow and block lists, device approval, provisioning policies, remote wipe, sender enforcement, DAV SSRF guard, XML without DTD, size limits, panel with argon2id + CSRF + strict CSP | +| Panel | overview charts, live traffic (SSE), devices grouped per phone, mailboxes, backends with connection tests, ActiveSync settings, policies, security, logs and audit; English/Polish, dark/light theme | +| Operations | hardened systemd unit, portable `.deb`, Prometheus `/metrics`, `/healthz`, Z-Push config importer, CLI equivalent of `z-push-admin`, automatic database retention and compaction | + +## Quick start (development) + +```bash +cp .env.example .env +./dev.sh backends # terminal 1: Dovecot + Radicale + SMTP sink (needs dovecot-imapd, radicale, aiosmtpd) +./dev.sh seed # settings matching those backends +./dev.sh # terminal 2: server (ActiveSync :8081, panel :9091) +./dev.sh admin setup-token # one-time token for creating the administrator at http://127.0.0.1:9091 +./dev.sh probe jan@test.local # ActiveSync smoke test (password: Test-Password-123) +./dev.sh e2e # end-to-end test of every command +``` + +`./dev.sh help` lists all commands (`lint`, `test`, `release`, `deb`, `dist`, `import-zpush` ...). + +## Production (Debian/Ubuntu) + +```bash +./dev.sh deb # -> dist/active-push_-1_amd64.deb (glibc >= 2.30) +sudo apt install ./dist/active-push_*.deb # creates the user and directories, enables the service +sudo -u active-push active-push admin setup-token +ssh -L 9091:127.0.0.1:9091 server # panel: http://127.0.0.1:9091 +``` + +Then replace the Z-Push fastcgi locations in nginx with `proxy_pass` - ready snippet: +[`deploy/nginx-active-push.conf`](deploy/nginx-active-push.conf). Details: [docs/INSTALL.md](docs/INSTALL.md). + +## Migrating from Z-Push + +```bash +python3 /usr/share/active-push/zpush-import.py /usr/share/z-push > zpush.json # or /etc/z-push +sudo -u active-push active-push config apply zpush.json +``` + +Devices resynchronise on their own (unknown sync key -> status 9/3, unknown policy key -> provisioning, +unknown Z-Push collection ids are removed from the device). See [docs/MIGRATION.md](docs/MIGRATION.md). + +## Documentation + +- [docs/PANEL.md](docs/PANEL.md) - tour of the administration panel with screenshots +- [docs/INSTALL.md](docs/INSTALL.md) - installation, nginx/RustProxy, systemd, upgrades, backups +- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) - every setting and its Z-Push equivalent +- [docs/LOGS.md](docs/LOGS.md) - logging, retention, rotation and database maintenance +- [docs/SECURITY.md](docs/SECURITY.md) - security model and recommendations +- [docs/MIGRATION.md](docs/MIGRATION.md) - moving from Z-Push +- [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - common problems +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) - architecture, tests, development environment +- [docs/API.md](docs/API.md) - panel API and Prometheus metrics + +## License + +AGPL-3.0-only (like Z-Push, from which the WBXML code pages and protocol behaviour originate). diff --git a/build-release.sh b/build-release.sh new file mode 100755 index 0000000..58a72b1 --- /dev/null +++ b/build-release.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Full release pipeline: lint, tests, optimised binary, Debian package, source archive, checksums. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" +fail() { printf 'error: %s\n' "$*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required"; } +need cargo; need rustc; need python3; need sha256sum; need tar + +VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" +HOST="$(rustc -vV | sed -n 's/^host: //p')" +[[ -f Cargo.lock ]] || fail "Cargo.lock is required for a reproducible release" +printf 'ActivePush %s release build for %s\n' "$VERSION" "$HOST" + +cargo fmt --all -- --check +cargo clippy --locked --all-targets -- -D warnings +python3 scripts/check-wbxml-names.py +if command -v node >/dev/null; then node --check web/app.js; fi +cargo test --locked --lib --bins +if command -v cargo-zigbuild >/dev/null 2>&1 && [[ "${GLIBC_TARGET:-2.31}" != "0" ]]; then + # portable binary (old glibc baseline) + cargo zigbuild --locked --release --target "$(uname -m)-unknown-linux-gnu.${GLIBC_TARGET:-2.31}" + BIN="target/$(uname -m)-unknown-linux-gnu/release/active-push" +else + cargo build --locked --release + BIN="target/release/active-push" +fi +[[ -x "$BIN" ]] || fail "release binary missing" +"$BIN" --version + +rm -rf dist && mkdir -p dist +ART="active-push-${VERSION}-${HOST}" +cp "$BIN" "dist/$ART" +chmod 0755 "dist/$ART" +(cd dist && sha256sum "$ART" > "$ART.sha256") + +if command -v dpkg-deb >/dev/null; then + BIN="$BIN" scripts/build-deb.sh --no-build +else + printf 'dpkg-deb not found: skipping .deb\n' +fi + +SRC="dist/active-push-${VERSION}-source.tar.gz" +tar --exclude=./target --exclude=./data --exclude=./dist --exclude=./.git --exclude=./.env \ + -czf "$SRC" --transform "s,^\.,active-push-${VERSION}," . +(cd dist && sha256sum "$(basename "$SRC")" > "$(basename "$SRC").sha256") + +printf '\nRelease artifacts:\n' +ls -1 dist diff --git a/deploy/active-push-autodiscover.conf b/deploy/active-push-autodiscover.conf new file mode 100644 index 0000000..6740076 --- /dev/null +++ b/deploy/active-push-autodiscover.conf @@ -0,0 +1,6 @@ +proxy_pass http://127.0.0.1:8081; +proxy_set_header Host $host; +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_read_timeout 300s; +client_max_body_size 64k; diff --git a/deploy/active-push.service b/deploy/active-push.service new file mode 100644 index 0000000..4972634 --- /dev/null +++ b/deploy/active-push.service @@ -0,0 +1,58 @@ +[Unit] +Description=ActivePush Exchange ActiveSync server +Documentation=file:///usr/share/doc/active-push/README.md +Wants=network-online.target +After=network-online.target dovecot.service postfix.service + +[Service] +Type=simple +User=active-push +Group=active-push +WorkingDirectory=/var/lib/active-push +UMask=0077 +Environment=RUST_LOG=active_push=info,warn +Environment=MALLOC_ARENA_MAX=2 +EnvironmentFile=-/etc/active-push/environment +ExecStart=/usr/bin/active-push serve +Restart=on-failure +RestartSec=3 +# hanging Ping requests are released on SIGTERM +KillSignal=SIGTERM +TimeoutStopSec=30 +LimitNOFILE=65536 + +StateDirectory=active-push +StateDirectoryMode=0700 +ConfigurationDirectory=active-push +ConfigurationDirectoryMode=0750 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectClock=true +ProtectHostname=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +# AF_UNIX is needed only for the optional sendmail method +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +LockPersonality=true +MemoryDenyWriteExecute=true +CapabilityBoundingSet= +AmbientCapabilities= +ReadWritePaths=/var/lib/active-push +# sendmail method: postfix needs its queue directory +# ReadWritePaths=/var/spool/postfix/maildrop + +[Install] +WantedBy=multi-user.target diff --git a/deploy/environment.example b/deploy/environment.example new file mode 100644 index 0000000..fb65968 --- /dev/null +++ b/deploy/environment.example @@ -0,0 +1,12 @@ +# /etc/active-push/environment - read by the systemd unit (KEY=value, no shell syntax). +# All runtime settings (IMAP, SMTP, CalDAV, policies, security…) are managed in the panel. +ACTIVE_PUSH_DATA_DIR=/var/lib/active-push +ACTIVE_PUSH_EAS_LISTEN=127.0.0.1:8081 +ACTIVE_PUSH_ADMIN_LISTEN=127.0.0.1:9091 +# Expose the panel on the network (only with TLS and allowed_networks set in the panel): +# ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true +# ACTIVE_PUSH_ADMIN_LISTEN=0.0.0.0:9091 +# ACTIVE_PUSH_ADMIN_TLS_CERT=/etc/active-push/tls/fullchain.pem +# ACTIVE_PUSH_ADMIN_TLS_KEY=/etc/active-push/tls/privkey.pem +# ACTIVE_PUSH_LOG_JSON=true +RUST_LOG=active_push=info,warn diff --git a/deploy/nginx-active-push.conf b/deploy/nginx-active-push.conf new file mode 100644 index 0000000..3a1d640 --- /dev/null +++ b/deploy/nginx-active-push.conf @@ -0,0 +1,47 @@ +# ActivePush - drop-in replacement for the Z-Push PHP-FPM locations. +# Put these inside the existing `server { listen 443 ssl; ... }` block of the mail host. + +location = /Microsoft-Server-ActiveSync { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + # Ping/hanging Sync keep the request open up to ~59 minutes + proxy_read_timeout 3660s; + proxy_send_timeout 3660s; + proxy_buffering off; + proxy_request_buffering on; + client_max_body_size 64m; +} + +# Health check for monitoring / load balancers (no authentication, no data). +location = /activesync-healthz { + proxy_pass http://127.0.0.1:8081/healthz; + access_log off; +} + +# The same three case variants previously routed to autodiscover.php +location = /AutoDiscover/AutoDiscover.xml { include /etc/nginx/snippets/active-push-autodiscover.conf; } +location = /Autodiscover/Autodiscover.xml { include /etc/nginx/snippets/active-push-autodiscover.conf; } +location = /autodiscover/autodiscover.xml { include /etc/nginx/snippets/active-push-autodiscover.conf; } + +# --- /etc/nginx/snippets/active-push-autodiscover.conf ------------------------- +# proxy_pass http://127.0.0.1:8081; +# proxy_set_header Host $host; +# proxy_set_header X-Forwarded-For $remote_addr; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_read_timeout 300s; +# client_max_body_size 64k; + +# --- Optional: panel behind nginx with an extra auth layer ---------------------- +# location /active-push-admin/ { +# allow 10.0.0.0/8; deny all; +# proxy_pass http://127.0.0.1:9091/; +# proxy_set_header X-Forwarded-For $remote_addr; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_buffering off; # live event stream (SSE) +# } +# The panel uses absolute paths (/api, /app.js); a dedicated (sub)domain is simpler: +# server { server_name push-admin.example.com; location / { proxy_pass http://127.0.0.1:9091; ... } } diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..799f7fd --- /dev/null +++ b/dev.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" +umask 077 +if [[ -f .env ]]; then + # Local shell configuration. Do not source untrusted .env files. + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi +if ! command -v cargo >/dev/null 2>&1 && [[ -x "$HOME/.cargo/bin/cargo" ]]; then + export PATH="$HOME/.cargo/bin:$PATH" +fi +VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" +mode="${1:-run}" +if [[ $# -gt 0 ]]; then shift; fi + +case "$mode" in + help|-h|--help) + cat <&2; exit 2 ;; +esac + +case "$mode" in + doctor) + for tool in cargo rustc cc python3 node dpkg-deb dovecot radicale; do + printf '%-10s ' "$tool"; command -v "$tool" || printf 'NOT FOUND\n' + done + exit 0 ;; + backends) exec tools/dev/test-backends.sh "$@" ;; + import-zpush) exec python3 tools/zpush-import.py "$@" ;; + deb) exec scripts/build-deb.sh "$@" ;; + dist) exec ./build-release.sh "$@" ;; +esac + +if ! command -v cargo >/dev/null 2>&1; then + cat >&2 <<'MISSING' +Rust/Cargo is not installed or not in PATH. +Install the stable toolchain from https://rustup.rs and a C linker (build-essential). +This script never installs system software or runs sudo. +MISSING + exit 127 +fi + +export ACTIVE_PUSH_DATA_DIR="${ACTIVE_PUSH_DATA_DIR:-$ROOT/data}" +export ACTIVE_PUSH_EAS_LISTEN="${ACTIVE_PUSH_EAS_LISTEN:-127.0.0.1:8081}" +export ACTIVE_PUSH_ADMIN_LISTEN="${ACTIVE_PUSH_ADMIN_LISTEN:-127.0.0.1:9091}" +export RUST_LOG="${RUST_LOG:-active_push=info,warn}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +mkdir -p "$ACTIVE_PUSH_DATA_DIR" + +case "$mode" in + fmt) exec cargo fmt --all "$@" ;; + check) exec cargo check --all-targets "$@" ;; + test) exec cargo test --lib --bins "$@" ;; + lint) + cargo fmt --all -- --check + cargo clippy --all-targets -- -D warnings + if command -v node >/dev/null; then node --check web/app.js; fi + python3 scripts/check-wbxml-names.py + printf 'Lint: OK\n' + exit 0 ;; + release) exec cargo build --release "$@" ;; + e2e) + export AP_E2E_URL="${AP_E2E_URL:-http://${ACTIVE_PUSH_EAS_LISTEN}}" + exec cargo test --test e2e -- --nocapture --test-threads=1 "$@" ;; +esac + +printf 'Building ActivePush %s...\n' "$VERSION" >&2 +cargo build +BIN="$CARGO_TARGET_DIR/debug/active-push" +case "$mode" in + build) printf 'Binary: %s\n' "$BIN" ;; + seed) exec "$BIN" config apply tools/dev/test-settings.json ;; + probe) + user="${1:?usage: ./dev.sh probe USER}" + exec "$BIN" probe --url "http://${ACTIVE_PUSH_EAS_LISTEN}/Microsoft-Server-ActiveSync" --user "$user" ;; + admin) exec "$BIN" admin "$@" ;; + config) exec "$BIN" config "$@" ;; + device) exec "$BIN" device "$@" ;; + run) + printf 'ActiveSync: http://%s/Microsoft-Server-ActiveSync\nPanel: http://%s/\n' "$ACTIVE_PUSH_EAS_LISTEN" "$ACTIVE_PUSH_ADMIN_LISTEN" >&2 + exec "$BIN" serve "$@" ;; +esac diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6b2836a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,46 @@ +# Panel API + +Every endpoint except `/api/session`, `/api/login`, `/api/setup`, `/metrics` and `/healthz` requires a session +(cookie `ap_session`); methods other than GET additionally need the `X-CSRF-Token` header (value from +`/api/session`). + +| Method | Path | Description | +|---|---|---| +| GET | `/api/session` | session state, CSRF token, whether setup is required | +| POST | `/api/setup` | `{token, username, password}` - first administrator | +| POST | `/api/login` / `/api/logout` | sign in / out | +| POST | `/api/password` | `{current, new}` - change password (revokes other sessions) | +| POST | `/api/sessions/revoke-others` | sign out other sessions | +| GET | `/api/overview` | counters (`devices_*` = physical devices, `users_*` = mailboxes, `partnerships_total`), commands, runtime, database size | +| GET | `/api/timeseries?minutes=60` | minute buckets (up to 24 h) | +| GET | `/api/stats/hourly?hours=168` | hourly statistics from the database | +| GET | `/api/live` | requests in flight, push connections, recent events | +| GET | `/api/events` | Server-Sent Events: `request` event after every request | +| GET | `/api/devices`, `/api/devices/{id}` | partnerships (device x mailbox), details (folders, history) | +| POST | `/api/devices/{id}/{action}` | `wipe`, `cancel-wipe`, `resync`, `reprovision`, `block`, `unblock`, `approve`, `remove`, `notes`, `recovery-password` | +| GET | `/api/users` | mailboxes with device names and other mailboxes on the same device | +| GET/PUT | `/api/settings` | settings (secrets as `__SET__`; PUT accepts a full or partial document) | +| POST | `/api/test/{imap,smtp,caldav,carddav}` | `{login, password, settings?}` - connection test | +| GET | `/api/logs?level=WARN&q=text&n=500` | log buffer | +| GET | `/api/audit` | audit log | +| GET/POST | `/api/lockouts`, `/api/lockouts/unlock` | login lockouts | + +## Health check + +`GET /healthz` on both listeners returns `ok` (HTTP 200) when the process and the database respond, otherwise +HTTP 503. + +## Prometheus + +Set `panel.prometheus_token`, then: + +```yaml +- job_name: active-push + metrics_path: /metrics + authorization: { type: Bearer, credentials: "" } + static_configs: [{ targets: ["127.0.0.1:9091"] }] +``` + +Metrics: `activepush_requests_total`, `errors_total`, `auth_failures_total`, `bytes_in/out_total`, +`items_in/out_total`, `mails_sent_total`, `pings_active`, `imap_pool_connections`, `uptime_seconds`, +`resident_memory_bytes`, `command_total{command=...}`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..77dfd16 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,73 @@ +# Configuration + +Runtime settings live in SQLite (`settings` table, JSON with a revision number) and are edited in the panel. +Saving in the panel applies them **immediately** (no restart): the IMAP pool and login cache are cleared and DAV +HTTP clients are rebuilt. + +From the command line: + +```bash +active-push config get # secrets masked as "__SET__" +active-push config get --with-secrets +active-push config validate file.json +active-push config apply file.json # partial JSON - only the given keys change (deep merge) +``` + +Startup parameters (listen addresses, data directory, panel TLS) come from `/etc/active-push/environment` or +flags (`active-push --help`). + +| Variable | Default | Meaning | +|---|---|---| +| `ACTIVE_PUSH_DATA_DIR` | `/var/lib/active-push` | database directory | +| `ACTIVE_PUSH_EAS_LISTEN` | `127.0.0.1:8081` | ActiveSync + Autodiscover + `/healthz` | +| `ACTIVE_PUSH_ADMIN_LISTEN` | `127.0.0.1:9091` | panel | +| `ACTIVE_PUSH_ALLOW_REMOTE_ADMIN` | `false` | allow the panel on a non-loopback address | +| `ACTIVE_PUSH_ADMIN_TLS_CERT/KEY` | - | panel TLS | +| `ACTIVE_PUSH_EAS_TLS_CERT/KEY` | - | ActiveSync TLS (without a reverse proxy) | +| `ACTIVE_PUSH_LOG_JSON` | `false` | JSON logs | +| `RUST_LOG` | `active_push=info,warn` | log filter | +| `ACTIVE_PUSH_PANEL_LOG` | `active_push=info,warn` | filter for the panel log viewer | + +## Z-Push equivalents + +| Z-Push | ActivePush (`section.key`) | +|---|---| +| `TIMEZONE` | `general.default_timezone` | +| `USE_FULLEMAIL_FOR_LOGIN` | `general.login_format` (`as_given` / `local_part` / `full_email`) + `general.default_domain` | +| `IMAP_DEFAULTFROM` | `general.email_template` (`{login}`, `{local}@{domain}`, `{local}@example.com`) | +| `IMAP_SERVER`, `IMAP_PORT`, `IMAP_OPTIONS` | `imap.host`, `imap.port`, `imap.security` (`none`/`start_tls`/`tls`), `imap.verify_tls` | +| `IMAP_FOLDER_*`, `IMAP_FOLDER_PREFIX` | `imap.folder_*`, `imap.folder_prefix`, `imap.use_special_use` | +| `IMAP_EXCLUDED_FOLDERS` | `imap.excluded_folders` (list, `prefix*` allowed) | +| `IMAP_AUTOSEEN_ON_DELETE`, `IMAP_INLINE_FORWARD` | `imap.autoseen_on_delete`, `imap.inline_forward` | +| `IMAP_SMTP_METHOD`, `IMAP_SMTP_PARAMS` | `smtp.method`, `smtp.host`, `smtp.port`, `smtp.security`, `smtp.auth` | +| `CALDAV_*` | `caldav.enabled`, `caldav.base_url`, `caldav.path`, `caldav.include_regex`, `caldav.exclude_regex` | +| `CARDDAV_*`, `CARDDAV_GAL_PATH`, `CARDDAV_GAL_MIN_LENGTH` | `carddav.*`, `carddav.gal_path`, `carddav.gal_min_length` | +| `SYNC_MAX_ITEMS`, `SYNC_FILTERTIME_MAX`, `SYNC_CONFLICT_DEFAULT` | `sync.max_items`, `sync.filter_time_max`, `sync.server_wins` | +| `SYNC_CONTACTS_MAXPICTURESIZE` | `carddav.max_picture_kb` | +| `PING_INTERVAL`, `PING_*_BOUND_LIFETIME` | `ping.check_interval_secs`, `ping.lifetime_min/max` | +| `RETRY_AFTER_DELAY`, `SEARCH_WAIT`, `SEARCH_MAXRESULTS` | `sync.retry_after_secs`, `sync.search_wait_secs`, `sync.search_max_results` | +| `PROVISIONING`, `LOOSE_PROVISIONING`, `policies.ini` | `provisioning.enabled`, `provisioning.loose`, `provisioning.policies[]`, `provisioning.user_policies[]` | +| `LOGAUTHFAIL`, `LOGUSERLEVEL` | `security.log_auth_failures`, `logging.debug_users` | +| `ZPUSH_HOST` (autodiscover) | `autodiscover.server_url` | +| `STATE_MACHINE`, `IPC_PROVIDER` | not needed - states in SQLite, IPC in process | + +DAV paths accept `%u` (login), `%l` (local part) and `%d` (domain). + +## Settings without a Z-Push counterpart + +- `security.auth_fail_max / auth_fail_window_secs / lockout_secs` - password guessing protection. +- `security.trusted_proxies`, `security.real_ip_header` - real client IP behind a proxy. +- `security.eas_allowed_networks`, `panel.allowed_networks` - network allow lists. +- `security.allowed_users`, `blocked_users`, `blocked_device_types`, `require_device_approval`. +- `security.auth_cache_secs` - successful login cache (HMAC with a random per-process key, never the password). +- `smtp.enforce_from` - sender always equals the account address. +- `sync.loop_detection` - detects devices replaying an old SyncKey (shrinks the window, reported in the panel). +- `imap.pool_*` - pool of authenticated IMAP connections reused between requests. +- `panel.prometheus_token`, `panel.device_inactive_days`, `logging.*` - see [LOGS.md](LOGS.md). + +## Time zones + +Devices send the zone as a 172-byte Windows `TIME_ZONE_INFORMATION` structure. ActivePush maps it to an IANA +zone, preferring `general.default_timezone` (comparing this year's DST rules). Recurring events are stored in +CalDAV with `TZID` and a generated `VTIMEZONE`; single events in UTC (unless the original used `TZID`, in which +case the format is kept). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..9d574a8 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,78 @@ +# Development + +## Architecture + +``` +src/ + main.rs CLI (serve, admin, config, device, probe) + lib.rs public router (ActiveSync, Autodiscover, /, /healthz) + app.rs application state: settings (ArcSwap), IMAP pool, metrics, lockouts + config.rs settings model (serde, validation, merging, secrets) + db.rs SQLite (sqlx, WAL), migrations in migrations/, housekeeping + server.rs HTTP/1.1 + HTTP/2 listener, optional TLS, graceful shutdown + wbxml/ WBXML encoder/decoder + MS-ASWBXML code pages + eas/ ActiveSync protocol + mod.rs request parsing (plain/base64 query), auth, provisioning, dispatcher + state.rs SyncKey {uuid}N and states (item fingerprints) + folders.rs FolderSync / Create / Update / Delete + sync.rs Sync, GetItemEstimate, MoveItems + ping.rs Ping and the shared wait-for-changes loop (hanging Sync) + provision.rs policies, remote wipe + mailcmd.rs SendMail / SmartReply / SmartForward / MeetingResponse + itemops.rs ItemOperations, GetAttachment + misc.rs Settings, Search, ResolveRecipients + convert/ RFC 5322 / iCalendar / vCard <-> ApplicationData + backend/ + imap.rs own async IMAP client (literals, UIDPLUS, MOVE, SPECIAL-USE) + dav.rs CalDAV/CardDAV client + smtp.rs SMTP (lettre) / sendmail + ical.rs iCalendar/vCard parser and serializer + tz.rs EAS <-> IANA time zones, dates + mime.rs MIME building (SmartReply/Forward) + mod.rs session: folder hierarchy, fingerprint listings, operations + autodiscover.rs + admin.rs panel API, sessions, CSRF, SSE, Prometheus +web/ panel (dependency-free HTML/CSS/JS, embedded in the binary) +``` + +### Synchronisation + +The device state of a folder is a map `item id -> fingerprint` (IMAP: flags; DAV: ETag) stored under the key +`{uuid}N`. Sync compares the backend listing with the state and sends the differences (deletes -> changes -> +adds, newest mail first) within `WindowSize`. The new state is stored as `N+1`; the previous one is kept to +handle lost responses (the device replays the old key). Client adds are idempotent thanks to the `ClientId` map. + +Ping keeps a folder signature (IMAP `STATUS UIDNEXT/MESSAGES/UNSEEN/HIGHESTMODSEQ`, DAV CTag); a full listing +comparison only happens when the signature changes. + +Collection ids that a device uses but that do not exist (Z-Push leftovers) are remembered per device and sent +as FolderHierarchy Delete on the next FolderSync. + +## Development environment + +```bash +sudo apt install dovecot-imapd # any dovecot >= 2.3 +pip install radicale aiosmtpd +./dev.sh backends # Dovecot :11143, Radicale :15232, SMTP sink :12525 +./dev.sh seed && ./dev.sh # ActivePush :8081 / panel :9091 +./dev.sh e2e # test of every command against live backends +``` + +Test accounts: `jan`, `anna`, `biuro`, `kasia` `@test.local`, password `Test-Password-123`. + +`./dev.sh lint` - rustfmt, clippy `-D warnings`, JS syntax check and WBXML tag name check +(`scripts/check-wbxml-names.py`). `./dev.sh test` - unit tests (WBXML, IMAP tokenizer, UTF-7, calendar/contact/ +task conversions, time zones, query parser, CIDR, MIME). + +Protocol debugging: add the login to **ActiveSync -> Logging -> Debug users**; decoded WBXML of requests and +responses goes to the log. + +Simulating devices: `active-push probe --device-id F2LX8KQ1N72J --device-type iPhone --model "iPhone 15 Pro" ...`. + +## Documentation screenshots + +`tools/dev/screenshots.js` (Playwright) regenerates `docs/images/` from a running dev instance: + +```bash +node tools/dev/screenshots.js http://127.0.0.1:9091 admin 'password' +``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..f491512 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,132 @@ +# Installation + +## Requirements + +- Linux x86_64 (the `.deb` needs glibc >= 2.30: Debian 11+, Ubuntu 20.04+). No OpenSSL or PHP - TLS is + rustls and SQLite is built in. +- IMAP server (Dovecot recommended: `UIDPLUS`, `MOVE`, `SPECIAL-USE`, `CONDSTORE`, `UNSELECT`). +- SMTP submission (Postfix on 587 with SASL) or a local `sendmail`. +- Optional CalDAV/CardDAV. +- A TLS reverse proxy (nginx, RustProxy, Apache). ActivePush listens on `127.0.0.1` only by default. + +## Debian package + +```bash +./dev.sh deb # on the build machine (Rust stable; cargo-zigbuild makes it portable) +sudo apt install ./dist/active-push_*_amd64.deb +``` + +| Path | Content | +|---|---| +| `/usr/bin/active-push` | binary (server + CLI) | +| `/lib/systemd/system/active-push.service` | service with systemd sandboxing | +| `/etc/active-push/environment` | listen addresses, panel TLS, log level (conffile) | +| `/var/lib/active-push/active-push.db` | SQLite: settings, devices, states, statistics, audit (0600) | +| `/usr/share/doc/active-push/` | documentation and nginx examples | +| `/usr/share/active-push/zpush-import.py` | Z-Push configuration importer | + +`postinst` creates the `active-push` system user and enables and starts the service. Upgrading the package +restarts the service (push connections are released and devices reconnect). `apt purge` also removes data. + +### Portable builds + +`scripts/build-deb.sh` uses `cargo zigbuild` with a glibc 2.31 baseline when it is installed +(`pip install ziglang && cargo install cargo-zigbuild`), otherwise a normal `cargo build`. The `libc6` +dependency is computed from the binary's symbols. `GLIBC_TARGET=0` forces a native build. + +### First administrator + +```bash +sudo -u active-push active-push admin setup-token # one-time token, valid 60 minutes +ssh -L 9091:127.0.0.1:9091 admin@server # from your workstation +# browser: http://127.0.0.1:9091 -> paste the token, choose login and password (min. 12 characters) +``` + +Without a browser: `sudo -u active-push active-push admin init --username admin`. +Lost password: `sudo -u active-push active-push admin reset-password` (revokes all sessions). +Always run CLI commands as `active-push`; the binary refuses to touch a data directory owned by another user. + +## nginx + +Replace the Z-Push fastcgi locations with the snippet from `deploy/nginx-active-push.conf`: + +```nginx +location = /Microsoft-Server-ActiveSync { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3660s; # Ping holds the request for up to ~59 minutes + proxy_send_timeout 3660s; + proxy_buffering off; + client_max_body_size 64m; +} +location = /AutoDiscover/AutoDiscover.xml { include snippets/active-push-autodiscover.conf; } +location = /Autodiscover/Autodiscover.xml { include snippets/active-push-autodiscover.conf; } +location = /autodiscover/autodiscover.xml { include snippets/active-push-autodiscover.conf; } + +# health check for monitoring / load balancers (no authentication, no data) +location = /activesync-healthz { + proxy_pass http://127.0.0.1:8081/healthz; + access_log off; +} +``` + +`snippets/active-push-autodiscover.conf` is `deploy/active-push-autodiscover.conf`. +**Important:** `X-Forwarded-For $remote_addr` (overwrite, not append) together with the default trusted proxy +`127.0.0.1/32` gives the real client IP for lockouts and logs. + +Port 8081 answers `/` with a short text, `/healthz` with `ok` (also checks the database) and anything else +with a plain 404. + +## RustProxy + +Create a vhost for the mail domain that routes `/Microsoft-Server-ActiveSync` and the three autodiscover paths +to `http://127.0.0.1:8081` with a response timeout of at least 3660 s and response buffering disabled; point a +health check at `/healthz`. RustProxy sets `X-Forwarded-For`, so its address must be in **Security -> Trusted +proxies** (loopback by default). + +## Postfix (submission) + +Recommended: SMTP on 587 authenticated with the device user's credentials (`auth: user_credentials`). With +`smtpd_sender_login_maps` + `reject_sender_login_mismatch` the mail server enforces the sender as well. +The `sendmail` method needs `NoNewPrivileges=false` and `ReadWritePaths=/var/spool/postfix/maildrop` in the unit +(setgid `postdrop`), which is why it is not recommended. + +## Panel outside localhost + +The panel refuses to start on a non-loopback address unless allowed: + +```ini +# /etc/active-push/environment +ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true +ACTIVE_PUSH_ADMIN_LISTEN=0.0.0.0:9091 +ACTIVE_PUSH_ADMIN_TLS_CERT=/etc/active-push/tls/fullchain.pem +ACTIVE_PUSH_ADMIN_TLS_KEY=/etc/active-push/tls/privkey.pem +``` + +and set **Security -> Networks allowed to use the panel**. Certificates must be readable by the +`active-push` group (e.g. copied by a certbot deploy hook). + +## Upgrades + +Install the new package; the schema is migrated automatically on start. Settings, devices and states are kept. + +## Backups + +See [LOGS.md](LOGS.md#backups). Losing the database never loses mail - devices resynchronise. + +## Building from source without a package + +```bash +cargo build --release +sudo install -m 0755 target/release/active-push /usr/bin/active-push +sudo adduser --system --group --home /var/lib/active-push active-push +sudo install -d -o active-push -g active-push -m 0700 /var/lib/active-push +sudo install -m 0644 deploy/active-push.service /etc/systemd/system/ +sudo install -d -m 0750 -g active-push /etc/active-push +sudo install -m 0640 -g active-push deploy/environment.example /etc/active-push/environment +sudo systemctl daemon-reload && sudo systemctl enable --now active-push +``` diff --git a/docs/LOGS.md b/docs/LOGS.md new file mode 100644 index 0000000..f6ff85e --- /dev/null +++ b/docs/LOGS.md @@ -0,0 +1,79 @@ +# Logging, retention and database maintenance + +ActivePush is designed so that nothing grows without bounds. This page lists every place where data +accumulates and how it is limited. + +## Server log + +- Written to **stdout/stderr**, collected by **journald** under the systemd unit. ActivePush does not write + log files itself, so there is nothing to rotate with logrotate. +- Level: `RUST_LOG` in `/etc/active-push/environment` (default `active_push=info,warn`). + `ACTIVE_PUSH_LOG_JSON=true` switches to JSON lines (ELK, Loki, Vector). +- Size is controlled by journald. Recommended drop-in: + + ```ini + # /etc/systemd/journald.conf.d/size.conf + [Journal] + SystemMaxUse=500M + SystemKeepFree=1G + MaxRetentionSec=30day + ``` + + then `systemctl restart systemd-journald`. Read the log with `journalctl -u active-push -f`. +- Volume at `info` is roughly one line per notable event (new device, provisioning, failed login, sent + mail, errors) - routine Sync/Ping requests are not logged individually. +- **Debug WBXML** (`logging.log_wbxml` or `logging.debug_users`) logs full requests and responses including + message bodies. Enable it only for a short diagnosis and for chosen mailboxes. + +If you redirect output to a file yourself (e.g. a container without journald), rotate it with +`copytruncate`: + +``` +/var/log/active-push/active-push.log { + daily + rotate 14 + compress + missingok + notifempty + copytruncate +} +``` + +## In-memory buffers (panel) + +| Buffer | Limit | +|---|---| +| Log viewer | `logging.ring_size` lines (default 2000, 100..100000) | +| Live request stream | last 500 requests | +| Minute statistics | 24 hours (1440 buckets) | +| Active mailbox tracking | entries older than 24 h are pruned every minute | +| Failed login counters / lockouts / login cache | expire automatically, pruned every minute | +| Loop detection, stale collections, time zone cache | bounded and expiring | + +All of these are lost on restart by design; persistent history is in the database. + +## Database + +`/var/lib/active-push/active-push.db` (SQLite, WAL). Hourly housekeeping: + +| Data | Retention | +|---|---| +| Hourly statistics | `logging.stats_retention_days` (default 90) | +| Audit log | `logging.audit_retention_days` (default 365) | +| Panel sessions, setup tokens | removed when expired | +| Devices not seen for `panel.device_inactive_days` (default 180, `0` = never) | removed with their sync states and folder statistics; a device that returns later simply resynchronises | +| Sync states | at most two per folder and device (current + previous for retries); removed with the device, on folder deletion and on FolderSync reset; states untouched for more than 400 days are dropped | +| Folder/item id maps | removed for mailboxes that no longer have any device | +| WAL file | checkpointed and truncated every hour | +| Free pages | incremental auto-vacuum (databases from 0.1.0 are converted once automatically) | + +Typical size: a few hundred kilobytes per device, mostly sync states of large mail folders. The panel shows +the database and WAL size on the Overview page (**Server** card). + +## Backups + +```bash +sqlite3 /var/lib/active-push/active-push.db ".backup '/root/active-push-$(date +%F).db'" +``` + +Losing the database never loses mail data: devices perform a full resynchronisation. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..b04196b --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,58 @@ +# Migrating from Z-Push + +## 1. Import the configuration + +```bash +python3 /usr/share/active-push/zpush-import.py /usr/share/z-push > zpush.json # source installation +python3 /usr/share/active-push/zpush-import.py /etc/z-push > zpush.json # Debian packages (*.conf.php) +less zpush.json # review and complete +sudo -u active-push active-push config apply zpush.json +``` + +The importer reads `config.php`, `backend/{imap,caldav,carddav,combined}/config.php`, `autodiscover/config.php` +and `policies.ini`. Comments on stderr point out what needs attention (e.g. `IMAP_SMTP_METHOD = 'mail'` -> +sendmail; switching to SMTP submission on 587 is recommended). + +## 2. Switch the traffic + +1. Install ActivePush, create the administrator, verify the backends with **Test connection** in the panel. +2. Check the protocol with a real account: + `active-push probe --url https://mail.example.com/Microsoft-Server-ActiveSync --user jan@example.com` + (through nginx after the switch) or directly against `http://127.0.0.1:8081/...` before it. +3. In nginx replace the `fastcgi_pass ... z-push/index.php` and `autodiscover.php` blocks with `proxy_pass` + (see INSTALL.md), then `nginx -t && systemctl reload nginx`. +4. Disable the Z-Push PHP-FPM pool and the `z-push-admin` cron jobs. + +## 3. What happens to devices + +Z-Push states are not transferred (their format depends on the PHP backends). On the first connection: + +- the device sends its old `X-MS-PolicyKey` -> new provisioning (HTTP 449, or status 142/144 for AS >= 14), +- the old hierarchy `SyncKey` -> `FolderSync` status 9 -> the device downloads the folder list again, +- old folder keys -> `Sync` status 3 -> full folder synchronisation, +- folder ids that only existed in Z-Push (e.g. `a6271970`) -> `Sync` status 12, and the next FolderSync sends a + **Delete** for them so the device stops using them. + +iOS, Android (Gmail, Samsung, Nine), Outlook and Windows Mail handle this automatically. Large mailboxes may +take a few minutes - limit `sync.filter_time_max` or the policy `maxemailagefilter` if needed. + +## 4. `z-push-admin` equivalents + +| z-push-admin | ActivePush | +|---|---| +| `-a list` | `active-push device list` / panel **Devices** | +| `-a wipe -d ID` | `active-push device wipe ` / **Remote wipe** button | +| `-a remove -d ID` | `active-push device remove ` | +| `-a resync -d ID` | `active-push device resync ` / **Full resync** | +| `-a clearloop` | automatic - loop counter in the device details | +| `-a fixstates` | `panel.device_inactive_days` + hourly housekeeping | +| `z-push-top` | panel **Live** | + +## 5. Functional differences + +- **Kopano/Zarafa (MAPI)** is not supported - only IMAP/SMTP + CalDAV/CardDAV (`combined`). +- **LDAP/SQL as sender source** (`IMAP_FROM_SQL_*`, `IMAP_FROM_LDAP_*`) is replaced by `general.email_template`. +- **searchldap** -> GAL through CardDAV (`carddav.gal_path`); SOGo/Nextcloud expose the directory as a book. +- **Out of office** - reading returns "disabled", setting returns an error; configure vacation in Sieve. +- **AS 16.x** is not announced (like Z-Push with IMAP by default) - devices use 14.1. +- Notes and SMS are not synchronised (Z-Push IMAP did not do this either). diff --git a/docs/PANEL.md b/docs/PANEL.md new file mode 100644 index 0000000..7fdf60b --- /dev/null +++ b/docs/PANEL.md @@ -0,0 +1,109 @@ +# Administration panel + +The panel is a separate listener (default `127.0.0.1:9091`) embedded in the binary. Reach it through an SSH +tunnel (`ssh -L 9091:127.0.0.1:9091 server`) or a VPN. It is in English by default; switch to Polish with the +language selector (remembered per browser). Settings pages apply changes immediately, without a restart. + +## First login + +![First run](images/setup.png) + +On the first start there is no administrator. Generate a one-time token on the server and paste it together +with the new login and password (at least 12 characters): + +```bash +sudo -u active-push active-push admin setup-token # valid 60 minutes, single use +``` + +Without a browser: `sudo -u active-push active-push admin init --username admin`. + +## 01 Overview + +![Overview](images/overview.png) + +- **Tiles** - requests per minute, active push connections, **devices** (physical phones, counted by DeviceId), + **mailboxes** (logins), mails sent, errors, failed logins and transfer. One person with several mailboxes on + one phone counts as one device and several mailboxes; the devices tile also shows the number of + partnerships (device x mailbox pairs). +- **Charts** - traffic, synchronised items and data per minute (60 min / 6 h / 24 h), requests per hour for the + last 7 days. Hover a chart to read exact values. +- **Commands**, **device types** and **server** (version, uptime, memory, IMAP pool, database and WAL size, + configured backends). + +## 02 Live + +![Live](images/live.png) + +Push connections (Ping or hanging Sync) with their heartbeat progress, requests in flight and a live stream of +finished requests (Server-Sent Events). Filter the stream by mailbox, device or command; pause it while reading. + +## 03 Devices + +![Devices](images/devices.png) + +One row per **physical device**. Every mailbox configured on it is a chip; a green dot means that mailbox +currently holds a push connection. Filters: push, pending approval, blocked, inactive for 30 days. + +Click a mailbox chip (or the row) to open the partnership: + +![Device](images/device.png) + +- details reported by the device (model, OS, IMEI, user agent, protocol, policy), +- per-folder statistics (items, sent/received, last sync, window size, loop detection hits), +- recent requests and free-form notes, +- actions: **approve**, **full resync**, **force re-provision**, **block/unblock**, **remote wipe** (confirmed by + typing the device id), **cancel wipe**, **show recovery password**, **remove partnership**. + +All actions are written to the audit log. + +## 04 Mailboxes + +![Mailboxes](images/mailboxes.png) + +Every login that synchronises through ActiveSync, with its devices, the **other mailboxes on the same device** +(useful to see one person's accounts together), push connections, requests, last activity and effective policy. +Blocking a mailbox applies immediately on every device. Active login lockouts can be cleared here. + +## 05 Backends + +![Backends](images/backends.png) + +IMAP, SMTP, CalDAV and CardDAV. Each section has a **Test connection** button that uses the values currently in +the form (also unsaved ones) and an optional test login; the password is never stored. + +## 06 ActiveSync + +![ActiveSync](images/activesync.png) + +Login format and default domain, e-mail address template, protocol version, synchronisation limits, push +intervals, Autodiscover, logging and retention. + +## 07 Policies + +![Policies](images/policies.png) + +Provisioning switch, loose provisioning, named policies with every `policies.ini` option and per-mailbox +assignments (`login=policy`, one per line). Changing a policy makes devices accept it again. + +## 08 Security + +![Security](images/security.png) + +Login lockout thresholds, trusted proxies and client IP header, network allow lists for ActiveSync and the +panel, allowed/blocked mailboxes and device types, device approval, panel session lifetime, Prometheus token, +administrator password change and signing out other sessions. + +## 09 Logs and audit + +![Logs](images/logs.png) + +Recent server log lines (level filter, search, auto refresh) and the audit trail of administrative actions. +The log view is an in-memory ring buffer; the full log goes to journald. See [LOGS.md](LOGS.md). + +## Themes and small screens + +![Light theme](images/overview-light.png) + +The theme button switches between dark and light. On narrow screens the navigation collapses into a menu. + +Mobile diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..8470806 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,61 @@ +# Security + +## Model + +- **Devices** authenticate with Basic Auth (required by the protocol) - always behind TLS (reverse proxy). + The password is verified by logging in to IMAP and is passed to SMTP/DAV only within the same request. It is + never stored. The successful login cache keeps only HMAC-SHA256(login, password) with a random key generated + at process start. +- **The IMAP pool** keeps authenticated connections keyed by the same HMAC - a connection is only reused for a + request with identical credentials. +- **The panel** is a separate listener (loopback by default; another address requires explicit consent). + Administrator passwords: argon2id. Session: random 256-bit token in an `HttpOnly; SameSite=Strict` cookie + (`Secure` over HTTPS); only its SHA-256 is stored. Every modifying request needs the CSRF header. The first + administrator can only be created with a one-time token from the CLI (valid 60 minutes). +- **Panel headers:** strict CSP (`script-src 'self'`, no inline code), `X-Frame-Options: DENY`, `nosniff`, + `Referrer-Policy: no-referrer`, COOP. + +## Abuse protection + +- Lockout after `auth_fail_max` failures per account and `3x` per IP within the window; HTTP 429 with + `Retry-After`. Other accounts from the same IP keep working until the IP limit is reached. +- fail2ban friendly log line: + `authentication failed user=jan@example.com ip=203.0.113.7 service=activesync` + ```ini + # /etc/fail2ban/filter.d/active-push.conf + [Definition] + failregex = authentication failed user=\S+ ip= service= + journalmatch = _SYSTEMD_UNIT=active-push.service + ``` +- Panel: 5 failed logins in 10 minutes lock the IP for 15 minutes; constant verification cost even for unknown + accounts. +- Limits: request size (`sync.max_request_mb`), attachments, messages, WBXML depth and element count, IMAP + response size, timeouts on every backend operation, HTTP header read timeout. +- Unknown ActiveSync command names are counted as `Unknown` (no unbounded metric labels). +- XML (Autodiscover, WebDAV) is parsed without DTD/entities (XXE, billion laughs). +- The DAV client never follows `href`s to another host (SSRF). +- Logins, device ids and device types are validated; SQL parameters are always bound. +- `smtp.enforce_from` rewrites `From` to the account address; `Bcc` is stripped from the transmitted copy. + +## Device control + +- Provisioning policies (password, encryption, camera, maximum mail age ...). Changing a policy invalidates + keys - devices must accept the policy again. +- Remote wipe (confirmed by typing the device id), device block, approval of new devices, mailbox block, + blocked device types/User-Agents. +- Every administrative action is written to the audit log (who, what, when, from where). + +## systemd + +The service runs as `active-push` without privileges or capabilities, with `ProtectSystem=strict`, +`ProtectHome`, `PrivateTmp`, `PrivateDevices`, `MemoryDenyWriteExecute`, a system call filter and write access +only to `/var/lib/active-push`. The database is `0600`, the directory `0700`, `UMask=0077`. + +## Recommendations + +1. TLS only at the reverse proxy, with HSTS; ActivePush on `127.0.0.1`. +2. Panel through an SSH tunnel or VPN. If exposed - TLS + `panel.allowed_networks`. +3. SMTP submission with `reject_sender_login_mismatch`. +4. Enable `security.require_device_approval` in company environments. +5. Set a Prometheus token only if you scrape metrics; the endpoint then lives on the panel port. +6. `logging.log_wbxml` contains message content - only for short diagnosis. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..0be2beb --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,52 @@ +# Troubleshooting + +## Health and diagnostics + +```bash +curl -s http://127.0.0.1:8081/healthz # "ok" when the process and database respond +journalctl -u active-push -f # server log +sudo -u active-push active-push device list +active-push probe --url https://mail.example.com/Microsoft-Server-ActiveSync --user jan@example.com +``` + +`probe` performs OPTIONS, Provision, FolderSync and an Inbox Sync like a phone would. Use `--device-id`, +`--device-type` and `--model` to imitate a specific device. For protocol-level detail add the mailbox to +**ActiveSync -> Logging -> Debug users**; decoded WBXML of every request/response then appears in the log. + +## After migrating from Z-Push: "collection ... not found" in the live stream + +Phones may keep folder ids created by Z-Push (e.g. `a6271970`). ActivePush answers such a Sync with status 12 +(hierarchy changed), remembers the id and sends a FolderHierarchy **Delete** for it on the device's next +FolderSync, so the phone drops the phantom folder and continues with the real ones. If a device still loops +after a few minutes, use **Full resync** in the device dialog or remove and re-add the account on the phone. + +## Device keeps asking for the password / HTTP 401 + +- Wrong password, or the login format does not match IMAP (`general.login_format`, `default_domain`). +- The account or IP may be locked out: **Mailboxes -> Active login lockouts** shows and clears lockouts. +- Check `authentication failed user=... ip=...` lines in the log. + +## HTTP 449 or status 142/144 + +The device has not accepted the current policy. It normally re-provisions automatically. If a device cannot +apply the policy (e.g. it does not support encryption), relax the policy or enable loose provisioning. + +## Push is late + +Push latency is at most `ping.check_interval_secs` (default 30 s). The reverse proxy must allow long requests +(`proxy_read_timeout 3660s`, buffering off). The **Live** page shows every held Ping and its heartbeat. + +## HTTP 503 with Retry-After + +The IMAP server was unreachable during the request. Check the **Backends** page test buttons. + +## Panel is not reachable + +The panel listens on loopback by default; use `ssh -L 9091:127.0.0.1:9091 server`. Starting it on another +address requires `ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true` (preferably with TLS). Lost password: +`sudo -u active-push active-push admin reset-password`. + +## "belongs to uid ..." when running a command + +CLI commands must run as the service user so database files keep the right owner: +`sudo -u active-push active-push ...`. diff --git a/docs/images/activesync.png b/docs/images/activesync.png new file mode 100644 index 0000000..afa227c Binary files /dev/null and b/docs/images/activesync.png differ diff --git a/docs/images/backends.png b/docs/images/backends.png new file mode 100644 index 0000000..1b027e2 Binary files /dev/null and b/docs/images/backends.png differ diff --git a/docs/images/device.png b/docs/images/device.png new file mode 100644 index 0000000..d61d5bb Binary files /dev/null and b/docs/images/device.png differ diff --git a/docs/images/devices.png b/docs/images/devices.png new file mode 100644 index 0000000..1a8ec5c Binary files /dev/null and b/docs/images/devices.png differ diff --git a/docs/images/live.png b/docs/images/live.png new file mode 100644 index 0000000..40ff1d6 Binary files /dev/null and b/docs/images/live.png differ diff --git a/docs/images/logs.png b/docs/images/logs.png new file mode 100644 index 0000000..999d5a8 Binary files /dev/null and b/docs/images/logs.png differ diff --git a/docs/images/mailboxes.png b/docs/images/mailboxes.png new file mode 100644 index 0000000..a01eafb Binary files /dev/null and b/docs/images/mailboxes.png differ diff --git a/docs/images/mobile.png b/docs/images/mobile.png new file mode 100644 index 0000000..7211cc3 Binary files /dev/null and b/docs/images/mobile.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png new file mode 100644 index 0000000..f2b03e8 Binary files /dev/null and b/docs/images/overview-light.png differ diff --git a/docs/images/overview.png b/docs/images/overview.png new file mode 100644 index 0000000..774701e Binary files /dev/null and b/docs/images/overview.png differ diff --git a/docs/images/policies.png b/docs/images/policies.png new file mode 100644 index 0000000..49dc649 Binary files /dev/null and b/docs/images/policies.png differ diff --git a/docs/images/security.png b/docs/images/security.png new file mode 100644 index 0000000..aa81cc5 Binary files /dev/null and b/docs/images/security.png differ diff --git a/docs/images/setup.png b/docs/images/setup.png new file mode 100644 index 0000000..abb426d Binary files /dev/null and b/docs/images/setup.png differ diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..99ac70c --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,140 @@ +-- ActivePush schema v1 + +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_login INTEGER, + password_changed_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS admin_sessions ( + token_hash TEXT PRIMARY KEY, + admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, + csrf TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + ip TEXT, + user_agent TEXT +); + +CREATE TABLE IF NOT EXISTS setup_token ( + id INTEGER PRIMARY KEY CHECK (id = 1), + token_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY, + device_id TEXT NOT NULL, + username TEXT NOT NULL, + device_type TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + imei TEXT NOT NULL DEFAULT '', + friendly_name TEXT NOT NULL DEFAULT '', + os TEXT NOT NULL DEFAULT '', + os_language TEXT NOT NULL DEFAULT '', + phone_number TEXT NOT NULL DEFAULT '', + mobile_operator TEXT NOT NULL DEFAULT '', + as_version TEXT NOT NULL DEFAULT '', + policy_key INTEGER NOT NULL DEFAULT 0, + policy_temp_key INTEGER NOT NULL DEFAULT 0, + policy_name TEXT NOT NULL DEFAULT '', + policy_hash TEXT NOT NULL DEFAULT '', + wipe_status INTEGER NOT NULL DEFAULT 0, + wipe_requested_at INTEGER, + wipe_requested_by TEXT, + wipe_completed_at INTEGER, + blocked INTEGER NOT NULL DEFAULT 0, + approved INTEGER NOT NULL DEFAULT 1, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + last_ip TEXT NOT NULL DEFAULT '', + last_command TEXT NOT NULL DEFAULT '', + requests INTEGER NOT NULL DEFAULT 0, + recovery_password TEXT NOT NULL DEFAULT '', + ping_folders TEXT NOT NULL DEFAULT '', + ping_lifetime INTEGER NOT NULL DEFAULT 0, + last_sync_request TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + UNIQUE (device_id, username) +); +CREATE INDEX IF NOT EXISTS devices_user ON devices(username); +CREATE INDEX IF NOT EXISTS devices_seen ON devices(last_seen); + +CREATE TABLE IF NOT EXISTS sync_states ( + device_rowid INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + collection TEXT NOT NULL, + uuid TEXT NOT NULL, + counter INTEGER NOT NULL, + data BLOB NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (device_rowid, collection, uuid, counter) +); + +CREATE TABLE IF NOT EXISTS folder_map ( + username TEXT NOT NULL, + backend_id TEXT NOT NULL, + short_id INTEGER NOT NULL, + PRIMARY KEY (username, backend_id), + UNIQUE (username, short_id) +); + +CREATE TABLE IF NOT EXISTS item_map ( + username TEXT NOT NULL, + folder TEXT NOT NULL, + long_id TEXT NOT NULL, + short_id INTEGER NOT NULL, + PRIMARY KEY (username, folder, long_id), + UNIQUE (username, folder, short_id) +); + +CREATE TABLE IF NOT EXISTS folder_stats ( + device_rowid INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + collection TEXT NOT NULL, + folder_name TEXT NOT NULL DEFAULT '', + folder_class TEXT NOT NULL DEFAULT '', + last_sync INTEGER NOT NULL DEFAULT 0, + items_out INTEGER NOT NULL DEFAULT 0, + items_in INTEGER NOT NULL DEFAULT 0, + item_count INTEGER NOT NULL DEFAULT 0, + last_status INTEGER NOT NULL DEFAULT 0, + window_size INTEGER NOT NULL DEFAULT 0, + loop_hits INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_rowid, collection) +); + +CREATE TABLE IF NOT EXISTS stats_hourly ( + hour INTEGER PRIMARY KEY, + requests INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + auth_failures INTEGER NOT NULL DEFAULT 0, + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + items_out INTEGER NOT NULL DEFAULT 0, + items_in INTEGER NOT NULL DEFAULT 0, + mails_sent INTEGER NOT NULL DEFAULT 0, + total_ms INTEGER NOT NULL DEFAULT 0, + commands TEXT NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + details TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS audit_ts ON audit_log(ts); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..05e6ca1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..c964486 --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Build a Debian/Ubuntu package with dpkg-deb only (no debhelper needed). +# scripts/build-deb.sh build release binary for the host and package it +# scripts/build-deb.sh --no-build package an existing target/release/active-push +# TARGET=aarch64-unknown-linux-gnu scripts/build-deb.sh (cross toolchain must be installed) +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +fail() { printf 'error: %s\n' "$*" >&2; exit 1; } +command -v dpkg-deb >/dev/null || fail "dpkg-deb is required (apt install dpkg)" + +VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" +REVISION="${DEB_REVISION:-1}" +BUILD=1 +[[ "${1:-}" == "--no-build" ]] && BUILD=0 +HOST_ARCH="$(uname -m)" +# Portable builds: link against an old glibc with cargo-zigbuild when available +# (pip install ziglang && cargo install cargo-zigbuild). GLIBC_TARGET=0 disables it. +GLIBC_TARGET="${GLIBC_TARGET:-2.31}" +TARGET="${TARGET:-}" +ZIG=0 +if [[ -z "$TARGET" && "$GLIBC_TARGET" != "0" ]] && command -v cargo-zigbuild >/dev/null 2>&1; then + TARGET="${HOST_ARCH}-unknown-linux-gnu" + ZIG=1 +fi +case "${TARGET:-$HOST_ARCH}" in + x86_64*) ARCH=amd64 ;; + aarch64*) ARCH=arm64 ;; + armv7*) ARCH=armhf ;; + *) ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)" ;; +esac +if [[ -n "${BIN:-}" ]]; then + : +elif [[ -n "$TARGET" ]]; then + BIN="${CARGO_TARGET_DIR:-target}/$TARGET/release/active-push" +else + BIN="${CARGO_TARGET_DIR:-target}/release/active-push" +fi + +if [[ $BUILD == 1 ]]; then + printf 'Building release binary %s...\n' "$VERSION" + if [[ $ZIG == 1 ]]; then + cargo zigbuild --release --locked --target "$TARGET.$GLIBC_TARGET" + elif [[ -n "$TARGET" ]]; then + cargo build --release --locked --target "$TARGET" + else + cargo build --release --locked + fi +fi +[[ -x "$BIN" ]] || fail "binary $BIN not found" + +# minimal libc6 version from the binary's versioned symbols +LIBC_MIN="2.31" +if command -v objdump >/dev/null; then + v="$(objdump -T "$BIN" 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -V | tail -1)" + [[ -n "$v" ]] && LIBC_MIN="$v" +fi +printf 'Binary %s requires glibc >= %s\n' "$BIN" "$LIBC_MIN" + +PKG="active-push_${VERSION}-${REVISION}_${ARCH}" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +D="$STAGE/$PKG" + +install -d -m 0755 "$D/DEBIAN" "$D/usr/bin" "$D/lib/systemd/system" "$D/etc/active-push" \ + "$D/usr/share/doc/active-push/examples" "$D/usr/share/active-push" +install -m 0755 "$BIN" "$D/usr/bin/active-push" +strip --strip-unneeded "$D/usr/bin/active-push" 2>/dev/null || true +install -m 0644 deploy/active-push.service "$D/lib/systemd/system/active-push.service" +install -m 0640 deploy/environment.example "$D/etc/active-push/environment" +install -m 0644 deploy/nginx-active-push.conf "$D/usr/share/doc/active-push/examples/nginx-active-push.conf" +install -m 0644 deploy/active-push-autodiscover.conf "$D/usr/share/doc/active-push/examples/active-push-autodiscover.conf" +install -m 0644 README.md "$D/usr/share/doc/active-push/README.md" +for f in docs/*.md; do install -m 0644 "$f" "$D/usr/share/doc/active-push/"; done +install -m 0755 tools/zpush-import.py "$D/usr/share/active-push/zpush-import.py" +install -m 0644 LICENSE "$D/usr/share/doc/active-push/copyright" +gzip -9n -c CHANGELOG.md > "$D/usr/share/doc/active-push/changelog.gz" + +SIZE="$(du -sk "$D" | cut -f1)" +cat > "$D/DEBIAN/control" < +Installed-Size: ${SIZE} +Depends: libc6 (>= ${LIBC_MIN}), adduser, ca-certificates +Recommends: nginx | apache2 | rust-proxy +Suggests: dovecot-imapd, postfix, radicale +Conflicts: z-push-common +Description: Exchange ActiveSync server for IMAP/SMTP, CalDAV and CardDAV + ActivePush lets phones and Outlook synchronise mail, calendars, tasks and + contacts over Exchange ActiveSync (2.5-14.1) with standard IMAP/SMTP, + CalDAV and CardDAV servers. It is a Rust rewrite of Z-Push with a built-in + administration panel (devices, remote wipe, policies, live statistics). +EOF +# Z-Push can be installed side by side; drop the Conflicts line if you rely on that. +sed -i '/^Conflicts: z-push-common/d' "$D/DEBIAN/control" + +echo "/etc/active-push/environment" > "$D/DEBIAN/conffiles" + +cat > "$D/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +case "$1" in + configure) + if ! getent group active-push >/dev/null; then addgroup --system active-push >/dev/null; fi + if ! getent passwd active-push >/dev/null; then + adduser --system --ingroup active-push --home /var/lib/active-push --no-create-home \ + --shell /usr/sbin/nologin --gecos "ActivePush" active-push >/dev/null + fi + install -d -o active-push -g active-push -m 0700 /var/lib/active-push + chown root:active-push /etc/active-push /etc/active-push/environment + chmod 0750 /etc/active-push + chmod 0640 /etc/active-push/environment + if [ -d /run/systemd/system ]; then + systemctl daemon-reload >/dev/null 2>&1 || true + if [ -z "$2" ]; then + systemctl enable --now active-push.service >/dev/null 2>&1 || true + echo "" + echo "ActivePush installed and started." + echo " ActiveSync listener : 127.0.0.1:8081 (proxy /Microsoft-Server-ActiveSync and autodiscover here)" + echo " Admin panel : http://127.0.0.1:9091/ (ssh -L 9091:127.0.0.1:9091 server)" + echo " First administrator : sudo -u active-push active-push admin setup-token" + echo " nginx example : /usr/share/doc/active-push/examples/nginx-active-push.conf" + echo " Import Z-Push config: python3 /usr/share/active-push/zpush-import.py /usr/share/z-push > zpush.json" + echo " sudo -u active-push active-push config apply zpush.json" + echo "" + else + systemctl try-restart active-push.service >/dev/null 2>&1 || true + fi + fi + ;; +esac +exit 0 +EOF + +cat > "$D/DEBIAN/prerm" <<'EOF' +#!/bin/sh +set -e +if [ -d /run/systemd/system ] && [ "$1" = remove ]; then + systemctl disable --now active-push.service >/dev/null 2>&1 || true +fi +exit 0 +EOF + +cat > "$D/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = purge ]; then + rm -rf /etc/active-push + # device states, statistics and settings + rm -rf /var/lib/active-push + if getent passwd active-push >/dev/null; then deluser --system active-push >/dev/null 2>&1 || true; fi + if getent group active-push >/dev/null; then delgroup --system active-push >/dev/null 2>&1 || true; fi +fi +if [ -d /run/systemd/system ]; then systemctl daemon-reload >/dev/null 2>&1 || true; fi +exit 0 +EOF +chmod 0755 "$D/DEBIAN/postinst" "$D/DEBIAN/prerm" "$D/DEBIAN/postrm" + +mkdir -p dist +dpkg-deb --root-owner-group -Zxz --build "$D" "dist/$PKG.deb" >/dev/null +sha256sum "dist/$PKG.deb" > "dist/$PKG.deb.sha256" +printf 'Package: dist/%s.deb\n' "$PKG" +dpkg-deb --info "dist/$PKG.deb" | sed -n '1,12p' diff --git a/scripts/check-wbxml-names.py b/scripts/check-wbxml-names.py new file mode 100755 index 0000000..c0f137a --- /dev/null +++ b/scripts/check-wbxml-names.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Verify that every literal "Namespace:Tag" used in the sources exists in the WBXML code pages. + +Element::new() panics on unknown names at runtime; this catches typos at build time. +""" +import pathlib +import re +import sys + +root = pathlib.Path(__file__).resolve().parent.parent +cp = (root / "src/wbxml/codepages.rs").read_text() +namespaces = re.findall(r'"([^"]+)"', re.search(r"NAMESPACES: &\[&str\] = &\[(.*?)\];", cp, re.S).group(1)) +body = cp[cp.index("pub const PAGES"):] +known = set() +for i, chunk in enumerate(re.split(r"\n\s*// \d+ \w+\n", body)[1:]): + for tag in re.findall(r'\(0x[0-9A-Fa-f]+,\s*"([^"]+)"\)', chunk): + known.add(f"{namespaces[i]}:{tag}") + +pattern = re.compile(r'"((?:%s):[A-Za-z0-9]+)"' % "|".join(re.escape(n) for n in namespaces)) +bad = [] +for f in (root / "src").rglob("*.rs"): + if f.name == "codepages.rs": + continue + for n, line in enumerate(f.read_text().splitlines(), 1): + for m in pattern.finditer(line): + if m.group(1) not in known: + bad.append(f"{f.relative_to(root)}:{n}: {m.group(1)}") +if bad: + print("Unknown WBXML tag names:\n " + "\n ".join(bad)) + sys.exit(1) +print(f"WBXML names: OK ({len(known)} known tags)") diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..34e0c43 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,1254 @@ +//! Administration panel: authentication, JSON API, live events (SSE), Prometheus metrics. + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use argon2::password_hash::{ + rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, +}; +use argon2::Argon2; +use axum::body::Body; +use axum::extract::{ConnectInfo, Path, Query, Request, State}; +use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures_util::stream::Stream; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::Row; + +use crate::app::App; +use crate::backend::dav::DavKind; +use crate::db::{self, DeviceField}; +use crate::util::{self, now, sha256_hex}; + +const SESSION_COOKIE: &str = "ap_session"; + +// ------------------------------------------------------------------ static assets + +const INDEX_HTML: &str = include_str!("../web/index.html"); +const APP_JS: &str = include_str!("../web/app.js"); +const STYLE_CSS: &str = include_str!("../web/style.css"); +const FAVICON: &str = include_str!("../web/favicon.svg"); + +fn asset(ct: &'static str, body: &'static str) -> Response { + let mut r = Response::new(Body::from(body)); + r.headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(ct)); + r.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + r +} + +// ------------------------------------------------------------------ password helpers + +pub fn hash_password(pw: &str) -> anyhow::Result { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(pw.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| anyhow::anyhow!("hash: {e}")) +} + +pub fn verify_password(pw: &str, hash: &str) -> bool { + PasswordHash::new(hash) + .map(|h| Argon2::default().verify_password(pw.as_bytes(), &h).is_ok()) + .unwrap_or(false) +} + +pub fn password_ok(pw: &str) -> Result<(), &'static str> { + if pw.chars().count() < 12 { + return Err("Password must have at least 12 characters"); + } + if pw.len() > 256 { + return Err("Password too long"); + } + Ok(()) +} + +fn valid_admin_name(u: &str) -> bool { + (3..=64).contains(&u.len()) + && u.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) +} + +// ------------------------------------------------------------------ sessions + +#[derive(Clone)] +struct AdminSession { + admin_id: i64, + username: String, + csrf: String, +} + +fn cookie_token(h: &HeaderMap) -> Option { + let c = h.get(header::COOKIE)?.to_str().ok()?; + c.split(';').find_map(|kv| { + let (k, v) = kv.trim().split_once('=')?; + (k == SESSION_COOKIE && v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit())) + .then(|| v.to_string()) + }) +} + +async fn load_session(app: &App, h: &HeaderMap) -> Option { + let tok = cookie_token(h)?; + let th = sha256_hex(tok.as_bytes()); + let r = sqlx::query( + "SELECT s.admin_id, s.csrf, a.username FROM admin_sessions s JOIN admins a ON a.id = s.admin_id WHERE s.token_hash = ? AND s.expires_at > ?", + ) + .bind(&th) + .bind(now()) + .fetch_optional(&app.db) + .await + .ok()??; + let ttl = app.settings().panel.session_ttl_minutes.max(5) as i64 * 60; + let _ = + sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = ? WHERE token_hash = ?") + .bind(now()) + .bind(now() + ttl) + .bind(&th) + .execute(&app.db) + .await; + Some(AdminSession { + admin_id: r.get(0), + csrf: r.get(1), + username: r.get(2), + }) +} + +fn is_https(app: &App, h: &HeaderMap) -> bool { + app.boot.admin_tls.is_some() + || h.get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("https")) + .unwrap_or(false) +} + +fn session_cookie(token: &str, secure: bool, max_age: i64) -> HeaderValue { + let s = format!( + "{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{}", + if secure { "; Secure" } else { "" } + ); + HeaderValue::from_str(&s).unwrap() +} + +fn peer_ip(app: &App, peer: SocketAddr, h: &HeaderMap) -> String { + crate::eas::client_ip(peer, h, &app.settings()).to_string() +} + +fn err(status: StatusCode, msg: &str) -> Response { + (status, Json(json!({ "error": msg }))).into_response() +} + +// ------------------------------------------------------------------ middleware + +async fn security_headers( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + req: Request, + next: Next, +) -> Response { + let nets = app.settings().panel.allowed_networks.clone(); + if !nets.is_empty() { + let ip = util::normalize(peer.ip()); + if !util::ip_in_any(&ip, &nets) { + return StatusCode::FORBIDDEN.into_response(); + } + } + let mut r = next.run(req).await; + let h = r.headers_mut(); + h.insert( + "Content-Security-Policy", + HeaderValue::from_static("default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"), + ); + h.insert( + "X-Content-Type-Options", + HeaderValue::from_static("nosniff"), + ); + h.insert("X-Frame-Options", HeaderValue::from_static("DENY")); + h.insert("Referrer-Policy", HeaderValue::from_static("no-referrer")); + h.insert( + "Cross-Origin-Opener-Policy", + HeaderValue::from_static("same-origin"), + ); + h.insert( + "Permissions-Policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + r +} + +/// Requires a valid session; non-GET requests also require the CSRF header. +async fn require_auth(State(app): State>, mut req: Request, next: Next) -> Response { + let Some(sess) = load_session(&app, req.headers()).await else { + return err(StatusCode::UNAUTHORIZED, "not authenticated"); + }; + if req.method() != Method::GET && req.method() != Method::HEAD { + let ok = req + .headers() + .get("x-csrf-token") + .and_then(|v| v.to_str().ok()) + .map(|v| util::ct_eq(v.as_bytes(), sess.csrf.as_bytes())) + .unwrap_or(false); + if !ok { + return err(StatusCode::FORBIDDEN, "CSRF token missing or invalid"); + } + } + req.extensions_mut().insert(sess); + next.run(req).await +} + +// ------------------------------------------------------------------ router + +pub fn router(app: Arc) -> Router { + let api = Router::new() + .route("/api/overview", get(overview)) + .route("/api/timeseries", get(timeseries)) + .route("/api/stats/hourly", get(hourly)) + .route("/api/live", get(live)) + .route("/api/events", get(events)) + .route("/api/devices", get(devices)) + .route("/api/devices/{id}", get(device_detail)) + .route("/api/devices/{id}/{action}", post(device_action)) + .route("/api/users", get(users)) + .route("/api/settings", get(get_settings).put(put_settings)) + .route("/api/test/{what}", post(test_backend)) + .route("/api/logs", get(logs)) + .route("/api/audit", get(audit)) + .route("/api/lockouts", get(lockouts)) + .route("/api/lockouts/unlock", post(unlock)) + .route("/api/password", post(change_password)) + .route("/api/logout", post(logout)) + .route("/api/sessions/revoke-others", post(revoke_others)) + .route_layer(middleware::from_fn_with_state(app.clone(), require_auth)); + Router::new() + .route( + "/", + get(|| async { asset("text/html; charset=utf-8", INDEX_HTML) }), + ) + .route( + "/app.js", + get(|| async { asset("application/javascript; charset=utf-8", APP_JS) }), + ) + .route( + "/style.css", + get(|| async { asset("text/css; charset=utf-8", STYLE_CSS) }), + ) + .route( + "/favicon.svg", + get(|| async { asset("image/svg+xml", FAVICON) }), + ) + .route("/api/session", get(session_info)) + .route("/api/login", post(login)) + .route("/api/setup", post(setup)) + .route("/metrics", get(prometheus)) + .route("/healthz", get(panel_healthz)) + .merge(api) + .layer(middleware::from_fn_with_state( + app.clone(), + security_headers, + )) + .layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)) + .with_state(app) +} + +async fn panel_healthz(State(app): State>) -> Response { + match sqlx::query_scalar::<_, i64>("SELECT 1") + .fetch_one(&app.db) + .await + { + Ok(_) => "ok\n".into_response(), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "database unavailable\n").into_response(), + } +} + +// ------------------------------------------------------------------ auth endpoints + +async fn admin_count(app: &App) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM admins") + .fetch_one(&app.db) + .await + .unwrap_or(0) +} + +async fn session_info(State(app): State>, headers: HeaderMap) -> Response { + let setup = admin_count(&app).await == 0; + match load_session(&app, &headers).await { + Some(s) => Json(json!({ + "authenticated": true, "username": s.username, "csrf": s.csrf, + "version": env!("CARGO_PKG_VERSION"), "setup_required": false, + "server_name": app.settings().general.server_name, + })) + .into_response(), + None => Json(json!({ + "authenticated": false, "setup_required": setup, "version": env!("CARGO_PKG_VERSION"), + })) + .into_response(), + } +} + +#[derive(Deserialize)] +struct LoginReq { + username: String, + password: String, + #[serde(default)] + token: String, +} + +async fn create_session( + app: &App, + admin_id: i64, + ip: &str, + ua: &str, + secure: bool, +) -> anyhow::Result { + let token = util::random_token(32); + let csrf = util::random_token(24); + let ttl = app.settings().panel.session_ttl_minutes.max(5) as i64 * 60; + sqlx::query( + "INSERT INTO admin_sessions (token_hash, admin_id, csrf, created_at, expires_at, last_seen, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(sha256_hex(token.as_bytes())) + .bind(admin_id) + .bind(&csrf) + .bind(now()) + .bind(now() + ttl) + .bind(now()) + .bind(ip) + .bind(util::truncate_bytes(ua, 200)) + .execute(&app.db) + .await?; + sqlx::query("UPDATE admins SET last_login = ? WHERE id = ?") + .bind(now()) + .bind(admin_id) + .execute(&app.db) + .await?; + let mut r = Json(json!({ "ok": true, "csrf": csrf })).into_response(); + r.headers_mut() + .insert(header::SET_COOKIE, session_cookie(&token, secure, ttl)); + Ok(r) +} + +async fn login( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Response { + let ip = peer_ip(&app, peer, &headers); + let key = format!("panel:{ip}"); + if app.auth.locked_until(&key).is_some() { + return err( + StatusCode::TOO_MANY_REQUESTS, + "Too many failed attempts, try again later", + ); + } + let row = sqlx::query("SELECT id, password_hash FROM admins WHERE username = ?") + .bind(&req.username) + .fetch_optional(&app.db) + .await + .ok() + .flatten(); + // constant work even for unknown users + static DUMMY: std::sync::OnceLock = std::sync::OnceLock::new(); + let dummy = + DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").unwrap_or_default()); + let (id, hash) = row + .map(|r| (r.get::(0), r.get::(1))) + .unwrap_or((0, dummy.clone())); + let ok = verify_password(&req.password, &hash) && id > 0; + if !ok { + app.auth.fail(&key, 5, 600, 900); + tracing::warn!(target: "active_push::auth", "panel login failed user={} ip={ip}", req.username); + db::audit(&app.db, &req.username, "panel_login_failed", "", &ip, "").await; + tokio::time::sleep(Duration::from_millis(400)).await; + return err(StatusCode::UNAUTHORIZED, "Invalid username or password"); + } + app.auth.success(&key); + db::audit(&app.db, &req.username, "panel_login", "", &ip, "").await; + let ua = headers + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + create_session(&app, id, &ip, ua, is_https(&app, &headers)) + .await + .unwrap_or_else(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())) +} + +async fn setup( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Response { + let ip = peer_ip(&app, peer, &headers); + let key = format!("setup:{ip}"); + if app.auth.locked_until(&key).is_some() { + return err(StatusCode::TOO_MANY_REQUESTS, "Too many attempts"); + } + if admin_count(&app).await > 0 { + return err(StatusCode::CONFLICT, "Administrator already exists"); + } + let stored: Option<(String, i64)> = + sqlx::query("SELECT token_hash, expires_at FROM setup_token WHERE id = 1") + .fetch_optional(&app.db) + .await + .ok() + .flatten() + .map(|r| (r.get(0), r.get(1))); + let valid = stored + .map(|(h, exp)| { + exp > now() + && util::ct_eq( + h.as_bytes(), + sha256_hex(req.token.trim().as_bytes()).as_bytes(), + ) + }) + .unwrap_or(false); + if !valid { + app.auth.fail(&key, 5, 600, 1800); + return err( + StatusCode::FORBIDDEN, + "Invalid or expired setup token (run: active-push admin setup-token)", + ); + } + if !valid_admin_name(&req.username) { + return err( + StatusCode::BAD_REQUEST, + "Username: 3-64 characters A-Z a-z 0-9 _ . -", + ); + } + if let Err(e) = password_ok(&req.password) { + return err(StatusCode::BAD_REQUEST, e); + } + let Ok(hash) = hash_password(&req.password) else { + return err(StatusCode::INTERNAL_SERVER_ERROR, "hash failed"); + }; + let r = sqlx::query("INSERT INTO admins (username, password_hash, created_at, password_changed_at) VALUES (?, ?, ?, ?) RETURNING id") + .bind(&req.username) + .bind(hash) + .bind(now()) + .bind(now()) + .fetch_one(&app.db) + .await; + let Ok(row) = r else { + return err(StatusCode::CONFLICT, "Could not create administrator"); + }; + let _ = sqlx::query("DELETE FROM setup_token") + .execute(&app.db) + .await; + db::audit( + &app.db, + &req.username, + "admin_created", + "", + &ip, + "setup token", + ) + .await; + let ua = headers + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + create_session(&app, row.get(0), &ip, ua, is_https(&app, &headers)) + .await + .unwrap_or_else(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())) +} + +async fn logout(State(app): State>, headers: HeaderMap) -> Response { + if let Some(t) = cookie_token(&headers) { + let _ = sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?") + .bind(sha256_hex(t.as_bytes())) + .execute(&app.db) + .await; + } + let mut r = Json(json!({"ok": true})).into_response(); + r.headers_mut().insert( + header::SET_COOKIE, + session_cookie("", is_https(&app, &headers), 0), + ); + r +} + +#[derive(Deserialize)] +struct PwChange { + current: String, + new: String, +} + +async fn change_password( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + axum::Extension(s): axum::Extension, + Json(req): Json, +) -> Response { + let hash: Option = sqlx::query_scalar("SELECT password_hash FROM admins WHERE id = ?") + .bind(s.admin_id) + .fetch_optional(&app.db) + .await + .ok() + .flatten(); + if !hash + .map(|h| verify_password(&req.current, &h)) + .unwrap_or(false) + { + return err(StatusCode::FORBIDDEN, "Current password is wrong"); + } + if let Err(e) = password_ok(&req.new) { + return err(StatusCode::BAD_REQUEST, e); + } + let Ok(h) = hash_password(&req.new) else { + return err(StatusCode::INTERNAL_SERVER_ERROR, "hash failed"); + }; + let _ = + sqlx::query("UPDATE admins SET password_hash = ?, password_changed_at = ? WHERE id = ?") + .bind(h) + .bind(now()) + .bind(s.admin_id) + .execute(&app.db) + .await; + // revoke all other sessions + if let Some(t) = cookie_token(&headers) { + let _ = sqlx::query("DELETE FROM admin_sessions WHERE admin_id = ? AND token_hash != ?") + .bind(s.admin_id) + .bind(sha256_hex(t.as_bytes())) + .execute(&app.db) + .await; + } + db::audit( + &app.db, + &s.username, + "password_changed", + "", + &peer_ip(&app, peer, &headers), + "", + ) + .await; + Json(json!({"ok": true})).into_response() +} + +async fn revoke_others( + State(app): State>, + headers: HeaderMap, + axum::Extension(s): axum::Extension, +) -> Response { + if let Some(t) = cookie_token(&headers) { + let _ = sqlx::query("DELETE FROM admin_sessions WHERE admin_id = ? AND token_hash != ?") + .bind(s.admin_id) + .bind(sha256_hex(t.as_bytes())) + .execute(&app.db) + .await; + } + Json(json!({"ok": true})).into_response() +} + +// ------------------------------------------------------------------ data endpoints + +fn rss_bytes() -> u64 { + std::fs::read_to_string("/proc/self/statm") + .ok() + .and_then(|s| { + s.split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()) + }) + .map(|pages| pages * 4096) + .unwrap_or(0) +} + +async fn overview(State(app): State>) -> Response { + let c = &app.metrics.c; + let s = app.settings(); + let dev_total: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT device_id) FROM devices") + .fetch_one(&app.db) + .await + .unwrap_or(0); + let dev_24h: i64 = + sqlx::query_scalar("SELECT COUNT(DISTINCT device_id) FROM devices WHERE last_seen > ?") + .bind(now() - 86400) + .fetch_one(&app.db) + .await + .unwrap_or(0); + let partnerships: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM devices") + .fetch_one(&app.db) + .await + .unwrap_or(0); + let users_total: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT username) FROM devices") + .fetch_one(&app.db) + .await + .unwrap_or(0); + let wipes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM devices WHERE wipe_status IN (2, 4)") + .fetch_one(&app.db) + .await + .unwrap_or(0); + let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM devices WHERE approved = 0") + .fetch_one(&app.db) + .await + .unwrap_or(0); + let mut types: Vec<(String, i64)> = sqlx::query( + "SELECT device_type, COUNT(DISTINCT device_id) FROM devices GROUP BY device_type ORDER BY 2 DESC LIMIT 8", + ) + .fetch_all(&app.db) + .await + .unwrap_or_default() + .into_iter() + .map(|r| (r.get(0), r.get(1))) + .collect(); + types.retain(|(t, _)| !t.is_empty()); + let mut cmds: Vec<(String, crate::metrics::CmdStat)> = + app.metrics.commands().into_iter().collect(); + cmds.sort_by_key(|c| std::cmp::Reverse(c.1.count)); + Json(json!({ + "version": env!("CARGO_PKG_VERSION"), + "server_name": s.general.server_name, + "uptime": app.started.elapsed().as_secs(), + "started": app.started_ts, + "rss": rss_bytes(), + "requests": c.requests.load(Ordering::Relaxed), + "errors": c.errors.load(Ordering::Relaxed), + "auth_failures": c.auth_failures.load(Ordering::Relaxed), + "bytes_in": c.bytes_in.load(Ordering::Relaxed), + "bytes_out": c.bytes_out.load(Ordering::Relaxed), + "items_out": c.items_out.load(Ordering::Relaxed), + "items_in": c.items_in.load(Ordering::Relaxed), + "mails_sent": c.mails_sent.load(Ordering::Relaxed), + "autodiscover": c.autodiscover.load(Ordering::Relaxed), + "active_requests": app.metrics.active().len(), + "pings": app.metrics.ping_count(), + "devices_total": dev_total, + "devices_24h": dev_24h, + "devices_pending": pending, + "wipes_pending": wipes, + "users_total": users_total, + "partnerships_total": partnerships, + "db_bytes": db_size(&app.boot.data_dir, "active-push.db"), + "wal_bytes": db_size(&app.boot.data_dir, "active-push.db-wal"), + "users_active_15m": app.metrics.users_active(900), + "imap_pool": app.pool.size(), + "lockouts": app.auth.lockouts().len(), + "device_types": types, + "commands": cmds, + "backends": { + "imap": format!("{}:{}", s.imap.host, s.imap.port), + "smtp": if s.smtp.method == crate::config::SendMethod::Sendmail { s.smtp.sendmail_path.clone() } else { format!("{}:{}", s.smtp.host, s.smtp.port) }, + "caldav": if s.caldav.enabled { s.caldav.base_url.clone() } else { String::new() }, + "carddav": if s.carddav.enabled { s.carddav.base_url.clone() } else { String::new() }, + }, + "provisioning": s.provisioning.enabled, + })) + .into_response() +} + +#[derive(Deserialize)] +struct MinQ { + minutes: Option, + hours: Option, +} + +async fn timeseries(State(app): State>, Query(q): Query) -> Response { + let n = q.minutes.unwrap_or(60).clamp(5, 1440); + Json(app.metrics.minutes(n)).into_response() +} + +async fn hourly(State(app): State>, Query(q): Query) -> Response { + // flush first so the current hour is visible + app.metrics.flush_hour(&app.db).await; + let hours = q.hours.unwrap_or(168).clamp(1, 24 * 90); + let rows = sqlx::query( + "SELECT hour, requests, errors, auth_failures, bytes_in, bytes_out, items_out, items_in, mails_sent, total_ms, commands FROM stats_hourly WHERE hour >= ? ORDER BY hour", + ) + .bind(now() - hours * 3600) + .fetch_all(&app.db) + .await + .unwrap_or_default(); + let v: Vec = rows + .into_iter() + .map(|r| { + json!({ + "t": r.get::(0), "requests": r.get::(1), "errors": r.get::(2), + "auth_failures": r.get::(3), "bytes_in": r.get::(4), "bytes_out": r.get::(5), + "items_out": r.get::(6), "items_in": r.get::(7), "mails_sent": r.get::(8), + "total_ms": r.get::(9), + "commands": serde_json::from_str::(&r.get::(10)).unwrap_or(Value::Null), + }) + }) + .collect(); + Json(v).into_response() +} + +async fn live(State(app): State>) -> Response { + Json(json!({ + "active": app.metrics.active(), + "pings": app.metrics.pings(), + "recent": app.metrics.recent(200), + "now": now(), + })) + .into_response() +} + +async fn events( + State(app): State>, +) -> Sse>> { + let mut rx = app.metrics.events.subscribe(); + let shutdown = app.shutdown.clone(); + let stream = async_stream(move |tx| async move { + loop { + tokio::select! { + ev = rx.recv() => match ev { + Ok(e) => { + let data = serde_json::to_string(&e).unwrap_or_default(); + if tx.send(Ok(SseEvent::default().event("request").data(data))).await.is_err() { break; } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + }, + _ = shutdown.cancelled() => break, + } + } + }); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) +} + +/// Minimal channel-backed stream helper (avoids an extra dependency). +fn async_stream(f: F) -> impl Stream> +where + F: FnOnce(tokio::sync::mpsc::Sender>) -> Fut, + Fut: std::future::Future + Send + 'static, +{ + let (tx, rx) = tokio::sync::mpsc::channel(64); + tokio::spawn(f(tx)); + futures_util::stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|v| (v, rx)) }) +} + +async fn devices(State(app): State>) -> Response { + let pings = app.metrics.pings(); + match db::device_list(&app.db).await { + Ok(list) => { + let v: Vec = list + .into_iter() + .map(|d| { + let pinging = pings + .iter() + .any(|p| p.device_id == d.device_id && p.user == d.username); + let mut j = serde_json::to_value(&d).unwrap_or_default(); + j["pinging"] = json!(pinging); + j["has_recovery_password"] = json!(!d.recovery_password.is_empty()); + j + }) + .collect(); + Json(v).into_response() + } + Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +async fn device_detail(State(app): State>, Path(id): Path) -> Response { + let Ok(Some(d)) = db::device_by_id(&app.db, id).await else { + return err(StatusCode::NOT_FOUND, "device not found"); + }; + let folders = db::folder_stats(&app.db, id).await.unwrap_or_default(); + let recent: Vec<_> = app + .metrics + .recent(500) + .into_iter() + .filter(|e| e.device_id == d.device_id && e.user == d.username) + .take(50) + .collect(); + let mut j = serde_json::to_value(&d).unwrap_or_default(); + j["folders"] = json!(folders); + j["recent"] = json!(recent); + j["has_recovery_password"] = json!(!d.recovery_password.is_empty()); + j["pinging"] = json!(app + .metrics + .pings() + .iter() + .any(|p| p.device_id == d.device_id && p.user == d.username)); + Json(j).into_response() +} + +#[derive(Deserialize, Default)] +struct ActionBody { + #[serde(default)] + notes: String, +} + +async fn device_action( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + axum::Extension(s): axum::Extension, + Path((id, action)): Path<(i64, String)>, + body: Option>, +) -> Response { + let Ok(Some(d)) = db::device_by_id(&app.db, id).await else { + return err(StatusCode::NOT_FOUND, "device not found"); + }; + let ip = peer_ip(&app, peer, &headers); + let key = format!("{}\u{0}{}", d.device_id, d.username); + let res = match action.as_str() { + "wipe" => { + app.metrics.ping_interrupt(&key); + db::device_request_wipe(&app.db, id, &s.username, false).await + } + "cancel-wipe" => { + db::device_set_field( + &app.db, + id, + DeviceField::WipeStatus, + Some(db::WIPE_NA), + None, + ) + .await + } + "resync" => { + app.metrics.ping_interrupt(&key); + db::device_reset_states(&app.db, id).await + } + "reprovision" => { + app.metrics.ping_interrupt(&key); + db::device_set_field(&app.db, id, DeviceField::PolicyKey, Some(0), None).await + } + "block" => { + app.metrics.ping_interrupt(&key); + db::device_set_field(&app.db, id, DeviceField::Blocked, Some(1), None).await + } + "unblock" => db::device_set_field(&app.db, id, DeviceField::Blocked, Some(0), None).await, + "approve" => db::device_set_field(&app.db, id, DeviceField::Approved, Some(1), None).await, + "remove" => { + app.metrics.ping_interrupt(&key); + db::device_delete(&app.db, id).await + } + "notes" => { + let n = body.map(|b| b.0.notes).unwrap_or_default(); + db::device_set_field( + &app.db, + id, + DeviceField::Notes, + None, + Some(util::truncate_bytes(&n, 2000)), + ) + .await + } + "recovery-password" => { + db::audit( + &app.db, + &s.username, + "device_recovery_password_viewed", + &d.device_id, + &ip, + &d.username, + ) + .await; + return Json(json!({ "password": d.recovery_password })).into_response(); + } + _ => return err(StatusCode::BAD_REQUEST, "unknown action"), + }; + match res { + Ok(()) => { + db::audit( + &app.db, + &s.username, + &format!("device_{action}"), + &d.device_id, + &ip, + &d.username, + ) + .await; + tracing::info!(admin = %s.username, device = %d.device_id, user = %d.username, "device action: {action}"); + Json(json!({"ok": true})).into_response() + } + Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +fn db_size(dir: &std::path::Path, f: &str) -> u64 { + std::fs::metadata(dir.join(f)).map(|m| m.len()).unwrap_or(0) +} + +async fn users(State(app): State>) -> Response { + let rows = sqlx::query( + "SELECT d.username, COUNT(*), MAX(d.last_seen), SUM(d.requests), SUM(CASE WHEN d.blocked = 1 THEN 1 ELSE 0 END), + GROUP_CONCAT(DISTINCT COALESCE(NULLIF(d.friendly_name, ''), NULLIF(d.model, ''), d.device_type)), + (SELECT GROUP_CONCAT(DISTINCT o.username) FROM devices o + WHERE o.device_id IN (SELECT device_id FROM devices WHERE username = d.username) AND o.username != d.username) + FROM devices d GROUP BY d.username ORDER BY MAX(d.last_seen) DESC", + ) + .fetch_all(&app.db) + .await + .unwrap_or_default(); + let s = app.settings(); + let pings = app.metrics.pings(); + let v: Vec = rows + .into_iter() + .map(|r| { + let u: String = r.get(0); + json!({ + "username": u, "devices": r.get::(1), "last_seen": r.get::(2), + "requests": r.get::(3), "blocked_devices": r.get::(4), + "blocked": s.security.blocked_users.iter().any(|b| b.eq_ignore_ascii_case(&u)), + "pings": pings.iter().filter(|p| p.user == u).count(), + "policy": s.policy_for(&u).name, + "device_names": r.get::, _>(5).unwrap_or_default(), + "same_device": r.get::, _>(6).unwrap_or_default(), + }) + }) + .collect(); + Json(v).into_response() +} + +async fn get_settings(State(app): State>) -> Response { + Json(json!({ + "settings": app.settings().redacted(), + "revision": app.settings_rev.load(Ordering::SeqCst), + "timezones": chrono_tz::TZ_VARIANTS.iter().map(|t| t.name()).collect::>(), + })) + .into_response() +} + +async fn put_settings( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + axum::Extension(s): axum::Extension, + Json(v): Json, +) -> Response { + let cur = app.settings(); + let new = match cur.merge_update(v) { + Ok(n) => n, + Err(e) => return err(StatusCode::BAD_REQUEST, &e), + }; + // diff summary for the audit log + let old_v = cur.redacted(); + let new_v = new.redacted(); + let mut changed = Vec::new(); + if let (Some(o), Some(n)) = (old_v.as_object(), new_v.as_object()) { + for (k, nv) in n { + if o.get(k) != Some(nv) { + changed.push(k.clone()); + } + } + } + match app.update_settings(new).await { + Ok(rev) => { + db::audit( + &app.db, + &s.username, + "settings_saved", + &changed.join(","), + &peer_ip(&app, peer, &headers), + &format!("revision {rev}"), + ) + .await; + tracing::info!(admin = %s.username, sections = %changed.join(","), "settings updated (revision {rev})"); + Json(json!({"ok": true, "revision": rev})).into_response() + } + Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +#[derive(Deserialize)] +struct TestReq { + #[serde(default)] + login: String, + #[serde(default)] + password: String, + /// optional unsaved settings to test + settings: Option, +} + +async fn test_backend( + State(app): State>, + Path(what): Path, + Json(req): Json, +) -> Response { + let cur = app.settings(); + let s = match req.settings { + Some(v) => match cur.merge_update(v) { + Ok(s) => Arc::new(s), + Err(e) => return err(StatusCode::BAD_REQUEST, &e), + }, + None => cur, + }; + let res = tokio::time::timeout(Duration::from_secs(30), async { + match what.as_str() { + "imap" => crate::backend::test_imap(&s, &req.login, &req.password).await, + "smtp" => crate::backend::smtp::test(&s.smtp, &req.login, &req.password).await, + "caldav" => { + crate::backend::test_dav(&s, DavKind::Calendar, &req.login, &req.password).await + } + "carddav" => { + crate::backend::test_dav(&s, DavKind::AddressBook, &req.login, &req.password).await + } + _ => Err(anyhow::anyhow!("unknown backend")), + } + }) + .await; + match res { + Ok(Ok(msg)) => Json(json!({"ok": true, "message": msg})).into_response(), + Ok(Err(e)) => Json(json!({"ok": false, "message": format!("{e:#}")})).into_response(), + Err(_) => Json(json!({"ok": false, "message": "timeout"})).into_response(), + } +} + +#[derive(Deserialize)] +struct LogQ { + level: Option, + q: Option, + n: Option, +} + +async fn logs(State(app): State>, Query(q): Query) -> Response { + Json(app.logs.tail( + q.n.unwrap_or(500).min(5000), + q.level.as_deref(), + q.q.as_deref(), + )) + .into_response() +} + +async fn audit(State(app): State>) -> Response { + Json(db::audit_list(&app.db, 500).await.unwrap_or_default()).into_response() +} + +async fn lockouts(State(app): State>) -> Response { + Json( + app.auth + .lockouts() + .into_iter() + .map(|(k, t)| json!({"key": k, "until": t})) + .collect::>(), + ) + .into_response() +} + +#[derive(Deserialize)] +struct UnlockReq { + key: String, +} + +async fn unlock( + State(app): State>, + axum::Extension(s): axum::Extension, + Json(r): Json, +) -> Response { + app.auth.unlock(&r.key); + db::audit(&app.db, &s.username, "lockout_cleared", &r.key, "", "").await; + Json(json!({"ok": true})).into_response() +} + +// ------------------------------------------------------------------ Prometheus + +async fn prometheus(State(app): State>, headers: HeaderMap) -> Response { + let token = app.settings().panel.prometheus_token.clone(); + if token.is_empty() { + return StatusCode::NOT_FOUND.into_response(); + } + let ok = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|t| util::ct_eq(t.trim().as_bytes(), token.as_bytes())) + .unwrap_or(false); + if !ok { + return StatusCode::UNAUTHORIZED.into_response(); + } + let c = &app.metrics.c; + let mut out = String::new(); + let mut m = |name: &str, kind: &str, help: &str, v: u64| { + out.push_str(&format!("# HELP activepush_{name} {help}\n# TYPE activepush_{name} {kind}\nactivepush_{name} {v}\n")); + }; + m( + "requests_total", + "counter", + "ActiveSync requests", + c.requests.load(Ordering::Relaxed), + ); + m( + "errors_total", + "counter", + "Failed requests", + c.errors.load(Ordering::Relaxed), + ); + m( + "auth_failures_total", + "counter", + "Failed logins", + c.auth_failures.load(Ordering::Relaxed), + ); + m( + "bytes_in_total", + "counter", + "Request bytes", + c.bytes_in.load(Ordering::Relaxed), + ); + m( + "bytes_out_total", + "counter", + "Response bytes", + c.bytes_out.load(Ordering::Relaxed), + ); + m( + "items_out_total", + "counter", + "Items sent to devices", + c.items_out.load(Ordering::Relaxed), + ); + m( + "items_in_total", + "counter", + "Items received from devices", + c.items_in.load(Ordering::Relaxed), + ); + m( + "mails_sent_total", + "counter", + "Messages submitted", + c.mails_sent.load(Ordering::Relaxed), + ); + m( + "pings_active", + "gauge", + "Hanging Ping/Sync requests", + app.metrics.ping_count() as u64, + ); + m( + "imap_pool_connections", + "gauge", + "Pooled IMAP connections", + app.pool.size() as u64, + ); + m( + "uptime_seconds", + "gauge", + "Uptime", + app.started.elapsed().as_secs(), + ); + m("resident_memory_bytes", "gauge", "RSS", rss_bytes()); + out.push_str("# HELP activepush_command_total Requests per command\n# TYPE activepush_command_total counter\n"); + for (k, v) in app.metrics.commands() { + out.push_str(&format!( + "activepush_command_total{{command=\"{}\"}} {}\n", + k.replace('"', ""), + v.count + )); + } + let mut r = Response::new(Body::from(out)); + r.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; version=0.0.4"), + ); + r +} + +// ------------------------------------------------------------------ CLI helpers + +pub async fn cli_setup_token(app: &App) -> anyhow::Result { + if admin_count(app).await > 0 { + anyhow::bail!("an administrator already exists; use `admin reset-password` instead"); + } + let token = util::random_token(32); + sqlx::query("INSERT OR REPLACE INTO setup_token (id, token_hash, expires_at) VALUES (1, ?, ?)") + .bind(sha256_hex(token.as_bytes())) + .bind(now() + 3600) + .execute(&app.db) + .await?; + Ok(token) +} + +pub async fn cli_init(app: &App, username: &str, password: &str) -> anyhow::Result<()> { + if admin_count(app).await > 0 { + anyhow::bail!("an administrator already exists"); + } + if !valid_admin_name(username) { + anyhow::bail!("invalid username (3-64 chars: A-Z a-z 0-9 _ . -)"); + } + password_ok(password).map_err(|e| anyhow::anyhow!(e))?; + sqlx::query("INSERT INTO admins (username, password_hash, created_at, password_changed_at) VALUES (?, ?, ?, ?)") + .bind(username) + .bind(hash_password(password)?) + .bind(now()) + .bind(now()) + .execute(&app.db) + .await?; + db::audit(&app.db, username, "admin_created", "", "cli", "").await; + Ok(()) +} + +pub async fn cli_reset_password( + app: &App, + username: Option<&str>, + password: &str, +) -> anyhow::Result { + password_ok(password).map_err(|e| anyhow::anyhow!(e))?; + let user: String = match username { + Some(u) => u.to_string(), + None => sqlx::query_scalar("SELECT username FROM admins ORDER BY id LIMIT 1") + .fetch_optional(&app.db) + .await? + .ok_or_else(|| anyhow::anyhow!("no administrator exists; use `admin init`"))?, + }; + let r = sqlx::query( + "UPDATE admins SET password_hash = ?, password_changed_at = ? WHERE username = ?", + ) + .bind(hash_password(password)?) + .bind(now()) + .bind(&user) + .execute(&app.db) + .await?; + if r.rows_affected() == 0 { + anyhow::bail!("administrator {user} not found"); + } + sqlx::query("DELETE FROM admin_sessions") + .execute(&app.db) + .await?; + db::audit( + &app.db, + &user, + "password_reset", + "", + "cli", + "all sessions revoked", + ) + .await; + Ok(user) +} + +pub async fn cli_logout_all(app: &App) -> anyhow::Result { + Ok(sqlx::query("DELETE FROM admin_sessions") + .execute(&app.db) + .await? + .rows_affected()) +} + +pub async fn cli_status(app: &App) -> anyhow::Result { + let admins: Vec<(String, Option)> = sqlx::query("SELECT username, last_login FROM admins") + .fetch_all(&app.db) + .await? + .into_iter() + .map(|r| (r.get(0), r.get(1))) + .collect(); + let sessions: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM admin_sessions WHERE expires_at > ?") + .bind(now()) + .fetch_one(&app.db) + .await?; + if admins.is_empty() { + return Ok("No administrator configured. Run `active-push admin setup-token` or `active-push admin init --username NAME`.".into()); + } + let mut s = String::new(); + for (u, l) in admins { + s.push_str(&format!( + "admin {u}, last login {}\n", + l.map(util::format_ts).unwrap_or_else(|| "never".into()) + )); + } + s.push_str(&format!("active sessions: {sessions}")); + Ok(s) +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3b67193 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,149 @@ +//! Shared application state. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use arc_swap::ArcSwap; +use rand::RngCore; + +use crate::backend::dav::DavClient; +use crate::backend::{ImapPool, Session}; +use crate::config::Settings; +use crate::db::{self, Db}; +use crate::logging::LogRing; +use crate::metrics::{AuthGuard, Metrics}; + +#[derive(Debug, Clone)] +pub struct Boot { + pub data_dir: PathBuf, + pub eas_listen: SocketAddr, + pub admin_listen: SocketAddr, + pub allow_remote_admin: bool, + pub admin_tls: Option<(String, String)>, + pub eas_tls: Option<(String, String)>, +} + +pub struct App { + pub db: Db, + pub boot: Boot, + settings: ArcSwap, + pub settings_rev: AtomicI64, + pub metrics: Metrics, + pub auth: AuthGuard, + pub pool: Arc, + pub logs: Arc, + pub started: Instant, + pub started_ts: i64, + http: Mutex<(i64, Option, Option)>, + /// Random per-process key for HMACs (credential cache keys). + pub secret: [u8; 32], + loops: Mutex>, + pub shutdown: tokio_util::sync::CancellationToken, + /// Collection ids a device still uses but that do not exist here (e.g. left over from + /// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync. + stale: Mutex>>, +} + +impl App { + pub async fn new(boot: Boot, logs: Arc) -> anyhow::Result> { + let db = db::open(&boot.data_dir).await?; + let (settings, rev) = db::load_settings(&db).await?; + if let Err(e) = settings.validate() { + tracing::warn!("stored settings do not validate ({e}); running with them anyway"); + } + logs.set_cap(settings.logging.ring_size); + let mut secret = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret); + Ok(Arc::new(App { + db, + boot, + settings: ArcSwap::from_pointee(settings), + settings_rev: AtomicI64::new(rev), + metrics: Metrics::new(), + auth: AuthGuard::default(), + pool: Arc::new(ImapPool::new(secret)), + logs, + started: Instant::now(), + started_ts: crate::util::now(), + http: Mutex::new((-1, None, None)), + secret, + loops: Mutex::new(std::collections::HashMap::new()), + shutdown: tokio_util::sync::CancellationToken::new(), + stale: Mutex::new(std::collections::HashMap::new()), + })) + } + + pub fn settings(&self) -> Arc { + self.settings.load_full() + } + + pub async fn update_settings(&self, s: Settings) -> anyhow::Result { + let rev = db::save_settings(&self.db, &s).await?; + self.logs.set_cap(s.logging.ring_size); + self.settings.store(Arc::new(s)); + self.settings_rev.store(rev, Ordering::SeqCst); + // backend endpoints may have changed + self.pool.clear(); + self.auth.clear_cache(); + Ok(rev) + } + + fn http_clients(&self, s: &Settings) -> (Option, Option) { + let rev = self.settings_rev.load(Ordering::SeqCst); + let mut g = self.http.lock().unwrap(); + if g.0 != rev { + let cal = s + .caldav + .enabled + .then(|| DavClient::build_http(s.caldav.verify_tls, s.caldav.timeout_secs).ok()) + .flatten(); + let card = s + .carddav + .enabled + .then(|| DavClient::build_http(s.carddav.verify_tls, s.carddav.timeout_secs).ok()) + .flatten(); + *g = (rev, cal, card); + } + (g.1.clone(), g.2.clone()) + } + + pub fn stale_add(&self, device: i64, collection: &str) { + if collection.is_empty() || collection.len() > 64 { + return; + } + let mut g = self.stale.lock().unwrap(); + let set = g.entry(device).or_default(); + if set.len() < 500 { + set.insert(collection.to_string()); + } + } + + pub fn stale_take(&self, device: i64) -> Vec { + self.stale + .lock() + .unwrap() + .remove(&device) + .map(|s| s.into_iter().collect()) + .unwrap_or_default() + } + + /// Count how often a (device, collection, old sync key) was retried within the last hour. + pub fn loop_hit(&self, key: &str) -> u32 { + let now = crate::util::now(); + let mut g = self.loops.lock().unwrap(); + g.retain(|_, (_, t)| *t > now - 3600); + let e = g.entry(key.to_string()).or_insert((0, now)); + e.0 += 1; + e.1 = now; + e.0 + } + + pub fn session(&self, login: &str, password: &str) -> Session { + let s = self.settings(); + let (cal, card) = self.http_clients(&s); + Session::new(s, self.pool.clone(), cal, card, login, password) + } +} diff --git a/src/autodiscover.rs b/src/autodiscover.rs new file mode 100644 index 0000000..1b724f9 --- /dev/null +++ b/src/autodiscover.rs @@ -0,0 +1,215 @@ +//! Autodiscover for mobile devices (MobileSync schema) - replaces z-push/autodiscover. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{ConnectInfo, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use http_body_util::BodyExt; + +use crate::app::App; +use crate::backend::AuthResult; +use crate::eas::{basic_auth, client_ip, normalize_login, user_allowed}; +use crate::metrics::Event; +use crate::util::{now, xml_escape}; +use crate::xmltree; + +const MOBILESYNC: &str = + "http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006"; + +fn xml_response(status: StatusCode, body: String) -> Response { + let mut r = (status, body).into_response(); + r.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("text/xml; charset=utf-8"), + ); + r +} + +fn error_xml(code: u32, msg: &str) -> String { + format!( + r#" + + + + {code} + {} + + + +"#, + chrono::Utc::now().format("%H:%M:%S%.6f"), + xml_escape(msg) + ) +} + +fn unauthorized() -> Response { + let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response(); + r.headers_mut().insert( + "WWW-Authenticate", + HeaderValue::from_static("Basic realm=\"ActiveSync\""), + ); + r +} + +fn safe_host(h: &str) -> Option<&str> { + (!h.is_empty() + && h.len() < 256 + && h.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))) + .then_some(h) +} + +pub async fn handler( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + method: Method, + headers: HeaderMap, + body: Body, +) -> Response { + let s = app.settings(); + let ip = client_ip(peer, &headers, &s).to_string(); + let started = std::time::Instant::now(); + app.metrics + .c + .autodiscover + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if !s.autodiscover.enabled { + return StatusCode::NOT_FOUND.into_response(); + } + if method != Method::POST && method != Method::GET { + return StatusCode::METHOD_NOT_ALLOWED.into_response(); + } + let body = match http_body_util::Limited::new(body, 64 * 1024) + .collect() + .await + { + Ok(b) => b.to_bytes(), + Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(), + }; + // requested address + schema + let (mut email, schema) = if body.is_empty() { + (String::new(), MOBILESYNC.to_string()) + } else { + match xmltree::parse(&body) { + Ok(x) => ( + x.text_of("EMailAddress").unwrap_or_default(), + x.text_of("AcceptableResponseSchema").unwrap_or_default(), + ), + Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")), + } + }; + if schema != MOBILESYNC { + tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema"); + return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")); + } + + // authentication + let creds = basic_auth(&headers, &s); + let login = match (&creds, s.autodiscover.require_auth) { + (None, true) => return unauthorized(), + (Some((l, _)), _) => l.clone(), + (None, false) => match normalize_login(&email, &s) { + Some(l) => l, + None => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")), + }, + }; + let ip_key = format!("ip:{ip}"); + let user_key = format!("user:{login}"); + if app.auth.locked_until(&ip_key).is_some() || app.auth.locked_until(&user_key).is_some() { + return (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response(); + } + if !user_allowed(&login, &s) { + return unauthorized(); + } + if let Some((l, p)) = &creds { + let key = app.pool.key(l, p); + if !app.auth.is_cached(&key) { + let mut sess = app.session(l, p); + match sess.authenticate().await { + Ok(AuthResult::Ok) => { + app.auth.success(&user_key); + app.auth.cache_ok(key, s.security.auth_cache_secs); + sess.close().await; + } + Ok(AuthResult::Denied) => { + app.metrics.auth_failure(); + if s.security.log_auth_failures { + tracing::warn!(target: "active_push::auth", "authentication failed user={l} ip={ip} service=autodiscover"); + } + let (m, w, lo) = ( + s.security.auth_fail_max, + s.security.auth_fail_window_secs, + s.security.lockout_secs, + ); + app.auth.fail(&ip_key, m * 3, w, lo); + app.auth.fail(&user_key, m, w, lo); + return unauthorized(); + } + Err(e) => { + tracing::error!("autodiscover: backend unavailable: {e:#}"); + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + } + } + } + if email.is_empty() || !email.contains('@') { + email = crate::backend::primary_email(&s, &login); + } + let url = if !s.autodiscover.server_url.is_empty() { + s.autodiscover.server_url.clone() + } else { + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|h| h.to_str().ok()) + .and_then(safe_host) + .unwrap_or("localhost"); + format!("https://{host}/Microsoft-Server-ActiveSync") + }; + let display = email.clone(); + let xml = format!( + r#" + + + en:en + + {} + {} + + + + + MobileSync + {} + {} + + + + +"#, + xml_escape(&display), + xml_escape(&email), + xml_escape(&url), + xml_escape(&url) + ); + tracing::info!(user = %login, ip = %ip, "autodiscover answered"); + app.metrics.record(Event { + ts: now(), + user: login, + device_id: String::new(), + device_type: "Autodiscover".into(), + command: "Autodiscover".into(), + ip, + http: 200, + status: "ok".into(), + ms: started.elapsed().as_millis() as u64, + bytes_in: body.len() as u64, + bytes_out: xml.len() as u64, + items_out: 0, + items_in: 0, + }); + xml_response(StatusCode::OK, xml) +} diff --git a/src/backend/dav.rs b/src/backend/dav.rs new file mode 100644 index 0000000..f356c67 --- /dev/null +++ b/src/backend/dav.rs @@ -0,0 +1,580 @@ +//! Minimal CalDAV / CardDAV client (RFC 4791 / RFC 6352). + +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS}; +use reqwest::{Client, Method, StatusCode}; + +use crate::util::xml_escape; +use crate::xmltree; + +const PATH_SEG: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}') + .add(b'/') + .add(b'%') + .add(b'\\'); + +pub fn encode_segment(s: &str) -> String { + utf8_percent_encode(s, PATH_SEG).to_string() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DavKind { + Calendar, + AddressBook, +} + +#[derive(Debug, Clone)] +pub struct Collection { + pub href: String, + pub display_name: String, + pub ctag: String, + pub sync_token: String, + pub supports_events: bool, + pub supports_todos: bool, +} + +#[derive(Debug, Clone)] +pub struct Resource { + pub href: String, + pub etag: String, + pub data: Option, +} + +#[derive(Clone)] +pub struct DavClient { + http: Client, + base: url::Url, + user: String, + pass: String, +} + +impl DavClient { + pub fn build_http(verify: bool, timeout_secs: u64) -> Result { + let mut b = Client::builder() + .timeout(Duration::from_secs(timeout_secs.max(5))) + .connect_timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::limited(3)) + .user_agent(concat!("ActivePush/", env!("CARGO_PKG_VERSION"))) + .pool_idle_timeout(Duration::from_secs(60)); + if !verify { + b = b.danger_accept_invalid_certs(true); + } + Ok(b.build()?) + } + + pub fn new(http: Client, base_url: &str, user: &str, pass: &str) -> Result { + let base = url::Url::parse(base_url).context("invalid DAV base URL")?; + Ok(DavClient { + http, + base, + user: user.to_string(), + pass: pass.to_string(), + }) + } + + fn url(&self, href: &str) -> Result { + let u = self.base.join(href)?; + // never follow hrefs to other hosts (SSRF protection) + if u.host_str() != self.base.host_str() + || u.port_or_known_default() != self.base.port_or_known_default() + { + bail!("DAV href points to a different host"); + } + Ok(u) + } + + async fn request( + &self, + method: &str, + href: &str, + depth: Option<&str>, + body: Option, + content_type: &str, + extra: &[(&str, String)], + ) -> Result<(StatusCode, reqwest::header::HeaderMap, String)> { + let m = Method::from_bytes(method.as_bytes())?; + let mut rb = self + .http + .request(m, self.url(href)?) + .basic_auth(&self.user, Some(&self.pass)); + if let Some(d) = depth { + rb = rb.header("Depth", d); + } + for (k, v) in extra { + rb = rb.header(*k, v); + } + if let Some(b) = body { + rb = rb.header("Content-Type", content_type).body(b); + } + let resp = rb + .send() + .await + .with_context(|| format!("DAV {method} {href}"))?; + let status = resp.status(); + let headers = resp.headers().clone(); + let text = resp.text().await.unwrap_or_default(); + if status == StatusCode::UNAUTHORIZED { + bail!("DAV authentication failed"); + } + Ok((status, headers, text)) + } + + pub async fn check(&self, home: &str) -> Result<()> { + let body = r#""#; + let (st, _, _) = self + .request( + "PROPFIND", + home, + Some("0"), + Some(body.into()), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if !st.is_success() && st.as_u16() != 207 { + bail!("DAV home {home} returned {st}"); + } + Ok(()) + } + + /// Discover collections below a home path. + pub async fn collections(&self, home: &str, kind: DavKind) -> Result> { + let body = r#" + + +"#; + let (st, _, text) = self + .request( + "PROPFIND", + home, + Some("1"), + Some(body.into()), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if st.as_u16() != 207 { + bail!("PROPFIND {home} returned {st}"); + } + let doc = xmltree::parse(text.as_bytes())?; + let mut out = Vec::new(); + for resp in doc.children_named("response") { + let href = resp.text_of("href").unwrap_or_default(); + let Some(rt) = resp.find("resourcetype") else { + continue; + }; + let is_cal = rt.child("calendar").is_some(); + let is_book = rt.child("addressbook").is_some(); + if (kind == DavKind::Calendar && !is_cal) || (kind == DavKind::AddressBook && !is_book) + { + continue; + } + let (mut ev, mut todo) = (true, true); + if let Some(set) = resp.find("supported-calendar-component-set") { + let comps: Vec = set + .children_named("comp") + .filter_map(|c| c.attr("name").map(|n| n.to_ascii_uppercase())) + .collect(); + if !comps.is_empty() { + ev = comps.iter().any(|c| c == "VEVENT"); + todo = comps.iter().any(|c| c == "VTODO"); + } + } + let name = resp + .text_of("displayname") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| last_segment(&href)); + out.push(Collection { + href: normalize_dir(&href), + display_name: name, + ctag: resp.text_of("getctag").unwrap_or_default(), + sync_token: resp.text_of("sync-token").unwrap_or_default(), + supports_events: ev, + supports_todos: todo, + }); + } + Ok(out) + } + + /// Cheap change marker for a collection (ctag / sync-token / etag list hash fallback). + pub async fn ctag(&self, href: &str) -> Result { + let body = r#""#; + let (st, _, text) = self + .request( + "PROPFIND", + href, + Some("0"), + Some(body.into()), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if st.as_u16() != 207 { + bail!("PROPFIND {href} returned {st}"); + } + let doc = xmltree::parse(text.as_bytes())?; + let tag = doc.text_of("getctag").filter(|s| !s.is_empty()); + let tok = doc.text_of("sync-token").filter(|s| !s.is_empty()); + Ok(tag.or(tok).unwrap_or_default()) + } + + /// List resources (href + etag). For calendars a component filter and optional + /// time-range start (UTC, compact format) are applied server side. + pub async fn list( + &self, + href: &str, + comp: Option<&str>, + since: Option<&str>, + ) -> Result> { + let text = if let Some(comp) = comp { + let range = since + .map(|s| format!("", xml_escape(s))) + .unwrap_or_default(); + let body = format!( + r#" + + + {range} +"# + ); + let (st, _, text) = self + .request( + "REPORT", + href, + Some("1"), + Some(body), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if st.as_u16() != 207 { + bail!("calendar-query {href} returned {st}"); + } + text + } else { + let body = r#""#; + let (st, _, text) = self + .request( + "PROPFIND", + href, + Some("1"), + Some(body.into()), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if st.as_u16() != 207 { + bail!("PROPFIND {href} returned {st}"); + } + text + }; + let doc = xmltree::parse(text.as_bytes())?; + let base = normalize_dir(href); + let mut out = Vec::new(); + for resp in doc.children_named("response") { + let h = resp.text_of("href").unwrap_or_default(); + if normalize_dir(&h) == base || h.ends_with('/') { + continue; + } + if resp + .find("resourcetype") + .map(|r| !r.children.is_empty()) + .unwrap_or(false) + { + continue; + } + let ok = resp.children_named("propstat").any(|p| { + p.text_of("status") + .map(|s| s.contains(" 200")) + .unwrap_or(true) + && p.find("getetag").is_some() + }); + if !ok { + continue; + } + out.push(Resource { + href: h, + etag: resp.text_of("getetag").unwrap_or_default(), + data: None, + }); + } + Ok(out) + } + + /// Fetch several resources at once (calendar-multiget / addressbook-multiget). + pub async fn multiget( + &self, + collection: &str, + hrefs: &[String], + kind: DavKind, + ) -> Result> { + if hrefs.is_empty() { + return Ok(vec![]); + } + let mut hs = String::new(); + for h in hrefs { + hs.push_str(&format!("{}", xml_escape(h))); + } + let body = match kind { + DavKind::Calendar => format!( + r#"{hs}"# + ), + DavKind::AddressBook => format!( + r#"{hs}"# + ), + }; + let (st, _, text) = self + .request( + "REPORT", + collection, + Some("1"), + Some(body), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if st.as_u16() != 207 { + // fallback: individual GETs + let mut out = Vec::new(); + for h in hrefs { + if let Ok(r) = self.get(h).await { + out.push(r); + } + } + return Ok(out); + } + let doc = xmltree::parse(text.as_bytes())?; + let mut out = Vec::new(); + for resp in doc.children_named("response") { + let data = resp + .text_of("calendar-data") + .or_else(|| resp.text_of("address-data")) + .filter(|s| !s.is_empty()); + if data.is_none() { + continue; + } + out.push(Resource { + href: resp.text_of("href").unwrap_or_default(), + etag: resp.text_of("getetag").unwrap_or_default(), + data, + }); + } + Ok(out) + } + + pub async fn get(&self, href: &str) -> Result { + let (st, headers, text) = self.request("GET", href, None, None, "", &[]).await?; + if st == StatusCode::NOT_FOUND { + bail!("not found"); + } + if !st.is_success() { + bail!("GET {href} returned {st}"); + } + Ok(Resource { + href: href.to_string(), + etag: headers + .get("etag") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(), + data: Some(text), + }) + } + + /// PUT a resource. `if_match` = Some(etag) for updates, None for create (If-None-Match: *). + pub async fn put( + &self, + href: &str, + body: String, + content_type: &str, + if_match: Option<&str>, + ) -> Result { + let extra = match if_match { + Some(e) if !e.is_empty() => vec![("If-Match", e.to_string())], + Some(_) => vec![], + None => vec![("If-None-Match", "*".to_string())], + }; + let (st, headers, text) = self + .request("PUT", href, None, Some(body), content_type, &extra) + .await?; + if st == StatusCode::PRECONDITION_FAILED { + bail!("conflict: resource changed on server"); + } + if !st.is_success() { + bail!( + "PUT {href} returned {st}: {}", + crate::util::truncate_bytes(&text, 200) + ); + } + let etag = headers + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + match etag { + Some(e) => Ok(e), + None => Ok(self.get(href).await.map(|r| r.etag).unwrap_or_default()), + } + } + + pub async fn delete(&self, href: &str, if_match: Option<&str>) -> Result<()> { + let extra = if_match + .filter(|e| !e.is_empty()) + .map(|e| vec![("If-Match", e.to_string())]) + .unwrap_or_default(); + let (st, _, _) = self.request("DELETE", href, None, None, "", &extra).await?; + if st.is_success() || st == StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(anyhow!("DELETE {href} returned {st}")) + } + } + + pub async fn make_collection( + &self, + href: &str, + name: &str, + kind: DavKind, + todo: bool, + ) -> Result<()> { + let n = xml_escape(name); + let (method, body) = match kind { + DavKind::Calendar => ( + "MKCALENDAR", + format!( + r#"{n}"#, + if todo { "VTODO" } else { "VEVENT" } + ), + ), + DavKind::AddressBook => ( + "MKCOL", + format!( + r#"{n}"# + ), + ), + }; + let (st, _, text) = self + .request( + method, + href, + None, + Some(body), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if !st.is_success() { + bail!( + "{method} {href} returned {st}: {}", + crate::util::truncate_bytes(&text, 200) + ); + } + Ok(()) + } + + pub async fn rename(&self, href: &str, name: &str) -> Result<()> { + let body = format!( + r#"{}"#, + xml_escape(name) + ); + let (st, _, _) = self + .request( + "PROPPATCH", + href, + None, + Some(body), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if !(st.is_success() || st.as_u16() == 207) { + bail!("PROPPATCH {href} returned {st}"); + } + Ok(()) + } + + /// addressbook-query text match on FN / EMAIL (GAL search). + pub async fn search_cards(&self, href: &str, text: &str) -> Result> { + let t = xml_escape(text); + let body = format!( + r#" + + + + {t} + {t} + {t} + +"# + ); + let (st, _, resp) = self + .request( + "REPORT", + href, + Some("1"), + Some(body), + "application/xml; charset=utf-8", + &[], + ) + .await?; + if st.as_u16() != 207 { + bail!("addressbook-query returned {st}"); + } + let doc = xmltree::parse(resp.as_bytes())?; + let mut out = Vec::new(); + for r in doc.children_named("response") { + if let Some(d) = r.text_of("address-data").filter(|s| !s.is_empty()) { + out.push(Resource { + href: r.text_of("href").unwrap_or_default(), + etag: r.text_of("getetag").unwrap_or_default(), + data: Some(d), + }); + } + } + Ok(out) + } +} + +pub fn normalize_dir(h: &str) -> String { + let d = percent_decode_str(h).decode_utf8_lossy().into_owned(); + if d.ends_with('/') { + d + } else { + format!("{d}/") + } +} + +pub fn last_segment(h: &str) -> String { + let d = percent_decode_str(h.trim_end_matches('/')) + .decode_utf8_lossy() + .into_owned(); + d.rsplit('/').next().unwrap_or("").to_string() +} + +/// Re-encode a decoded collection path for use in URLs. +pub fn encode_path(path: &str) -> String { + path.split('/') + .map(encode_segment) + .collect::>() + .join("/") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn paths() { + assert_eq!(normalize_dir("/a/b%20c"), "/a/b c/"); + assert_eq!(last_segment("/a/b/x%40y.ics"), "x@y.ics"); + assert_eq!(encode_path("/a b/c@d/"), "/a%20b/c@d/"); + } +} diff --git a/src/backend/ical.rs b/src/backend/ical.rs new file mode 100644 index 0000000..eca7bf4 --- /dev/null +++ b/src/backend/ical.rs @@ -0,0 +1,412 @@ +//! Generic iCalendar (RFC 5545) / vCard (RFC 6350, 2.1/3.0 tolerant) content-line model. + +#[derive(Debug, Clone, PartialEq)] +pub struct Property { + pub name: String, + pub params: Vec<(String, String)>, + pub value: String, +} + +impl Property { + pub fn new(name: &str, value: impl Into) -> Self { + Property { + name: name.to_ascii_uppercase(), + params: vec![], + value: value.into(), + } + } + + pub fn text(name: &str, value: &str) -> Self { + Property::new(name, escape_text(value)) + } + + pub fn with_param(mut self, k: &str, v: &str) -> Self { + self.params.push((k.to_ascii_uppercase(), v.to_string())); + self + } + + pub fn param(&self, k: &str) -> Option<&str> { + self.params + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(k)) + .map(|(_, v)| v.as_str()) + } + + /// All TYPE values (handles `TYPE=a,b`, repeated TYPE and vCard 2.1 bare params). + pub fn types(&self) -> Vec { + let mut out = Vec::new(); + for (k, v) in &self.params { + if k.eq_ignore_ascii_case("TYPE") { + out.extend(v.split(',').map(|s| s.trim().to_ascii_lowercase())); + } else if v.is_empty() { + out.push(k.to_ascii_lowercase()); + } + } + out + } + + pub fn text_value(&self) -> String { + unescape_text(&self.value) + } +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Component { + pub name: String, + pub props: Vec, + pub children: Vec, +} + +impl Component { + pub fn new(name: &str) -> Self { + Component { + name: name.to_ascii_uppercase(), + ..Default::default() + } + } + + pub fn prop(&self, name: &str) -> Option<&Property> { + self.props + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + } + + pub fn props_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.props + .iter() + .filter(move |p| p.name.eq_ignore_ascii_case(name)) + } + + pub fn text(&self, name: &str) -> Option { + self.prop(name) + .map(|p| p.text_value()) + .filter(|s| !s.is_empty()) + } + + pub fn remove(&mut self, name: &str) { + self.props.retain(|p| !p.name.eq_ignore_ascii_case(name)); + } + + pub fn set(&mut self, p: Property) { + self.remove(&p.name.clone()); + self.props.push(p); + } + + pub fn add(&mut self, p: Property) { + self.props.push(p); + } + + pub fn child(&self, name: &str) -> Option<&Component> { + self.children + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + } + + pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.children + .iter() + .filter(move |c| c.name.eq_ignore_ascii_case(name)) + } + + pub fn serialize(&self) -> String { + let mut out = String::new(); + self.write(&mut out); + out + } + + fn write(&self, out: &mut String) { + fold_line(out, &format!("BEGIN:{}", self.name)); + for p in &self.props { + let mut line = p.name.clone(); + for (k, v) in &p.params { + line.push(';'); + line.push_str(k); + line.push('='); + if v.contains([':', ';', ',']) && !v.starts_with('"') { + line.push('"'); + line.push_str(&v.replace('"', "'")); + line.push('"'); + } else { + line.push_str(v); + } + } + line.push(':'); + line.push_str(&p.value); + fold_line(out, &line); + } + for c in &self.children { + c.write(out); + } + fold_line(out, &format!("END:{}", self.name)); + } +} + +fn fold_line(out: &mut String, line: &str) { + // fold at 75 octets on char boundaries + let line = line.replace(['\r', '\n'], ""); + let mut count = 0; + for c in line.chars() { + let l = c.len_utf8(); + if count + l > 75 { + out.push_str("\r\n "); + count = 1; + } + out.push(c); + count += l; + } + out.push_str("\r\n"); +} + +/// Parse an iCalendar/vCard stream. Returns the top-level components. +pub fn parse(input: &str) -> Vec { + // unfold + let mut lines: Vec = Vec::new(); + for raw in input.split('\n') { + let raw = raw.strip_suffix('\r').unwrap_or(raw); + if (raw.starts_with(' ') || raw.starts_with('\t')) && !lines.is_empty() { + lines.last_mut().unwrap().push_str(&raw[1..]); + } else if !raw.is_empty() { + lines.push(raw.to_string()); + } + } + // vCard 2.1 quoted-printable soft line breaks + let mut merged: Vec = Vec::new(); + for l in lines { + if let Some(last) = merged.last_mut() { + if last.to_ascii_uppercase().contains("QUOTED-PRINTABLE") && last.ends_with('=') { + last.pop(); + last.push_str(&l); + continue; + } + } + merged.push(l); + } + + let mut stack: Vec = vec![Component::new("ROOT")]; + for line in merged { + let Some(p) = parse_line(&line) else { continue }; + if p.name == "BEGIN" { + if stack.len() > 32 { + break; + } + stack.push(Component::new(&p.value)); + } else if p.name == "END" { + if stack.len() > 1 { + let c = stack.pop().unwrap(); + stack.last_mut().unwrap().children.push(c); + } + } else if let Some(top) = stack.last_mut() { + if top.props.len() < 10_000 { + top.props.push(p); + } + } + } + while stack.len() > 1 { + let c = stack.pop().unwrap(); + stack.last_mut().unwrap().children.push(c); + } + stack.pop().map(|r| r.children).unwrap_or_default() +} + +fn parse_line(line: &str) -> Option { + // find the first ':' not inside quotes + let mut in_q = false; + let mut colon = None; + for (i, c) in line.char_indices() { + match c { + '"' => in_q = !in_q, + ':' if !in_q => { + colon = Some(i); + break; + } + _ => {} + } + } + let colon = colon?; + let head = &line[..colon]; + let mut value = line[colon + 1..].to_string(); + let mut parts = split_unquoted(head, ';').into_iter(); + let mut name = parts.next()?.trim().to_ascii_uppercase(); + // strip vCard group prefix (item1.EMAIL) + if let Some((_, n)) = name.split_once('.') { + name = n.to_string(); + } + let mut params = Vec::new(); + for p in parts { + match p.split_once('=') { + Some((k, v)) => params.push(( + k.trim().to_ascii_uppercase(), + v.trim().trim_matches('"').to_string(), + )), + None => params.push((p.trim().to_ascii_uppercase(), String::new())), + } + } + let qp = params.iter().any(|(k, v)| { + (k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE")) || k == "QUOTED-PRINTABLE" + }); + if qp { + value = decode_qp(&value); + params.retain(|(k, v)| { + !(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE")) + && k != "QUOTED-PRINTABLE" + }); + } + Some(Property { + name, + params, + value, + }) +} + +fn split_unquoted(s: &str, sep: char) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_q = false; + for c in s.chars() { + if c == '"' { + in_q = !in_q; + } + if c == sep && !in_q { + out.push(std::mem::take(&mut cur)); + } else { + cur.push(c); + } + } + out.push(cur); + out +} + +fn decode_qp(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'=' && i + 2 < b.len() + 1 { + if let (Some(h), Some(l)) = (b.get(i + 1), b.get(i + 2)) { + if let Ok(v) = u8::from_str_radix(&format!("{}{}", *h as char, *l as char), 16) { + out.push(v); + i += 3; + continue; + } + } + } + out.push(b[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +pub fn escape_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + ';' => out.push_str("\\;"), + ',' => out.push_str("\\,"), + '\n' => out.push_str("\\n"), + '\r' => {} + _ => out.push(c), + } + } + out +} + +pub fn unescape_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c == '\\' { + match it.next() { + Some('n') | Some('N') => out.push('\n'), + Some(o) => out.push(o), + None => {} + } + } else { + out.push(c); + } + } + out +} + +/// Split a structured value (N, ADR) on unescaped `;`. +pub fn split_structured(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut esc = false; + for c in s.chars() { + if esc { + cur.push('\\'); + cur.push(c); + esc = false; + } else if c == '\\' { + esc = true; + } else if c == ';' { + out.push(unescape_text(&std::mem::take(&mut cur))); + } else { + cur.push(c); + } + } + out.push(unescape_text(&cur)); + out +} + +/// Split a list value (CATEGORIES) on unescaped `,`. +pub fn split_list(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut esc = false; + for c in s.chars() { + if esc { + cur.push('\\'); + cur.push(c); + esc = false; + } else if c == '\\' { + esc = true; + } else if c == ',' { + out.push(unescape_text(&std::mem::take(&mut cur))); + } else { + cur.push(c); + } + } + out.push(unescape_text(&cur)); + out.into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_and_roundtrip() { + let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:abc\r\nSUMMARY:Spotkanie\\, ważne\r\nDESCRIPTION:Linia 1\\nLinia 2 bardzo długa linia, która musi zostać zawinięta bo przekracza 75 bajtów w UTF-8 ąęś\r\nDTSTART;TZID=Europe/Warsaw:20261105T100000\r\nATTENDEE;CN=\"Kowalski, Jan\";PARTSTAT=ACCEPTED:mailto:jan@x.pl\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let c = parse(src); + assert_eq!(c.len(), 1); + let ev = c[0].child("VEVENT").unwrap(); + assert_eq!(ev.text("SUMMARY").unwrap(), "Spotkanie, ważne"); + assert_eq!( + ev.prop("DTSTART").unwrap().param("TZID"), + Some("Europe/Warsaw") + ); + assert_eq!( + ev.prop("ATTENDEE").unwrap().param("CN"), + Some("Kowalski, Jan") + ); + let again = parse(&c[0].serialize()); + assert_eq!(again[0], c[0]); + } + + #[test] + fn vcard21() { + let src = "BEGIN:VCARD\nVERSION:2.1\nitem1.TEL;CELL;PREF:+48 600\nN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Kowalsk=C4=85;Anna;;;\nEND:VCARD\n"; + let c = parse(src); + let tel = c[0].prop("TEL").unwrap(); + assert!(tel.types().contains(&"cell".to_string())); + assert_eq!( + split_structured(&c[0].prop("N").unwrap().value)[0], + "Kowalską" + ); + } +} diff --git a/src/backend/imap.rs b/src/backend/imap.rs new file mode 100644 index 0000000..9cd1ffe --- /dev/null +++ b/src/backend/imap.rs @@ -0,0 +1,1072 @@ +//! Minimal, strict async IMAP4rev1 client (RFC 3501 + UIDPLUS, MOVE, SPECIAL-USE, CONDSTORE). +//! +//! Only the subset needed by the ActiveSync gateway is implemented. Every command runs under +//! a timeout, response size is bounded and literals are length checked. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio_rustls::client::TlsStream; + +use crate::config::{ImapSettings, TlsMode}; +use crate::tls::client_config; + +/// Max size of one response (protects against hostile/broken servers). Messages larger than +/// this cannot be synchronised. +const MAX_RESPONSE: usize = 256 * 1024 * 1024; + +enum Stream { + Plain(TcpStream), + Tls(Box>), +} + +impl AsyncRead for Stream { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + Stream::Plain(s) => std::pin::Pin::new(s).poll_read(cx, buf), + Stream::Tls(s) => std::pin::Pin::new(s.as_mut()).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for Stream { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + match self.get_mut() { + Stream::Plain(s) => std::pin::Pin::new(s).poll_write(cx, buf), + Stream::Tls(s) => std::pin::Pin::new(s.as_mut()).poll_write(cx, buf), + } + } + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + Stream::Plain(s) => std::pin::Pin::new(s).poll_flush(cx), + Stream::Tls(s) => std::pin::Pin::new(s.as_mut()).poll_flush(cx), + } + } + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.get_mut() { + Stream::Plain(s) => std::pin::Pin::new(s).poll_shutdown(cx), + Stream::Tls(s) => std::pin::Pin::new(s.as_mut()).poll_shutdown(cx), + } + } +} + +// ------------------------------------------------------------------ values + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Atom(String), + Str(Vec), + List(Vec), + Nil, +} + +impl Value { + pub fn as_str(&self) -> Option { + match self { + Value::Atom(a) => Some(a.clone()), + Value::Str(s) => Some(String::from_utf8_lossy(s).into_owned()), + _ => None, + } + } + pub fn as_u64(&self) -> Option { + match self { + Value::Atom(a) => a.parse().ok(), + _ => None, + } + } + pub fn as_list(&self) -> &[Value] { + match self { + Value::List(l) => l, + _ => &[], + } + } + pub fn into_bytes(self) -> Vec { + match self { + Value::Str(s) => s, + Value::Atom(a) => a.into_bytes(), + _ => Vec::new(), + } + } +} + +struct Tokenizer<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Tokenizer<'a> { + fn skip_ws(&mut self) { + while self.p < self.b.len() && (self.b[self.p] == b' ') { + self.p += 1; + } + } + + fn at_end(&mut self) -> bool { + self.skip_ws(); + self.p >= self.b.len() || self.b[self.p] == b'\r' || self.b[self.p] == b'\n' + } + + fn value(&mut self) -> Result { + self.skip_ws(); + let c = *self + .b + .get(self.p) + .ok_or_else(|| anyhow!("imap: unexpected end"))?; + match c { + b'(' => { + self.p += 1; + let mut items = Vec::new(); + loop { + self.skip_ws(); + match self.b.get(self.p) { + Some(b')') => { + self.p += 1; + break; + } + None => bail!("imap: unterminated list"), + _ => items.push(self.value()?), + } + } + Ok(Value::List(items)) + } + b'"' => { + self.p += 1; + let mut out = Vec::new(); + loop { + let c = *self + .b + .get(self.p) + .ok_or_else(|| anyhow!("imap: unterminated string"))?; + self.p += 1; + match c { + b'\\' => { + let n = *self + .b + .get(self.p) + .ok_or_else(|| anyhow!("imap: bad escape"))?; + self.p += 1; + out.push(n); + } + b'"' => break, + _ => out.push(c), + } + } + Ok(Value::Str(out)) + } + b'{' => { + let close = self.b[self.p..] + .iter() + .position(|c| *c == b'}') + .ok_or_else(|| anyhow!("imap: bad literal"))?; + let num = + std::str::from_utf8(&self.b[self.p + 1..self.p + close])?.trim_end_matches('+'); + let n: usize = num.parse()?; + self.p += close + 1; + if self.b.get(self.p) == Some(&b'\r') { + self.p += 1; + } + if self.b.get(self.p) == Some(&b'\n') { + self.p += 1; + } + if self.p + n > self.b.len() { + bail!("imap: literal overflow"); + } + let v = self.b[self.p..self.p + n].to_vec(); + self.p += n; + Ok(Value::Str(v)) + } + _ => { + let start = self.p; + let mut depth = 0i32; + while self.p < self.b.len() { + let c = self.b[self.p]; + if c == b'[' { + depth += 1; + } else if c == b']' { + depth -= 1; + } else if depth <= 0 + && (c == b' ' || c == b'(' || c == b')' || c == b'\r' || c == b'\n') + { + break; + } + self.p += 1; + } + let atom = String::from_utf8_lossy(&self.b[start..self.p]).into_owned(); + if atom.eq_ignore_ascii_case("NIL") { + Ok(Value::Nil) + } else { + Ok(Value::Atom(atom)) + } + } + } + } + + fn rest_text(&mut self) -> String { + self.skip_ws(); + let s = String::from_utf8_lossy(&self.b[self.p..]) + .trim_end() + .to_string(); + self.p = self.b.len(); + s + } +} + +#[derive(Debug)] +pub struct Untagged { + pub raw: Vec, +} + +impl Untagged { + /// Tokenize the untagged response (without the leading `* `). + pub fn values(&self) -> Result> { + let mut t = Tokenizer { + b: &self.raw[2..], + p: 0, + }; + let mut out = Vec::new(); + // status responses carry free text; only tokenize the leading code + let first = t.value()?; + let is_status = matches!(&first, Value::Atom(a) if ["OK","NO","BAD","BYE","PREAUTH"].contains(&a.to_ascii_uppercase().as_str())); + out.push(first); + if is_status { + t.skip_ws(); + if t.b.get(t.p) == Some(&b'[') { + let v = t.value()?; + out.push(v); + } + out.push(Value::Str(t.rest_text().into_bytes())); + return Ok(out); + } + while !t.at_end() { + out.push(t.value()?); + } + Ok(out) + } + + pub fn kind(&self) -> String { + // `* 12 FETCH` -> FETCH ; `* LIST` -> LIST + let s = String::from_utf8_lossy(&self.raw[2..self.raw.len().min(64)]).to_string(); + let mut parts = s.split_whitespace(); + let a = parts.next().unwrap_or("").to_ascii_uppercase(); + if a.chars().all(|c| c.is_ascii_digit()) { + parts.next().unwrap_or("").to_ascii_uppercase() + } else { + a + } + } +} + +#[derive(Debug, Clone)] +pub struct Mailbox { + pub name: String, + pub delimiter: Option, + pub attributes: Vec, +} + +impl Mailbox { + pub fn has_attr(&self, a: &str) -> bool { + self.attributes.iter().any(|x| x.eq_ignore_ascii_case(a)) + } +} + +#[derive(Debug, Clone, Default)] +pub struct MailboxStatus { + pub messages: u32, + pub uidnext: u32, + pub uidvalidity: u32, + pub unseen: u32, + pub highestmodseq: u64, +} + +#[derive(Debug, Clone)] +pub struct FlagInfo { + pub uid: u32, + pub flags: Vec, + pub size: u32, +} + +#[derive(Debug, Clone)] +pub struct FetchedMessage { + pub uid: u32, + pub flags: Vec, + pub internal_date: Option>, + pub body: Vec, + pub size: u32, +} + +pub struct ImapConn { + io: BufReader, + tag: u32, + pub caps: Vec, + pub selected: Option, + pub uidvalidity: u32, + timeout: Duration, + pub last_used: std::time::Instant, + pub broken: bool, +} + +impl ImapConn { + pub async fn connect(cfg: &ImapSettings) -> Result { + let timeout = Duration::from_secs(cfg.timeout_secs.max(5)); + let addr = format!("{}:{}", cfg.host, cfg.port); + let tcp = tokio::time::timeout(timeout, TcpStream::connect(&addr)) + .await + .map_err(|_| anyhow!("IMAP connect timeout ({addr})"))? + .with_context(|| format!("IMAP connect {addr}"))?; + let _ = tcp.set_nodelay(true); + let stream = if cfg.security == TlsMode::Tls { + Stream::Tls(Box::new(tls_wrap(tcp, &cfg.host, cfg.verify_tls).await?)) + } else { + Stream::Plain(tcp) + }; + let mut conn = ImapConn { + io: BufReader::with_capacity(64 * 1024, stream), + tag: 0, + caps: Vec::new(), + selected: None, + uidvalidity: 0, + timeout, + last_used: std::time::Instant::now(), + broken: false, + }; + let greeting = conn.read_line_full().await?; + if !greeting.starts_with(b"* OK") && !greeting.starts_with(b"* PREAUTH") { + bail!( + "IMAP greeting rejected: {}", + String::from_utf8_lossy(&greeting).trim() + ); + } + conn.capability().await?; + if cfg.security == TlsMode::StartTls { + if !conn.has_cap("STARTTLS") { + bail!("IMAP server does not offer STARTTLS"); + } + conn.run("STARTTLS").await?; + let inner = conn.io.into_inner(); + let tcp = match inner { + Stream::Plain(t) => t, + Stream::Tls(_) => bail!("already TLS"), + }; + conn.io = BufReader::with_capacity( + 64 * 1024, + Stream::Tls(Box::new(tls_wrap(tcp, &cfg.host, cfg.verify_tls).await?)), + ); + conn.capability().await?; + } + Ok(conn) + } + + pub fn has_cap(&self, c: &str) -> bool { + self.caps.iter().any(|x| x.eq_ignore_ascii_case(c)) + } + + async fn capability(&mut self) -> Result<()> { + let (untagged, _) = self.run("CAPABILITY").await?; + for u in untagged { + if u.kind() == "CAPABILITY" { + self.caps = String::from_utf8_lossy(&u.raw[2..]) + .split_whitespace() + .skip(1) + .map(|s| s.to_ascii_uppercase()) + .collect(); + } + } + Ok(()) + } + + pub async fn login(&mut self, user: &str, pass: &str) -> Result { + let res = if self.has_cap("AUTH=PLAIN") { + let mut raw = Vec::with_capacity(user.len() + pass.len() + 2); + raw.push(0); + raw.extend_from_slice(user.as_bytes()); + raw.push(0); + raw.extend_from_slice(pass.as_bytes()); + let b64 = base64::engine::general_purpose::STANDARD.encode(&raw); + zeroize::Zeroize::zeroize(&mut raw); + if self.has_cap("SASL-IR") { + self.run_secret(&format!("AUTHENTICATE PLAIN {b64}")).await + } else { + self.run_with_continuation("AUTHENTICATE PLAIN", b64.as_bytes()) + .await + } + } else { + if self.has_cap("LOGINDISABLED") { + bail!("IMAP LOGIN disabled and AUTH=PLAIN not offered (TLS required?)"); + } + let cmd = format!("LOGIN {} {}", quote(user), quote(pass)); + self.run_secret(&cmd).await + }; + match res { + Ok(_) => { + // capabilities may change after login + let _ = self.capability().await; + Ok(true) + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("IMAP NO") || msg.contains("IMAP BAD") { + Ok(false) + } else { + Err(e) + } + } + } + } + + pub async fn logout(&mut self) { + let _ = tokio::time::timeout(Duration::from_secs(2), self.run("LOGOUT")).await; + } + + pub async fn noop(&mut self) -> Result<()> { + self.run("NOOP").await.map(|_| ()) + } + + pub async fn list(&mut self) -> Result> { + let cmd = if self.has_cap("SPECIAL-USE") && self.has_cap("LIST-EXTENDED") { + "LIST \"\" \"*\" RETURN (SPECIAL-USE)" + } else { + "LIST \"\" \"*\"" + }; + let (untagged, _) = self.run(cmd).await?; + let mut out = Vec::new(); + for u in untagged { + if u.kind() != "LIST" { + continue; + } + let v = u.values()?; + // LIST (attrs) "delim" name + if v.len() < 4 { + continue; + } + let attrs = v[1] + .as_list() + .iter() + .filter_map(|a| a.as_str()) + .collect::>(); + let delim = v[2].as_str().and_then(|s| s.chars().next()); + let name = match v[3].as_str() { + Some(n) => n, + None => continue, + }; + out.push(Mailbox { + name, + delimiter: delim, + attributes: attrs, + }); + } + Ok(out) + } + + /// Leave the selected mailbox without expunging (UNSELECT, RFC 3691). + pub async fn unselect(&mut self) -> Result<()> { + if self.selected.is_some() { + if self.has_cap("UNSELECT") { + self.run("UNSELECT").await?; + } else { + // EXAMINE of a non-existent mailbox leaves the selected state without CLOSE's expunge + let _ = self.run("EXAMINE \"\"").await; + } + self.selected = None; + } + Ok(()) + } + + pub async fn status(&mut self, mailbox: &str) -> Result { + // STATUS on the selected mailbox may report the (stale) session view + if self.selected.as_deref() == Some(mailbox) { + self.unselect().await?; + } + let items = if self.has_cap("CONDSTORE") { + "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN HIGHESTMODSEQ)" + } else { + "(MESSAGES UIDNEXT UIDVALIDITY UNSEEN)" + }; + let (untagged, _) = self + .run(&format!("STATUS {} {items}", quote(mailbox))) + .await?; + let mut st = MailboxStatus::default(); + for u in untagged { + if u.kind() != "STATUS" { + continue; + } + let v = u.values()?; + if let Some(Value::List(l)) = v.get(2) { + let mut it = l.iter(); + while let (Some(k), Some(val)) = (it.next(), it.next()) { + let n = val.as_u64().unwrap_or(0); + match k.as_str().unwrap_or_default().to_ascii_uppercase().as_str() { + "MESSAGES" => st.messages = n as u32, + "UIDNEXT" => st.uidnext = n as u32, + "UIDVALIDITY" => st.uidvalidity = n as u32, + "UNSEEN" => st.unseen = n as u32, + "HIGHESTMODSEQ" => st.highestmodseq = n, + _ => {} + } + } + } + } + Ok(st) + } + + pub async fn select(&mut self, mailbox: &str) -> Result { + if self.selected.as_deref() == Some(mailbox) { + // refresh the view of the already selected mailbox (new EXISTS/EXPUNGE) + self.run("NOOP").await?; + return Ok(self.uidvalidity); + } + self.selected = None; + let (untagged, _) = self.run(&format!("SELECT {}", quote(mailbox))).await?; + let mut uv = 0; + for u in untagged { + let s = String::from_utf8_lossy(&u.raw); + if let Some(i) = s.find("[UIDVALIDITY ") { + uv = s[i + 13..] + .split(']') + .next() + .and_then(|x| x.trim().parse().ok()) + .unwrap_or(0); + } + } + self.selected = Some(mailbox.to_string()); + self.uidvalidity = uv; + Ok(uv) + } + + pub async fn uid_search(&mut self, criteria: &str) -> Result> { + let (untagged, _) = self.run(&format!("UID SEARCH {criteria}")).await?; + let mut out = Vec::new(); + for u in untagged { + if u.kind() == "SEARCH" { + for v in String::from_utf8_lossy(&u.raw[2..]) + .split_whitespace() + .skip(1) + { + if let Ok(n) = v.parse() { + out.push(n); + } + } + } + } + out.sort_unstable(); + Ok(out) + } + + /// UID SEARCH with a UTF-8 charset and literal argument (for user supplied text). + pub async fn uid_search_text( + &mut self, + key: &str, + text: &str, + extra: &str, + ) -> Result> { + let lit = text.as_bytes(); + let cmd = format!("UID SEARCH CHARSET UTF-8 {extra}{key} {{{}}}", lit.len()); + let (untagged, _) = self.run_with_continuation(&cmd, lit).await?; + let mut out = Vec::new(); + for u in untagged { + if u.kind() == "SEARCH" { + for v in String::from_utf8_lossy(&u.raw[2..]) + .split_whitespace() + .skip(1) + { + if let Ok(n) = v.parse() { + out.push(n); + } + } + } + } + out.sort_unstable(); + Ok(out) + } + + pub async fn uid_fetch_flags(&mut self, set: &str) -> Result> { + let (untagged, _) = self + .run(&format!("UID FETCH {set} (UID FLAGS RFC822.SIZE)")) + .await?; + let mut out = Vec::new(); + for u in untagged { + if u.kind() != "FETCH" { + continue; + } + let v = u.values()?; + let Some(Value::List(items)) = v.get(2) else { + continue; + }; + let mut info = FlagInfo { + uid: 0, + flags: vec![], + size: 0, + }; + let mut it = items.iter(); + while let (Some(k), Some(val)) = (it.next(), it.next()) { + match k.as_str().unwrap_or_default().to_ascii_uppercase().as_str() { + "UID" => info.uid = val.as_u64().unwrap_or(0) as u32, + "RFC822.SIZE" => info.size = val.as_u64().unwrap_or(0) as u32, + "FLAGS" => { + info.flags = val.as_list().iter().filter_map(|f| f.as_str()).collect() + } + _ => {} + } + } + if info.uid > 0 { + out.push(info); + } + } + Ok(out) + } + + /// Fetch full messages (BODY.PEEK[] does not set \Seen). + pub async fn uid_fetch_full(&mut self, set: &str) -> Result> { + self.uid_fetch_section(set, "BODY.PEEK[]").await + } + + /// Fetch only headers (cheap listing for search results). + pub async fn uid_fetch_headers(&mut self, set: &str) -> Result> { + self.uid_fetch_section(set, "BODY.PEEK[HEADER]").await + } + + async fn uid_fetch_section(&mut self, set: &str, section: &str) -> Result> { + let (untagged, _) = self + .run(&format!( + "UID FETCH {set} (UID FLAGS INTERNALDATE RFC822.SIZE {section})" + )) + .await?; + let mut out = Vec::new(); + for u in untagged { + if u.kind() != "FETCH" { + continue; + } + let v = u.values()?; + let Some(Value::List(items)) = v.get(2) else { + continue; + }; + let mut m = FetchedMessage { + uid: 0, + flags: vec![], + internal_date: None, + body: vec![], + size: 0, + }; + let mut it = items.iter().cloned(); + while let (Some(k), Some(val)) = (it.next(), it.next()) { + let key = k.as_str().unwrap_or_default().to_ascii_uppercase(); + match key.as_str() { + "UID" => m.uid = val.as_u64().unwrap_or(0) as u32, + "RFC822.SIZE" => m.size = val.as_u64().unwrap_or(0) as u32, + "FLAGS" => m.flags = val.as_list().iter().filter_map(|f| f.as_str()).collect(), + "INTERNALDATE" => { + m.internal_date = val.as_str().and_then(|s| { + chrono::DateTime::parse_from_str(&s, "%d-%b-%Y %H:%M:%S %z") + .ok() + .map(|d| d.with_timezone(&chrono::Utc)) + }) + } + k if k.starts_with("BODY[") => m.body = val.into_bytes(), + _ => {} + } + } + if m.uid > 0 { + out.push(m); + } + } + Ok(out) + } + + pub async fn uid_store(&mut self, uid: u32, op: &str, flags: &str) -> Result<()> { + self.run(&format!("UID STORE {uid} {op}FLAGS.SILENT ({flags})")) + .await + .map(|_| ()) + } + + /// Move a message; returns the new UID when the server reports COPYUID. + pub async fn uid_move(&mut self, uid: u32, dest: &str) -> Result> { + if self.has_cap("MOVE") { + let (untagged, tagged) = self.run(&format!("UID MOVE {uid} {}", quote(dest))).await?; + let mut all: Vec = untagged + .iter() + .map(|u| String::from_utf8_lossy(&u.raw).into_owned()) + .collect(); + all.push(tagged); + return Ok(all.iter().find_map(|s| parse_copyuid(s))); + } + let (_, tagged) = self.run(&format!("UID COPY {uid} {}", quote(dest))).await?; + let new_uid = parse_copyuid(&tagged); + self.uid_store(uid, "+", "\\Deleted").await?; + self.uid_expunge(uid).await?; + Ok(new_uid) + } + + pub async fn uid_expunge(&mut self, uid: u32) -> Result<()> { + if self.has_cap("UIDPLUS") { + self.run(&format!("UID EXPUNGE {uid}")).await.map(|_| ()) + } else { + self.run("EXPUNGE").await.map(|_| ()) + } + } + + pub async fn append(&mut self, mailbox: &str, flags: &str, data: &[u8]) -> Result> { + let cmd = format!("APPEND {} ({flags}) {{{}}}", quote(mailbox), data.len()); + let (_, tagged) = self.run_with_continuation(&cmd, data).await?; + // [APPENDUID uidvalidity uid] + Ok(tagged.find("[APPENDUID ").and_then(|i| { + tagged[i + 11..] + .split(']') + .next() + .and_then(|s| s.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + })) + } + + pub async fn create(&mut self, mailbox: &str) -> Result<()> { + self.run(&format!("CREATE {}", quote(mailbox))).await?; + let _ = self.run(&format!("SUBSCRIBE {}", quote(mailbox))).await; + Ok(()) + } + + pub async fn rename(&mut self, from: &str, to: &str) -> Result<()> { + if self.selected.as_deref() == Some(from) { + self.selected = None; + } + self.run(&format!("RENAME {} {}", quote(from), quote(to))) + .await?; + let _ = self.run(&format!("UNSUBSCRIBE {}", quote(from))).await; + let _ = self.run(&format!("SUBSCRIBE {}", quote(to))).await; + Ok(()) + } + + pub async fn delete(&mut self, mailbox: &str) -> Result<()> { + if self.selected.as_deref() == Some(mailbox) { + self.run("CLOSE").await.ok(); + self.selected = None; + } + let _ = self.run(&format!("UNSUBSCRIBE {}", quote(mailbox))).await; + self.run(&format!("DELETE {}", quote(mailbox))) + .await + .map(|_| ()) + } + + // ------------------------------------------------------------- plumbing + + async fn send_line(&mut self, line: &[u8]) -> Result<()> { + let s = self.io.get_mut(); + s.write_all(line).await?; + s.write_all(b"\r\n").await?; + s.flush().await?; + Ok(()) + } + + fn next_tag(&mut self) -> String { + self.tag += 1; + format!("A{:04}", self.tag) + } + + pub async fn run(&mut self, cmd: &str) -> Result<(Vec, String)> { + self.run_inner(cmd, None, false).await + } + + async fn run_secret(&mut self, cmd: &str) -> Result<(Vec, String)> { + self.run_inner(cmd, None, true).await + } + + async fn run_with_continuation( + &mut self, + cmd: &str, + payload: &[u8], + ) -> Result<(Vec, String)> { + self.run_inner(cmd, Some(payload), false).await + } + + async fn run_inner( + &mut self, + cmd: &str, + payload: Option<&[u8]>, + secret: bool, + ) -> Result<(Vec, String)> { + if self.broken { + bail!("IMAP connection is broken"); + } + let timeout = self.timeout; + let fut = self.run_raw(cmd, payload); + let res = match tokio::time::timeout(timeout, fut).await { + Ok(r) => r, + Err(_) => { + self.broken = true; + Err(anyhow!("IMAP command timeout")) + } + }; + self.last_used = std::time::Instant::now(); + if let Err(e) = &res { + let m = e.to_string(); + if !(m.contains("IMAP NO") || m.contains("IMAP BAD")) { + self.broken = true; + } + if secret { + return Err(anyhow!("{}", m.split(':').next().unwrap_or("IMAP error"))); + } + } + res + } + + async fn run_raw( + &mut self, + cmd: &str, + payload: Option<&[u8]>, + ) -> Result<(Vec, String)> { + let tag = self.next_tag(); + self.send_line(format!("{tag} {cmd}").as_bytes()).await?; + let mut untagged = Vec::new(); + let mut payload_sent = payload.is_none(); + loop { + let line = self.read_line_full().await?; + if line.starts_with(b"+") { + if let Some(p) = payload.filter(|_| !payload_sent) { + let s = self.io.get_mut(); + s.write_all(p).await?; + s.write_all(b"\r\n").await?; + s.flush().await?; + payload_sent = true; + continue; + } + // unexpected continuation (e.g. SASL challenge): cancel + self.send_line(b"*").await?; + continue; + } + if line.starts_with(b"* ") { + if line.len() > 6 && line[2..].starts_with(b"BYE") { + self.broken = true; + } + untagged.push(Untagged { raw: line }); + continue; + } + if line.starts_with(tag.as_bytes()) { + let rest = String::from_utf8_lossy(&line[tag.len()..]) + .trim() + .to_string(); + let upper = rest.to_ascii_uppercase(); + if upper.starts_with("OK") { + return Ok((untagged, rest)); + } + let verb = cmd.split_whitespace().next().unwrap_or(""); + if upper.starts_with("NO") { + bail!("IMAP NO ({verb}): {}", truncate(&rest, 200)); + } + bail!("IMAP BAD ({verb}): {}", truncate(&rest, 200)); + } + // stray line, ignore + } + } + + /// Read one logical response line including any literal payloads. + async fn read_line_full(&mut self) -> Result> { + let mut buf: Vec = Vec::with_capacity(256); + loop { + let start = buf.len(); + let n = self.io.read_until(b'\n', &mut buf).await?; + if n == 0 { + self.broken = true; + bail!("IMAP connection closed"); + } + if buf.len() > MAX_RESPONSE { + self.broken = true; + bail!("IMAP response too large"); + } + let chunk = &buf[start..]; + // literal at end of line: {123}\r\n + let trimmed = chunk + .strip_suffix(b"\r\n") + .or_else(|| chunk.strip_suffix(b"\n")); + if let Some(t) = trimmed { + if t.ends_with(b"}") { + if let Some(open) = t.iter().rposition(|c| *c == b'{') { + let num = std::str::from_utf8(&t[open + 1..t.len() - 1]) + .unwrap_or("") + .trim_end_matches('+'); + if let Ok(len) = num.parse::() { + if buf.len() + len > MAX_RESPONSE { + self.broken = true; + bail!("IMAP literal too large"); + } + let old = buf.len(); + buf.resize(old + len, 0); + self.io.read_exact(&mut buf[old..]).await?; + continue; + } + } + } + } + return Ok(buf); + } + } +} + +fn truncate(s: &str, n: usize) -> String { + if s.len() <= n { + s.to_string() + } else { + let mut i = n; + while !s.is_char_boundary(i) { + i -= 1; + } + format!("{}…", &s[..i]) + } +} + +fn parse_copyuid(s: &str) -> Option { + let i = s.find("[COPYUID ")?; + let inner = s[i + 9..].split(']').next()?; + let dst = inner.split_whitespace().nth(2)?; + // single uid expected + dst.split(':').next()?.split(',').next()?.parse().ok() +} + +/// Quote an IMAP string (UTF-8 safe for mailbox names already encoded in modified UTF-7). +pub fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + if c == '"' || c == '\\' { + out.push('\\'); + } + if c == '\r' || c == '\n' { + continue; + } + out.push(c); + } + out.push('"'); + out +} + +async fn tls_wrap(tcp: TcpStream, host: &str, verify: bool) -> Result> { + let cfg: Arc = client_config(verify); + let connector = tokio_rustls::TlsConnector::from(cfg); + let name = rustls::pki_types::ServerName::try_from(host.to_string()) + .map_err(|_| anyhow!("invalid TLS server name {host}"))?; + connector.connect(name, tcp).await.context("TLS handshake") +} + +// ------------------------------------------------------------- modified UTF-7 + +/// Decode an IMAP modified UTF-7 mailbox name (RFC 3501 5.1.3). +pub fn utf7_decode(s: &str) -> String { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '&' { + out.push(c); + continue; + } + let mut b64 = String::new(); + for d in chars.by_ref() { + if d == '-' { + break; + } + b64.push(if d == ',' { '/' } else { d }); + } + if b64.is_empty() { + out.push('&'); + continue; + } + let bytes = base64::engine::general_purpose::STANDARD_NO_PAD + .decode(b64.trim_end_matches('=')) + .unwrap_or_default(); + let units: Vec = bytes + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + out.push_str(&String::from_utf16_lossy(&units)); + } + out +} + +pub fn utf7_encode(s: &str) -> String { + let mut out = String::new(); + let mut pending: Vec = Vec::new(); + let flush = |pending: &mut Vec, out: &mut String| { + if pending.is_empty() { + return; + } + let bytes: Vec = pending.iter().flat_map(|u| u.to_be_bytes()).collect(); + let enc = base64::engine::general_purpose::STANDARD_NO_PAD + .encode(bytes) + .replace('/', ","); + out.push('&'); + out.push_str(&enc); + out.push('-'); + pending.clear(); + }; + for c in s.chars() { + if (' '..='~').contains(&c) { + flush(&mut pending, &mut out); + if c == '&' { + out.push_str("&-"); + } else { + out.push(c); + } + } else { + let mut buf = [0u16; 2]; + pending.extend_from_slice(c.encode_utf16(&mut buf)); + } + } + flush(&mut pending, &mut out); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn utf7_roundtrip() { + for s in ["INBOX", "Wysłane", "Kosz & śmieci", "日本語", "a&b"] { + assert_eq!(utf7_decode(&utf7_encode(s)), s); + } + assert_eq!(utf7_decode("Wys&AUI-ane"), "Wysłane"); + assert_eq!(utf7_encode("Wysłane"), "Wys&AUI-ane"); + } + + #[test] + fn tokenize_fetch() { + let raw = + b"* 12 FETCH (UID 55 FLAGS (\\Seen $Forwarded) RFC822.SIZE 10 BODY[] {5}\r\nhello)\r\n" + .to_vec(); + let u = Untagged { raw }; + assert_eq!(u.kind(), "FETCH"); + let v = u.values().unwrap(); + let items = v[2].as_list(); + assert_eq!(items[1].as_u64(), Some(55)); + assert_eq!(items[7].clone().into_bytes(), b"hello"); + } + + #[test] + fn tokenize_list() { + let u = Untagged { + raw: b"* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Items\"\r\n".to_vec(), + }; + let v = u.values().unwrap(); + assert_eq!(v[3].as_str().unwrap(), "Sent Items"); + assert_eq!(v[2].as_str().unwrap(), "/"); + } + + #[test] + fn copyuid() { + assert_eq!(parse_copyuid("* OK [COPYUID 123 4 99] Moved"), Some(99)); + } +} diff --git a/src/backend/mime.rs b/src/backend/mime.rs new file mode 100644 index 0000000..a24d8cd --- /dev/null +++ b/src/backend/mime.rs @@ -0,0 +1,247 @@ +//! Small MIME builder used for SmartReply / SmartForward composition. + +use base64::Engine; +use rand::Rng; + +pub struct Attachment { + pub name: String, + pub content_type: String, + pub data: Vec, + pub content_id: Option, + pub inline: bool, +} + +fn boundary() -> String { + let n: u64 = rand::thread_rng().gen(); + format!("=_ap_{n:016x}") +} + +fn b64_lines(data: &[u8]) -> String { + let enc = base64::engine::general_purpose::STANDARD.encode(data); + let mut out = String::with_capacity(enc.len() + enc.len() / 76 * 2 + 2); + for chunk in enc.as_bytes().chunks(76) { + out.push_str(std::str::from_utf8(chunk).unwrap_or("")); + out.push_str("\r\n"); + } + out +} + +/// RFC 2047 encoded-word for header values / filenames when needed. +pub fn encode_word(s: &str) -> String { + if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) && !s.contains("=?") { + return s.to_string(); + } + format!( + "=?UTF-8?B?{}?=", + base64::engine::general_purpose::STANDARD.encode(s.as_bytes()) + ) +} + +fn quote_param(s: &str) -> String { + let e = encode_word(s); + format!("\"{}\"", e.replace(['"', '\\', '\r', '\n'], "_")) +} + +/// Build a complete message from header lines (already folded/encoded), bodies and attachments. +pub fn build( + headers: &[(String, String)], + text: Option<&str>, + html: Option<&str>, + atts: &[Attachment], +) -> Vec { + let mut out = String::new(); + for (k, v) in headers { + let v = v.replace(['\r', '\n'], " "); + out.push_str(&format!("{k}: {v}\r\n")); + } + out.push_str("MIME-Version: 1.0\r\n"); + + let text_part = |t: &str| { + format!( + "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}", + b64_lines(t.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes()) + ) + }; + let html_part = |h: &str| { + format!( + "Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}", + b64_lines(h.as_bytes()) + ) + }; + let body = match (text, html) { + (Some(t), Some(h)) => { + let b = boundary(); + format!( + "Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n--{b}\r\n{}--{b}\r\n{}--{b}--\r\n", + text_part(t), + html_part(h) + ) + } + (Some(t), None) => text_part(t), + (None, Some(h)) => html_part(h), + (None, None) => text_part(""), + }; + if atts.is_empty() { + out.push_str(&body); + return out.into_bytes(); + } + let b = boundary(); + out.push_str(&format!( + "Content-Type: multipart/mixed; boundary=\"{b}\"\r\n\r\nThis is a multi-part message in MIME format.\r\n--{b}\r\n{body}" + )); + for a in atts { + let ct = if a.content_type.contains('/') { + a.content_type.clone() + } else { + "application/octet-stream".into() + }; + out.push_str(&format!("\r\n--{b}\r\n")); + if ct.eq_ignore_ascii_case("message/rfc822") { + out.push_str(&format!( + "Content-Type: message/rfc822; name={}\r\nContent-Disposition: attachment; filename={}\r\n\r\n", + quote_param(&a.name), + quote_param(&a.name) + )); + out.push_str(&String::from_utf8_lossy(&a.data)); + continue; + } + out.push_str(&format!( + "Content-Type: {ct}; name={}\r\n", + quote_param(&a.name) + )); + out.push_str("Content-Transfer-Encoding: base64\r\n"); + if let Some(cid) = &a.content_id { + out.push_str(&format!( + "Content-ID: <{}>\r\n", + cid.trim_matches(['<', '>']) + )); + } + out.push_str(&format!( + "Content-Disposition: {}; filename={}\r\n\r\n", + if a.inline { "inline" } else { "attachment" }, + quote_param(&a.name) + )); + out.push_str(&b64_lines(&a.data)); + } + out.push_str(&format!("--{b}--\r\n")); + out.into_bytes() +} + +/// Split a raw message into (header block, body) at the first empty line. +pub fn split_headers(raw: &[u8]) -> (&[u8], &[u8]) { + for (i, w) in raw.windows(4).enumerate() { + if w == b"\r\n\r\n" { + return (&raw[..i + 2], &raw[i + 4..]); + } + } + for (i, w) in raw.windows(2).enumerate() { + if w == b"\n\n" { + return (&raw[..i + 1], &raw[i + 2..]); + } + } + (raw, &[]) +} + +/// Unfolded raw header lines (name, value). +pub fn header_lines(block: &[u8]) -> Vec<(String, String)> { + let text = String::from_utf8_lossy(block); + let mut out: Vec<(String, String)> = Vec::new(); + for line in text.split('\n') { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + if line.starts_with([' ', '\t']) { + if let Some(last) = out.last_mut() { + last.1.push(' '); + last.1.push_str(line.trim()); + } + continue; + } + if let Some((k, v)) = line.split_once(':') { + out.push((k.trim().to_string(), v.trim().to_string())); + } + } + out +} + +/// Remove headers (case-insensitive) from a raw message, keeping the body untouched. +pub fn strip_headers(raw: &[u8], names: &[&str]) -> Vec { + let (h, body) = split_headers(raw); + let text = String::from_utf8_lossy(h); + let mut out = String::with_capacity(raw.len()); + let mut skipping = false; + for line in text.split_inclusive('\n') { + if line.starts_with([' ', '\t']) { + if !skipping { + out.push_str(line); + } + continue; + } + let name = line.split(':').next().unwrap_or("").trim(); + skipping = names.iter().any(|n| n.eq_ignore_ascii_case(name)); + if !skipping { + out.push_str(line); + } + } + let mut v = out.into_bytes(); + if !v.ends_with(b"\n") { + v.extend_from_slice(b"\r\n"); + } + v.extend_from_slice(b"\r\n"); + v.extend_from_slice(body); + v +} + +/// Prepend header lines to a raw message. +pub fn prepend_headers(raw: &[u8], headers: &[(String, String)]) -> Vec { + let mut v = Vec::with_capacity(raw.len() + 256); + for (k, val) in headers { + v.extend_from_slice(format!("{k}: {}\r\n", val.replace(['\r', '\n'], " ")).as_bytes()); + } + v.extend_from_slice(raw); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_and_parse() { + let raw = build( + &[ + ("Subject".into(), encode_word("Zażółć")), + ("To".into(), "a@b.pl".into()), + ], + Some("tekst"), + Some("

tekst

"), + &[Attachment { + name: "plik ą.txt".into(), + content_type: "text/plain".into(), + data: b"abc".to_vec(), + content_id: None, + inline: false, + }], + ); + let m = mail_parser::MessageParser::default().parse(&raw).unwrap(); + assert_eq!(m.subject(), Some("Zażółć")); + assert_eq!(m.body_text(0).unwrap(), "tekst"); + assert_eq!(m.attachment_count(), 1); + use mail_parser::MimeHeaders; + assert_eq!( + m.attachment(0).unwrap().attachment_name(), + Some("plik ą.txt") + ); + } + + #[test] + fn strip() { + let raw = b"From: a\r\nBcc: x@y,\r\n z@w\r\nSubject: s\r\n\r\nbody"; + let s = strip_headers(raw, &["bcc"]); + assert_eq!( + String::from_utf8(s).unwrap(), + "From: a\r\nSubject: s\r\n\r\nbody" + ); + } +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..884f6db --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,1174 @@ +//! Combined backend: IMAP (mail) + CalDAV (calendar, tasks) + CardDAV (contacts) + SMTP. +//! Equivalent of Z-Push `BackendCombined` with the imap/caldav/carddav backends. + +pub mod dav; +pub mod ical; +pub mod imap; +pub mod mime; +pub mod smtp; +pub mod tz; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use zeroize::Zeroizing; + +use crate::config::{expand_user_template, Settings}; +use dav::{DavClient, DavKind}; +use imap::ImapConn; + +// ------------------------------------------------------------------ folder model + +pub const FT_GENERIC: u8 = 1; +pub const FT_INBOX: u8 = 2; +pub const FT_DRAFTS: u8 = 3; +pub const FT_TRASH: u8 = 4; +pub const FT_SENT: u8 = 5; +pub const FT_OUTBOX: u8 = 6; +pub const FT_TASKS: u8 = 7; +pub const FT_CALENDAR: u8 = 8; +pub const FT_CONTACTS: u8 = 9; +pub const FT_USER_MAIL: u8 = 12; +pub const FT_USER_CALENDAR: u8 = 13; +pub const FT_USER_CONTACTS: u8 = 14; +pub const FT_USER_TASKS: u8 = 15; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Kind { + Mail, + Calendar, + Tasks, + Contacts, +} + +impl Kind { + pub fn from_backend_id(id: &str) -> Option { + match id.get(..2)? { + "m:" => Some(Kind::Mail), + "c:" => Some(Kind::Calendar), + "t:" => Some(Kind::Tasks), + "a:" => Some(Kind::Contacts), + _ => None, + } + } + pub fn class(&self) -> &'static str { + match self { + Kind::Mail => "Email", + Kind::Calendar => "Calendar", + Kind::Tasks => "Tasks", + Kind::Contacts => "Contacts", + } + } + pub fn from_type(t: u8) -> Kind { + match t { + FT_TASKS | FT_USER_TASKS => Kind::Tasks, + FT_CALENDAR | FT_USER_CALENDAR => Kind::Calendar, + FT_CONTACTS | FT_USER_CONTACTS => Kind::Contacts, + _ => Kind::Mail, + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct BFolder { + pub id: String, + pub parent: Option, + pub name: String, + pub ftype: u8, +} + +impl BFolder { + pub fn kind(&self) -> Kind { + Kind::from_backend_id(&self.id).unwrap_or(Kind::Mail) + } + /// Backend specific path (IMAP mailbox or DAV collection path). + pub fn path(&self) -> &str { + &self.id[2..] + } +} + +#[derive(Debug, Clone, Default)] +pub struct Listing { + /// Opaque validity marker (IMAP UIDVALIDITY). A change invalidates the state. + pub meta: String, + /// item id -> fingerprint + pub items: Vec<(String, String)>, +} + +pub enum RawItem { + Mail(imap::FetchedMessage), + Dav { + id: String, + etag: String, + data: String, + }, +} + +// ------------------------------------------------------------------ IMAP pool + +type PoolKey = [u8; 32]; + +pub struct ImapPool { + inner: Mutex>>, + hmac_key: [u8; 32], +} + +impl ImapPool { + pub fn new(hmac_key: [u8; 32]) -> Self { + ImapPool { + inner: Mutex::new(HashMap::new()), + hmac_key, + } + } + + pub fn key(&self, login: &str, pass: &str) -> PoolKey { + let mut mac = as Mac>::new_from_slice(&self.hmac_key).expect("hmac"); + mac.update(login.as_bytes()); + mac.update(&[0]); + mac.update(pass.as_bytes()); + mac.finalize().into_bytes().into() + } + + pub fn take(&self, key: &PoolKey, idle: Duration) -> Option { + let mut g = self.inner.lock().unwrap(); + let v = g.get_mut(key)?; + while let Some(c) = v.pop() { + if !c.broken && c.last_used.elapsed() < idle { + return Some(c); + } + } + None + } + + pub fn put(&self, key: PoolKey, conn: ImapConn, max: usize) { + if conn.broken { + return; + } + let mut g = self.inner.lock().unwrap(); + let v = g.entry(key).or_default(); + if v.len() < max { + v.push(conn); + } + } + + pub fn clear(&self) { + self.inner.lock().unwrap().clear(); + } + + pub fn evict_idle(&self, idle: Duration) -> usize { + let mut g = self.inner.lock().unwrap(); + let mut n = 0; + g.retain(|_, v| { + v.retain(|c| c.last_used.elapsed() < idle && !c.broken); + n += v.len(); + !v.is_empty() + }); + n + } + + pub fn size(&self) -> usize { + self.inner.lock().unwrap().values().map(|v| v.len()).sum() + } +} + +// ------------------------------------------------------------------ session + +pub struct Session { + pub settings: Arc, + pub login: String, + password: Zeroizing, + pub email: String, + pool: Arc, + pool_key: PoolKey, + imap: Option, + http_cal: Option, + http_card: Option, + folder_cache: Option>, +} + +impl Drop for Session { + fn drop(&mut self) { + if let Some(c) = self.imap.take() { + self.pool + .put(self.pool_key, c, self.settings.imap.pool_max_per_user); + } + } +} + +pub enum AuthResult { + Ok, + Denied, +} + +impl Session { + pub fn new( + settings: Arc, + pool: Arc, + http_cal: Option, + http_card: Option, + login: &str, + password: &str, + ) -> Self { + let email = primary_email(&settings, login); + let pool_key = pool.key(login, password); + Session { + settings, + login: login.to_string(), + password: Zeroizing::new(password.to_string()), + email, + pool, + pool_key, + imap: None, + http_cal, + http_card, + folder_cache: None, + } + } + + pub fn password(&self) -> &str { + &self.password + } + + /// Authenticate against IMAP (or reuse a pooled authenticated connection). + pub async fn authenticate(&mut self) -> Result { + let idle = Duration::from_secs(self.settings.imap.pool_idle_secs); + if let Some(c) = self.pool.take(&self.pool_key, idle) { + self.imap = Some(c); + return Ok(AuthResult::Ok); + } + let mut c = ImapConn::connect(&self.settings.imap).await?; + if c.login(&self.login, &self.password).await? { + self.imap = Some(c); + Ok(AuthResult::Ok) + } else { + c.logout().await; + Ok(AuthResult::Denied) + } + } + + pub async fn imap(&mut self) -> Result<&mut ImapConn> { + // validate an existing (possibly pooled, long idle) connection + if let Some(c) = self.imap.as_mut() { + if c.broken + || (c.last_used.elapsed() > Duration::from_secs(30) && c.noop().await.is_err()) + { + self.imap = None; + } + } + if self.imap.is_none() { + let idle = Duration::from_secs(self.settings.imap.pool_idle_secs); + let mut pooled = self.pool.take(&self.pool_key, idle); + if let Some(c) = pooled.as_mut() { + if c.last_used.elapsed() > Duration::from_secs(30) && c.noop().await.is_err() { + pooled = None; + } + } + let conn = match pooled { + Some(c) => c, + None => { + let mut c = ImapConn::connect(&self.settings.imap).await?; + if !c.login(&self.login, &self.password).await? { + bail!("IMAP authentication failed"); + } + c + } + }; + self.imap = Some(conn); + } + Ok(self.imap.as_mut().expect("connection present")) + } + + fn dav(&self, kind: DavKind) -> Result> { + let s = &self.settings; + let (enabled, base, path, http) = match kind { + DavKind::Calendar => ( + s.caldav.enabled, + &s.caldav.base_url, + &s.caldav.path, + &self.http_cal, + ), + DavKind::AddressBook => ( + s.carddav.enabled, + &s.carddav.base_url, + &s.carddav.path, + &self.http_card, + ), + }; + if !enabled { + return Ok(None); + } + let http = http + .clone() + .ok_or_else(|| anyhow!("DAV client not configured"))?; + let client = DavClient::new(http, base, &self.login, &self.password)?; + let home = dav::encode_path(&expand_user_template( + path, + &self.login, + &s.general.default_domain, + )); + Ok(Some((client, home))) + } + + pub fn caldav(&self) -> Result> { + self.dav(DavKind::Calendar) + } + + pub fn carddav(&self) -> Result> { + self.dav(DavKind::AddressBook) + } + + fn dav_for(&self, kind: Kind) -> Result { + let k = if kind == Kind::Contacts { + DavKind::AddressBook + } else { + DavKind::Calendar + }; + self.dav(k)? + .map(|(c, _)| c) + .ok_or_else(|| anyhow!("{kind:?} backend disabled")) + } + + // -------------------------------------------------------------- hierarchy + + pub async fn folders(&mut self) -> Result> { + if let Some(f) = &self.folder_cache { + return Ok(f.clone()); + } + let mut out = self.mail_folders().await?; + let s = self.settings.clone(); + if let Some((c, home)) = self.caldav()? { + match c.collections(&home, DavKind::Calendar).await { + Ok(cols) => { + let cols = filter_cols(cols, &s.caldav.include_regex, &s.caldav.exclude_regex); + let def = + pick_default(&cols, &s.caldav.default_collection, |c| c.supports_events); + let def_t = + pick_default(&cols, &s.caldav.default_collection, |c| c.supports_todos); + for (i, col) in cols.iter().enumerate() { + if col.supports_events { + out.push(BFolder { + id: format!("c:{}", col.href), + parent: None, + name: col.display_name.clone(), + ftype: if Some(i) == def { + FT_CALENDAR + } else { + FT_USER_CALENDAR + }, + }); + } + if col.supports_todos { + let name = if col.supports_events { + format!("{} (tasks)", col.display_name) + } else { + col.display_name.clone() + }; + out.push(BFolder { + id: format!("t:{}", col.href), + parent: None, + name, + ftype: if Some(i) == def_t { + FT_TASKS + } else { + FT_USER_TASKS + }, + }); + } + } + } + Err(e) => tracing::warn!(user = %self.login, "CalDAV discovery failed: {e:#}"), + } + } + if let Some((c, home)) = self.carddav()? { + match c.collections(&home, DavKind::AddressBook).await { + Ok(cols) => { + let cols = + filter_cols(cols, &s.carddav.include_regex, &s.carddav.exclude_regex); + let gal = s.carddav.gal_path.trim_end_matches('/').to_string(); + let cols: Vec<_> = cols + .into_iter() + .filter(|c| { + gal.is_empty() + || c.href.trim_end_matches('/') + != expand_user_template( + &gal, + &self.login, + &s.general.default_domain, + ) + }) + .collect(); + let def = pick_default(&cols, &s.carddav.default_collection, |_| true); + for (i, col) in cols.iter().enumerate() { + out.push(BFolder { + id: format!("a:{}", col.href), + parent: None, + name: col.display_name.clone(), + ftype: if Some(i) == def { + FT_CONTACTS + } else { + FT_USER_CONTACTS + }, + }); + } + } + Err(e) => tracing::warn!(user = %self.login, "CardDAV discovery failed: {e:#}"), + } + } + self.folder_cache = Some(out.clone()); + Ok(out) + } + + pub fn invalidate_folders(&mut self) { + self.folder_cache = None; + } + + async fn mail_folders(&mut self) -> Result> { + let s = self.settings.clone(); + let cfg = &s.imap; + let boxes = self.imap().await?.list().await?; + let prefix = cfg.folder_prefix.clone(); + let names: std::collections::HashSet = + boxes.iter().map(|b| b.name.clone()).collect(); + let excluded = |decoded: &str| { + cfg.excluded_folders.iter().any(|p| { + let p = p.trim(); + if let Some(pre) = p.strip_suffix('*') { + decoded.starts_with(pre) + } else { + !p.is_empty() && decoded.eq_ignore_ascii_case(p) + } + }) + }; + let mut used: HashMap = HashMap::new(); + let mut out = Vec::new(); + // INBOX first so it gets type 2 + let mut boxes = boxes; + boxes.sort_by_key(|b| { + if b.name.eq_ignore_ascii_case("INBOX") { + 0 + } else { + 1 + } + }); + for b in boxes.iter() { + if b.has_attr("\\Noselect") || b.has_attr("\\NonExistent") { + continue; + } + let decoded = imap::utf7_decode(&b.name); + if excluded(&decoded) { + continue; + } + let rel = decoded + .strip_prefix(&imap::utf7_decode(&prefix)) + .unwrap_or(&decoded) + .to_string(); + let mut ftype = FT_USER_MAIL; + let is = |cfgname: &str| { + !cfgname.is_empty() + && (rel.eq_ignore_ascii_case(cfgname) + || decoded.eq_ignore_ascii_case(cfgname) + || decoded.eq_ignore_ascii_case(&format!( + "{}{}", + imap::utf7_decode(&prefix), + cfgname + ))) + }; + if b.name.eq_ignore_ascii_case("INBOX") || is(&cfg.folder_inbox) { + ftype = FT_INBOX; + } else if cfg.use_special_use && b.has_attr("\\Sent") { + ftype = FT_SENT; + } else if cfg.use_special_use && b.has_attr("\\Drafts") { + ftype = FT_DRAFTS; + } else if cfg.use_special_use && b.has_attr("\\Trash") { + ftype = FT_TRASH; + } else if is(&cfg.folder_sent) { + ftype = FT_SENT; + } else if is(&cfg.folder_drafts) { + ftype = FT_DRAFTS; + } else if is(&cfg.folder_trash) { + ftype = FT_TRASH; + } + if ftype != FT_USER_MAIL { + if used.get(&ftype).copied().unwrap_or(false) { + ftype = FT_USER_MAIL; + } else { + used.insert(ftype, true); + } + } + // parent + let mut parent = None; + let mut display = rel.clone(); + if let Some(d) = b.delimiter { + if let Some(pos) = b.name.rfind(d) { + let pname = &b.name[..pos]; + if names.contains(pname) && pname != prefix.trim_end_matches(d) { + let pdec = imap::utf7_decode(pname); + if !excluded(&pdec) { + parent = Some(format!("m:{pname}")); + } + } + if parent.is_some() { + display = imap::utf7_decode(&b.name[pos + d.len_utf8()..]); + } + } + } + out.push(BFolder { + id: format!("m:{}", b.name), + parent, + name: display, + ftype, + }); + } + Ok(out) + } + + pub async fn folder(&mut self, backend_id: &str) -> Result> { + Ok(self + .folders() + .await? + .into_iter() + .find(|f| f.id == backend_id)) + } + + pub async fn special_folder(&mut self, ftype: u8) -> Result> { + Ok(self.folders().await?.into_iter().find(|f| f.ftype == ftype)) + } + + // -------------------------------------------------------------- listing + + pub async fn list( + &mut self, + folder: &BFolder, + since: Option>, + ) -> Result { + match folder.kind() { + Kind::Mail => { + let c = self.imap().await?; + let uv = c.select(folder.path()).await?; + let infos = if let Some(since) = since { + let uids = c + .uid_search(&format!("UNDELETED SINCE {}", since.format("%d-%b-%Y"))) + .await?; + if uids.is_empty() { + vec![] + } else { + let mut all = Vec::new(); + for chunk in uids.chunks(5000) { + all.extend(c.uid_fetch_flags(&uid_set(chunk)).await?); + } + all + } + } else { + c.uid_fetch_flags("1:*").await.or_else(|e| { + // empty mailbox: some servers answer BAD/NO for 1:* + if e.to_string().contains("IMAP") { + Ok(vec![]) + } else { + Err(e) + } + })? + }; + let items = infos + .into_iter() + .filter(|i| !i.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Deleted"))) + .map(|i| (i.uid.to_string(), mail_fingerprint(&i.flags))) + .collect(); + Ok(Listing { + meta: uv.to_string(), + items, + }) + } + k => { + let c = self.dav_for(k)?; + let href = dav::encode_path(folder.path()); + let comp = match k { + Kind::Calendar => Some("VEVENT"), + Kind::Tasks => Some("VTODO"), + _ => None, + }; + let since_s = if k == Kind::Calendar { + since.map(|d| tz::fmt_compact(&d)) + } else { + None + }; + let res = c.list(&href, comp, since_s.as_deref()).await?; + let items = res + .into_iter() + .map(|r| (dav::last_segment(&r.href), r.etag)) + .collect(); + Ok(Listing { + meta: String::new(), + items, + }) + } + } + } + + /// Cheap change marker used by Ping. + pub async fn signature(&mut self, folder: &BFolder) -> Result { + match folder.kind() { + Kind::Mail => { + let st = self.imap().await?.status(folder.path()).await?; + Ok(format!( + "{}/{}/{}/{}/{}", + st.uidvalidity, st.uidnext, st.messages, st.unseen, st.highestmodseq + )) + } + k => { + let c = self.dav_for(k)?; + let href = dav::encode_path(folder.path()); + let tag = c.ctag(&href).await?; + if tag.is_empty() { + // no ctag support: hash the etag listing + let l = self.list(folder, None).await?; + let mut s: Vec = l + .items + .into_iter() + .map(|(a, b)| format!("{a}={b}")) + .collect(); + s.sort(); + Ok(crate::util::sha256_hex(s.join("\n").as_bytes())) + } else { + Ok(tag) + } + } + } + } + + // -------------------------------------------------------------- fetch + + pub async fn fetch(&mut self, folder: &BFolder, ids: &[String]) -> Result> { + if ids.is_empty() { + return Ok(vec![]); + } + match folder.kind() { + Kind::Mail => { + let uids: Vec = ids.iter().filter_map(|i| i.parse().ok()).collect(); + let c = self.imap().await?; + c.select(folder.path()).await?; + let mut out = Vec::new(); + for chunk in uids.chunks(25) { + for m in c.uid_fetch_full(&uid_set(chunk)).await? { + out.push(RawItem::Mail(m)); + } + } + Ok(out) + } + k => { + let c = self.dav_for(k)?; + let base = folder.path().to_string(); + let hrefs: Vec = ids + .iter() + .map(|id| format!("{}{}", dav::encode_path(&base), dav::encode_segment(id))) + .collect(); + let dk = if k == Kind::Contacts { + DavKind::AddressBook + } else { + DavKind::Calendar + }; + let mut out = Vec::new(); + for chunk in hrefs.chunks(50) { + for r in c.multiget(&dav::encode_path(&base), chunk, dk).await? { + if let Some(d) = r.data { + out.push(RawItem::Dav { + id: dav::last_segment(&r.href), + etag: r.etag, + data: d, + }); + } + } + } + Ok(out) + } + } + } + + pub async fn fetch_mail(&mut self, mailbox: &str, uid: u32) -> Result { + let c = self.imap().await?; + c.select(mailbox).await?; + c.uid_fetch_full(&uid.to_string()) + .await? + .into_iter() + .next() + .ok_or_else(|| anyhow!("message not found")) + } + + pub async fn fetch_dav(&mut self, folder: &BFolder, id: &str) -> Result { + let c = self.dav_for(folder.kind())?; + let href = format!( + "{}{}", + dav::encode_path(folder.path()), + dav::encode_segment(id) + ); + c.get(&href).await + } + + // -------------------------------------------------------------- mail changes + + pub async fn mail_set_flags( + &mut self, + folder: &BFolder, + uid: u32, + read: Option, + flagged: Option, + ) -> Result { + let c = self.imap().await?; + c.select(folder.path()).await?; + if let Some(r) = read { + c.uid_store(uid, if r { "+" } else { "-" }, "\\Seen") + .await?; + } + if let Some(f) = flagged { + c.uid_store(uid, if f { "+" } else { "-" }, "\\Flagged") + .await?; + } + let info = c.uid_fetch_flags(&uid.to_string()).await?; + Ok(info + .first() + .map(|i| mail_fingerprint(&i.flags)) + .unwrap_or_default()) + } + + pub async fn mail_add_flag(&mut self, mailbox: &str, uid: u32, flag: &str) -> Result<()> { + let c = self.imap().await?; + c.select(mailbox).await?; + c.uid_store(uid, "+", flag).await + } + + pub async fn mail_delete(&mut self, folder: &BFolder, uid: u32, to_trash: bool) -> Result<()> { + let trash = if to_trash && folder.ftype != FT_TRASH { + self.special_folder(FT_TRASH).await? + } else { + None + }; + let autoseen = self.settings.imap.autoseen_on_delete; + let c = self.imap().await?; + c.select(folder.path()).await?; + match trash { + Some(t) => { + if autoseen { + c.uid_store(uid, "+", "\\Seen").await?; + } + c.uid_move(uid, t.path()).await?; + } + None => { + c.uid_store(uid, "+", "\\Deleted").await?; + c.uid_expunge(uid).await?; + } + } + Ok(()) + } + + pub async fn mail_move( + &mut self, + src: &BFolder, + uid: u32, + dst: &BFolder, + ) -> Result> { + let c = self.imap().await?; + c.select(src.path()).await?; + let new_uid = c.uid_move(uid, dst.path()).await?; + if new_uid.is_some() { + return Ok(new_uid); + } + // no UIDPLUS: newest message in destination + c.select(dst.path()).await?; + let uids = c.uid_search("ALL").await?; + Ok(uids.last().copied()) + } + + pub async fn mail_append( + &mut self, + mailbox: &str, + flags: &str, + data: &[u8], + ) -> Result> { + let c = self.imap().await?; + c.append(mailbox, flags, data).await + } + + pub async fn mail_empty(&mut self, folder: &BFolder) -> Result<()> { + let c = self.imap().await?; + c.select(folder.path()).await?; + let uids = c.uid_search("UNDELETED").await?; + if uids.is_empty() { + return Ok(()); + } + c.run(&format!( + "UID STORE {} +FLAGS.SILENT (\\Deleted)", + uid_set(&uids) + )) + .await?; + c.run("EXPUNGE").await?; + Ok(()) + } + + // -------------------------------------------------------------- DAV changes + + pub async fn dav_put( + &mut self, + folder: &BFolder, + id: Option<&str>, + body: String, + if_match: Option<&str>, + ) -> Result<(String, String)> { + let c = self.dav_for(folder.kind())?; + let (id, create) = match id { + Some(i) => (i.to_string(), false), + None => { + let ext = if folder.kind() == Kind::Contacts { + "vcf" + } else { + "ics" + }; + (format!("{}.{ext}", uuid::Uuid::new_v4()), true) + } + }; + let href = format!( + "{}{}", + dav::encode_path(folder.path()), + dav::encode_segment(&id) + ); + let ct = if folder.kind() == Kind::Contacts { + "text/vcard; charset=utf-8" + } else { + "text/calendar; charset=utf-8" + }; + let etag = c + .put( + &href, + body, + ct, + if create { + None + } else { + Some(if_match.unwrap_or("")) + }, + ) + .await?; + Ok((id, etag)) + } + + pub async fn dav_delete(&mut self, folder: &BFolder, id: &str) -> Result<()> { + let c = self.dav_for(folder.kind())?; + let href = format!( + "{}{}", + dav::encode_path(folder.path()), + dav::encode_segment(id) + ); + c.delete(&href, None).await + } + + /// Move a DAV resource by GET + PUT + DELETE (portable across servers). + pub async fn dav_move(&mut self, src: &BFolder, id: &str, dst: &BFolder) -> Result { + let r = self.fetch_dav(src, id).await?; + let (new_id, _) = self + .dav_put(dst, None, r.data.unwrap_or_default(), None) + .await?; + self.dav_delete(src, id).await?; + Ok(new_id) + } + + // -------------------------------------------------------------- folder ops + + pub async fn create_folder( + &mut self, + parent: Option<&BFolder>, + name: &str, + ftype: u8, + ) -> Result { + let kind = Kind::from_type(ftype); + self.invalidate_folders(); + match kind { + Kind::Mail => { + let c = self.imap().await?; + let list = c.list().await?; + let delim = list.iter().find_map(|b| b.delimiter).unwrap_or('/'); + let enc = imap::utf7_encode(name); + let full = match parent.filter(|p| p.kind() == Kind::Mail) { + Some(p) => format!("{}{delim}{enc}", p.path()), + None => format!("{}{enc}", self.settings.imap.folder_prefix), + }; + if enc.contains(delim) { + bail!("folder name contains the hierarchy delimiter"); + } + let c = self.imap().await?; + c.create(&full).await?; + Ok(BFolder { + id: format!("m:{full}"), + parent: parent.map(|p| p.id.clone()), + name: name.to_string(), + ftype: FT_USER_MAIL, + }) + } + k => { + let (c, home) = match k { + Kind::Contacts => self.carddav()?, + _ => self.caldav()?, + } + .ok_or_else(|| anyhow!("{k:?} backend disabled"))?; + let seg = uuid::Uuid::new_v4().to_string(); + let href = format!("{}{}/", home.trim_end_matches('/').to_string() + "/", seg); + let dk = if k == Kind::Contacts { + DavKind::AddressBook + } else { + DavKind::Calendar + }; + c.make_collection(&href, name, dk, k == Kind::Tasks).await?; + let decoded = dav::normalize_dir(&href); + let prefix = match k { + Kind::Calendar => "c", + Kind::Tasks => "t", + _ => "a", + }; + Ok(BFolder { + id: format!("{prefix}:{decoded}"), + parent: None, + name: name.to_string(), + ftype, + }) + } + } + } + + pub async fn rename_folder( + &mut self, + folder: &BFolder, + new_parent: Option<&BFolder>, + name: &str, + ) -> Result { + self.invalidate_folders(); + match folder.kind() { + Kind::Mail => { + if folder.ftype != FT_USER_MAIL { + bail!("system folders cannot be renamed"); + } + let c = self.imap().await?; + let list = c.list().await?; + let delim = list.iter().find_map(|b| b.delimiter).unwrap_or('/'); + let enc = imap::utf7_encode(name); + let full = match new_parent.filter(|p| p.kind() == Kind::Mail) { + Some(p) => format!("{}{delim}{enc}", p.path()), + None => format!("{}{enc}", self.settings.imap.folder_prefix), + }; + let c = self.imap().await?; + c.rename(folder.path(), &full).await?; + Ok(BFolder { + id: format!("m:{full}"), + parent: new_parent.map(|p| p.id.clone()), + name: name.to_string(), + ftype: folder.ftype, + }) + } + k => { + let c = self.dav_for(k)?; + c.rename(&dav::encode_path(folder.path()), name).await?; + let mut f = folder.clone(); + f.name = name.to_string(); + Ok(f) + } + } + } + + pub async fn delete_folder(&mut self, folder: &BFolder) -> Result<()> { + self.invalidate_folders(); + match folder.kind() { + Kind::Mail => { + if folder.ftype != FT_USER_MAIL { + bail!("system folders cannot be deleted"); + } + self.imap().await?.delete(folder.path()).await + } + k => { + if matches!(folder.ftype, FT_CALENDAR | FT_CONTACTS | FT_TASKS) { + bail!("default folders cannot be deleted"); + } + let c = self.dav_for(k)?; + c.delete(&dav::encode_path(folder.path()), None).await + } + } + } + + // -------------------------------------------------------------- search helpers + + pub async fn mail_search( + &mut self, + folder: &BFolder, + text: &str, + since: Option>, + ) -> Result> { + let c = self.imap().await?; + c.select(folder.path()).await?; + let extra = since + .map(|d| format!("SINCE {} ", d.format("%d-%b-%Y"))) + .unwrap_or_default(); + if text.trim().is_empty() { + return c.uid_search(&format!("{extra}ALL")).await; + } + c.uid_search_text("TEXT", text, &extra).await + } + + pub async fn close(mut self) { + if let Some(c) = self.imap.take() { + self.pool + .put(self.pool_key, c, self.settings.imap.pool_max_per_user); + } + } + + pub fn smtp_settings(&self) -> &crate::config::SmtpSettings { + &self.settings.smtp + } +} + +fn filter_cols(cols: Vec, inc: &str, exc: &str) -> Vec { + let inc = (!inc.is_empty()) + .then(|| regex::Regex::new(inc).ok()) + .flatten(); + let exc = (!exc.is_empty()) + .then(|| regex::Regex::new(exc).ok()) + .flatten(); + cols.into_iter() + .filter(|c| inc.as_ref().map(|r| r.is_match(&c.href)).unwrap_or(true)) + .filter(|c| !exc.as_ref().map(|r| r.is_match(&c.href)).unwrap_or(false)) + .collect() +} + +fn pick_default( + cols: &[dav::Collection], + wanted: &str, + ok: impl Fn(&dav::Collection) -> bool, +) -> Option { + if !wanted.is_empty() { + if let Some(i) = cols.iter().position(|c| { + ok(c) && (dav::last_segment(&c.href) == wanted || c.display_name == wanted) + }) { + return Some(i); + } + } + cols.iter().position(ok) +} + +pub fn mail_fingerprint(flags: &[String]) -> String { + let mut s = String::new(); + let has = |f: &str| flags.iter().any(|x| x.eq_ignore_ascii_case(f)); + if has("\\Seen") { + s.push('S'); + } + if has("\\Flagged") { + s.push('F'); + } + if has("\\Answered") { + s.push('A'); + } + if has("$Forwarded") || has("Forwarded") { + s.push('W'); + } + if s.is_empty() { + s.push('-'); + } + s +} + +/// Compress a sorted uid list into an IMAP sequence set. +pub fn uid_set(uids: &[u32]) -> String { + let mut out = String::new(); + let mut i = 0; + while i < uids.len() { + let start = uids[i]; + let mut end = start; + while i + 1 < uids.len() && uids[i + 1] == end + 1 { + i += 1; + end = uids[i]; + } + if !out.is_empty() { + out.push(','); + } + if start == end { + out.push_str(&start.to_string()); + } else { + out.push_str(&format!("{start}:{end}")); + } + i += 1; + } + out +} + +pub fn primary_email(s: &Settings, login: &str) -> String { + let e = expand_user_template(&s.general.email_template, login, &s.general.default_domain); + if e.contains('@') { + e + } else if !s.general.default_domain.is_empty() { + format!("{e}@{}", s.general.default_domain) + } else { + e + } +} + +/// Validate backend connectivity for the panel (optionally with credentials). +pub async fn test_imap(s: &Settings, login: &str, pass: &str) -> Result { + let t = Instant::now(); + let mut c = ImapConn::connect(&s.imap).await.context("connect")?; + let caps = c.caps.join(" "); + if !login.is_empty() { + if !c.login(login, pass).await? { + bail!("connected, but login was rejected"); + } + let n = c.list().await?.len(); + c.logout().await; + return Ok(format!( + "OK in {} ms, login OK, {n} folders. Capabilities: {caps}", + t.elapsed().as_millis() + )); + } + c.logout().await; + Ok(format!( + "OK in {} ms. Capabilities: {caps}", + t.elapsed().as_millis() + )) +} + +pub async fn test_dav(s: &Settings, kind: DavKind, login: &str, pass: &str) -> Result { + let (base, path, verify, timeout) = match kind { + DavKind::Calendar => ( + &s.caldav.base_url, + &s.caldav.path, + s.caldav.verify_tls, + s.caldav.timeout_secs, + ), + DavKind::AddressBook => ( + &s.carddav.base_url, + &s.carddav.path, + s.carddav.verify_tls, + s.carddav.timeout_secs, + ), + }; + let http = DavClient::build_http(verify, timeout)?; + let c = DavClient::new(http, base, login, pass)?; + let home = dav::encode_path(&expand_user_template( + path, + login, + &s.general.default_domain, + )); + let cols = c.collections(&home, kind).await?; + Ok(format!( + "OK: {} collection(s): {}", + cols.len(), + cols.iter() + .map(|c| c.display_name.clone()) + .collect::>() + .join(", ") + )) +} + +#[cfg(test)] +mod tests { + #[test] + fn uidset() { + assert_eq!(super::uid_set(&[1, 2, 3, 5, 7, 8]), "1:3,5,7:8"); + } +} diff --git a/src/backend/smtp.rs b/src/backend/smtp.rs new file mode 100644 index 0000000..ba4b66a --- /dev/null +++ b/src/backend/smtp.rs @@ -0,0 +1,145 @@ +//! Outgoing mail: SMTP submission (lettre) or local sendmail binary. + +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use lettre::address::Envelope; +use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::client::{Tls, TlsParameters}; +use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; +use tokio::io::AsyncWriteExt; + +use crate::config::{SendMethod, SmtpAuth, SmtpSettings, TlsMode}; + +pub async fn send( + cfg: &SmtpSettings, + login: &str, + password: &str, + from: &str, + recipients: &[String], + raw: &[u8], +) -> Result<()> { + if recipients.is_empty() { + bail!("message has no recipients"); + } + if raw.len() as u64 > cfg.max_message_mb * 1024 * 1024 { + bail!("message too large"); + } + let from_addr: Address = from + .parse() + .map_err(|_| anyhow!("invalid sender address {from}"))?; + let mut to = Vec::with_capacity(recipients.len()); + for r in recipients { + to.push( + r.parse::
() + .map_err(|_| anyhow!("invalid recipient address {r}"))?, + ); + } + match cfg.method { + SendMethod::Smtp => { + let tls = |host: &str| -> Result { + Ok(TlsParameters::builder(host.to_string()) + .dangerous_accept_invalid_certs(!cfg.verify_tls) + .dangerous_accept_invalid_hostnames(!cfg.verify_tls) + .build_rustls()?) + }; + let mut b = AsyncSmtpTransport::::builder_dangerous(&cfg.host) + .port(cfg.port) + .timeout(Some(Duration::from_secs(cfg.timeout_secs.max(5)))); + b = match cfg.security { + TlsMode::None => b.tls(Tls::None), + TlsMode::StartTls => b.tls(Tls::Required(tls(&cfg.host)?)), + TlsMode::Tls => b.tls(Tls::Wrapper(tls(&cfg.host)?)), + }; + b = match cfg.auth { + SmtpAuth::None => b, + SmtpAuth::UserCredentials => { + b.credentials(Credentials::new(login.to_string(), password.to_string())) + } + SmtpAuth::Static => { + b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone())) + } + }; + let transport = b.build(); + let env = Envelope::new(Some(from_addr), to).context("invalid envelope")?; + transport + .send_raw(&env, raw) + .await + .map_err(|e| anyhow!("SMTP: {e}"))?; + Ok(()) + } + SendMethod::Sendmail => { + let mut cmd = tokio::process::Command::new(&cfg.sendmail_path); + cmd.arg("-i").arg("-f").arg(&from_addr).arg("--"); + for t in &to { + cmd.arg(t); + } + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn().context("spawn sendmail")?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(raw).await?; + stdin.shutdown().await.ok(); + } + let out = tokio::time::timeout( + Duration::from_secs(cfg.timeout_secs.max(5)), + child.wait_with_output(), + ) + .await + .map_err(|_| anyhow!("sendmail timeout"))??; + if !out.status.success() { + bail!( + "sendmail failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) + } + } +} + +/// Check connectivity (and optionally authentication) for the panel "test" button. +pub async fn test(cfg: &SmtpSettings, login: &str, password: &str) -> Result { + match cfg.method { + SendMethod::Sendmail => { + if std::path::Path::new(&cfg.sendmail_path).exists() { + Ok(format!("{} exists", cfg.sendmail_path)) + } else { + bail!("{} not found", cfg.sendmail_path) + } + } + SendMethod::Smtp => { + let tls = TlsParameters::builder(cfg.host.clone()) + .dangerous_accept_invalid_certs(!cfg.verify_tls) + .dangerous_accept_invalid_hostnames(!cfg.verify_tls) + .build_rustls()?; + let mut b = AsyncSmtpTransport::::builder_dangerous(&cfg.host) + .port(cfg.port) + .timeout(Some(Duration::from_secs(10))); + b = match cfg.security { + TlsMode::None => b.tls(Tls::None), + TlsMode::StartTls => b.tls(Tls::Required(tls)), + TlsMode::Tls => b.tls(Tls::Wrapper(tls)), + }; + match cfg.auth { + SmtpAuth::UserCredentials if !login.is_empty() => { + b = b.credentials(Credentials::new(login.into(), password.into())) + } + SmtpAuth::Static => { + b = b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone())) + } + _ => {} + } + let t: AsyncSmtpTransport = b.build(); + let ok = t.test_connection().await.map_err(|e| anyhow!("{e}"))?; + if ok { + Ok(format!("SMTP {}:{} OK", cfg.host, cfg.port)) + } else { + bail!("SMTP connection test failed") + } + } + } +} diff --git a/src/backend/tz.rs b/src/backend/tz.rs new file mode 100644 index 0000000..ebf7551 --- /dev/null +++ b/src/backend/tz.rs @@ -0,0 +1,501 @@ +//! Time zone helpers: EAS TIME_ZONE_INFORMATION blobs <-> IANA zones, date formats, iCal dates. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use base64::Engine; +use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, Offset, TimeZone, Timelike, Utc}; +use chrono_tz::Tz; + +use super::ical::{Component, Property}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SysTime { + pub year: u16, + pub month: u16, + pub dow: u16, + pub day: u16, + pub hour: u16, + pub minute: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TzRule { + pub bias: i32, + pub std_date: SysTime, + pub std_bias: i32, + pub dst_date: SysTime, + pub dst_bias: i32, +} + +fn offset_min(tz: &Tz, t: DateTime) -> i32 { + t.with_timezone(tz).offset().fix().local_minus_utc() / 60 +} + +fn sys_from_local(local: NaiveDateTime) -> SysTime { + let day = local.day(); + let dim = days_in_month(local.year(), local.month()); + let week = if day + 7 > dim { 5 } else { (day - 1) / 7 + 1 }; + SysTime { + year: 0, + month: local.month() as u16, + dow: local.weekday().num_days_from_sunday() as u16, + day: week as u16, + hour: local.hour() as u16, + minute: local.minute() as u16, + } +} + +pub fn days_in_month(y: i32, m: u32) -> u32 { + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + NaiveDate::from_ymd_opt(ny, nm, 1) + .and_then(|d| d.pred_opt()) + .map(|d| d.day()) + .unwrap_or(30) +} + +/// Compute the Windows-style rule for a zone in a given year. +pub fn rule_for(tz: &Tz, year: i32) -> TzRule { + static CACHE: OnceLock>> = OnceLock::new(); + let key = (tz.name().to_string(), year); + if let Some(r) = CACHE + .get_or_init(Default::default) + .lock() + .unwrap() + .get(&key) + { + return *r; + } + let start = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap(); + let mut transitions = Vec::new(); + let mut prev = offset_min(tz, start); + let mut min_off = prev; + let mut max_off = prev; + let mut t = start; + // hourly scan, then refine to the minute + for _ in 0..(366 * 24) { + let n = t + chrono::Duration::hours(1); + let o = offset_min(tz, n); + if o != prev { + let mut lo = t; + let mut hi = n; + while (hi - lo).num_minutes() > 1 { + let mid = lo + (hi - lo) / 2; + if offset_min(tz, mid) == prev { + lo = mid; + } else { + hi = mid; + } + } + transitions.push((hi, prev, o)); + prev = o; + } + min_off = min_off.min(o); + max_off = max_off.max(o); + t = n; + if t.year() > year { + break; + } + } + let mut rule = TzRule { + bias: -min_off, + ..Default::default() + }; + if max_off != min_off && transitions.len() >= 2 { + rule.dst_bias = -(max_off - min_off); + for (instant, from, to) in transitions { + if to > from { + // into daylight: expressed in local standard time + let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc(); + rule.dst_date = sys_from_local(local); + } else { + // into standard: expressed in local daylight time + let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc(); + rule.std_date = sys_from_local(local); + } + } + } + CACHE + .get_or_init(Default::default) + .lock() + .unwrap() + .insert(key, rule); + rule +} + +fn write_sys(out: &mut Vec, s: &SysTime) { + for v in [s.year, s.month, s.dow, s.day, s.hour, s.minute, 0, 0] { + out.extend_from_slice(&v.to_le_bytes()); + } +} + +fn write_name(out: &mut Vec, name: &str) { + let mut units: Vec = name.encode_utf16().take(31).collect(); + units.resize(32, 0); + for u in units { + out.extend_from_slice(&u.to_le_bytes()); + } +} + +pub fn blob_for(tz: &Tz, year: i32) -> String { + let r = rule_for(tz, year); + let mut out = Vec::with_capacity(172); + out.extend_from_slice(&r.bias.to_le_bytes()); + write_name(&mut out, tz.name()); + write_sys(&mut out, &r.std_date); + out.extend_from_slice(&r.std_bias.to_le_bytes()); + write_name(&mut out, tz.name()); + write_sys(&mut out, &r.dst_date); + out.extend_from_slice(&r.dst_bias.to_le_bytes()); + base64::engine::general_purpose::STANDARD.encode(out) +} + +pub fn parse_blob(b64: &str) -> Option { + let b = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .ok()?; + if b.len() < 172 { + return None; + } + let i32_at = |o: usize| i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); + let sys_at = |o: usize| SysTime { + year: u16_at(o), + month: u16_at(o + 2), + dow: u16_at(o + 4), + day: u16_at(o + 6), + hour: u16_at(o + 8), + minute: u16_at(o + 10), + }; + Some(TzRule { + bias: i32_at(0), + std_date: sys_at(68), + std_bias: i32_at(84), + dst_date: sys_at(152), + dst_bias: i32_at(168), + }) +} + +fn rules_match(a: &TzRule, b: &TzRule) -> bool { + let norm = |s: &SysTime| (s.month, s.dow, s.day, s.hour); + a.bias == b.bias + && (a.dst_date.month == 0) == (b.dst_date.month == 0) + && (a.dst_date.month == 0 + || (norm(&a.std_date) == norm(&b.std_date) + && norm(&a.dst_date) == norm(&b.dst_date) + && a.dst_bias == b.dst_bias)) +} + +/// Map a device-supplied blob to an IANA zone (preferring `preferred`, then common zones). +pub fn tz_from_blob(b64: &str, preferred: Tz) -> Tz { + static CACHE: OnceLock>> = OnceLock::new(); + let key = (b64.to_string(), preferred); + if let Some(t) = CACHE + .get_or_init(Default::default) + .lock() + .unwrap() + .get(&key) + { + return *t; + } + let Some(rule) = parse_blob(b64) else { + return preferred; + }; + let year = Utc::now().year(); + const COMMON: &[&str] = &[ + "Europe/Warsaw", + "Europe/Berlin", + "Europe/London", + "Europe/Kiev", + "Europe/Moscow", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "Asia/Tokyo", + "Asia/Shanghai", + "Australia/Sydney", + ]; + let found = if rules_match(&rule_for(&preferred, year), &rule) { + preferred + } else if rule.bias == 0 && rule.dst_date.month == 0 { + chrono_tz::UTC + } else { + COMMON + .iter() + .filter_map(|n| n.parse::().ok()) + .chain( + chrono_tz::TZ_VARIANTS + .iter() + .copied() + .filter(|z| !z.name().starts_with("Etc/") || rule.dst_date.month == 0), + ) + .find(|z| rules_match(&rule_for(z, year), &rule)) + .unwrap_or(preferred) + }; + { + let mut c = CACHE.get_or_init(Default::default).lock().unwrap(); + if c.len() > 2000 { + c.clear(); + } + c.insert(key, found); + } + found +} + +// ------------------------------------------------------------------ EAS date formats + +pub fn fmt_compact(d: &DateTime) -> String { + d.format("%Y%m%dT%H%M%SZ").to_string() +} + +pub fn fmt_iso(d: &DateTime) -> String { + d.format("%Y-%m-%dT%H:%M:%S.000Z").to_string() +} + +pub fn parse_eas_date(s: &str) -> Option> { + let s = s.trim(); + for f in [ + "%Y%m%dT%H%M%SZ", + "%Y-%m-%dT%H:%M:%S%.fZ", + "%Y-%m-%dT%H:%M:%SZ", + "%Y%m%dT%H%M%S", + ] { + if let Ok(n) = NaiveDateTime::parse_from_str(s, f) { + return Some(Utc.from_utc_datetime(&n)); + } + } + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc)) +} + +// ------------------------------------------------------------------ iCalendar dates + +/// Resolve a TZID parameter to an IANA zone. +pub fn resolve_tzid(tzid: &str, default: Tz) -> Tz { + let t = tzid.trim().trim_matches('"'); + if let Ok(z) = t.parse::() { + return z; + } + // /mozilla.org/20050126_1/Europe/Warsaw, /freeassociation.sourceforge.net/Tzfile/Europe/Warsaw + let segs: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect(); + for n in [3usize, 2, 1] { + if segs.len() >= n { + if let Ok(z) = segs[segs.len() - n..].join("/").parse::() { + return z; + } + } + } + let windows = [ + ("Central European Standard Time", "Europe/Warsaw"), + ("Central Europe Standard Time", "Europe/Budapest"), + ("W. Europe Standard Time", "Europe/Berlin"), + ("Romance Standard Time", "Europe/Paris"), + ("GMT Standard Time", "Europe/London"), + ("Greenwich Standard Time", "Atlantic/Reykjavik"), + ("E. Europe Standard Time", "Europe/Chisinau"), + ("FLE Standard Time", "Europe/Kiev"), + ("GTB Standard Time", "Europe/Bucharest"), + ("Russian Standard Time", "Europe/Moscow"), + ("Eastern Standard Time", "America/New_York"), + ("Central Standard Time", "America/Chicago"), + ("Mountain Standard Time", "America/Denver"), + ("Pacific Standard Time", "America/Los_Angeles"), + ("UTC", "UTC"), + ("Coordinated Universal Time", "UTC"), + ("China Standard Time", "Asia/Shanghai"), + ("Tokyo Standard Time", "Asia/Tokyo"), + ("India Standard Time", "Asia/Kolkata"), + ("AUS Eastern Standard Time", "Australia/Sydney"), + ]; + for (w, i) in windows { + if t.eq_ignore_ascii_case(w) { + return i.parse().unwrap_or(default); + } + } + default +} + +#[derive(Debug, Clone, Copy)] +pub struct IcalTime { + pub utc: DateTime, + pub all_day: bool, + pub tz: Option, + pub date: Option, +} + +pub fn parse_ical_time(p: &Property, default: Tz) -> Option { + let v = p.value.trim(); + let is_date = p + .param("VALUE") + .map(|x| x.eq_ignore_ascii_case("DATE")) + .unwrap_or(false) + || (v.len() == 8 && v.chars().all(|c| c.is_ascii_digit())); + if is_date { + let d = NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()?; + let local = d.and_hms_opt(0, 0, 0)?; + let utc = default + .from_local_datetime(&local) + .earliest() + .map(|x| x.with_timezone(&Utc))?; + return Some(IcalTime { + utc, + all_day: true, + tz: None, + date: Some(d), + }); + } + if let Some(stripped) = v.strip_suffix('Z') { + let n = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?; + return Some(IcalTime { + utc: Utc.from_utc_datetime(&n), + all_day: false, + tz: None, + date: None, + }); + } + let n = NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S").ok()?; + let tz = p + .param("TZID") + .map(|t| resolve_tzid(t, default)) + .unwrap_or(default); + let utc = tz + .from_local_datetime(&n) + .earliest() + .or_else(|| { + tz.from_local_datetime(&(n + chrono::Duration::hours(1))) + .earliest() + }) + .map(|x| x.with_timezone(&Utc))?; + Some(IcalTime { + utc, + all_day: false, + tz: Some(tz), + date: None, + }) +} + +pub fn ical_utc(name: &str, d: &DateTime) -> Property { + Property::new(name, d.format("%Y%m%dT%H%M%SZ").to_string()) +} + +pub fn ical_local(name: &str, d: &DateTime, tz: &Tz) -> Property { + Property::new( + name, + d.with_timezone(tz).format("%Y%m%dT%H%M%S").to_string(), + ) + .with_param("TZID", tz.name()) +} + +pub fn ical_date(name: &str, d: NaiveDate) -> Property { + Property::new(name, d.format("%Y%m%d").to_string()).with_param("VALUE", "DATE") +} + +/// Build a VTIMEZONE for `tz` using the rules of the current year. +pub fn vtimezone(tz: &Tz) -> Component { + let mut c = Component::new("VTIMEZONE"); + c.add(Property::new("TZID", tz.name())); + let year = Utc::now().year(); + let r = rule_for(tz, year); + let fmt_off = |m: i32| { + let s = if m < 0 { '-' } else { '+' }; + format!("{s}{:02}{:02}", m.abs() / 60, m.abs() % 60) + }; + let std_off = -r.bias; + if r.dst_date.month == 0 { + let mut s = Component::new("STANDARD"); + s.add(Property::new("DTSTART", "19700101T000000")); + s.add(Property::new("TZOFFSETFROM", fmt_off(std_off))); + s.add(Property::new("TZOFFSETTO", fmt_off(std_off))); + c.children.push(s); + return c; + } + let dst_off = std_off - r.dst_bias; + let byday = |st: &SysTime| { + let wd = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][st.dow as usize % 7]; + let n = if st.day >= 5 { + "-1".to_string() + } else { + st.day.to_string() + }; + format!("FREQ=YEARLY;BYMONTH={};BYDAY={n}{wd}", st.month) + }; + let mut d = Component::new("DAYLIGHT"); + d.add(Property::new( + "DTSTART", + format!("19700101T{:02}{:02}00", r.dst_date.hour, r.dst_date.minute), + )); + d.add(Property::new("RRULE", byday(&r.dst_date))); + d.add(Property::new("TZOFFSETFROM", fmt_off(std_off))); + d.add(Property::new("TZOFFSETTO", fmt_off(dst_off))); + let mut s = Component::new("STANDARD"); + s.add(Property::new( + "DTSTART", + format!("19700101T{:02}{:02}00", r.std_date.hour, r.std_date.minute), + )); + s.add(Property::new("RRULE", byday(&r.std_date))); + s.add(Property::new("TZOFFSETFROM", fmt_off(dst_off))); + s.add(Property::new("TZOFFSETTO", fmt_off(std_off))); + c.children.push(d); + c.children.push(s); + c +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warsaw_rule() { + let r = rule_for(&chrono_tz::Europe::Warsaw, 2026); + assert_eq!(r.bias, -60); + assert_eq!(r.dst_bias, -60); + assert_eq!( + ( + r.dst_date.month, + r.dst_date.day, + r.dst_date.dow, + r.dst_date.hour + ), + (3, 5, 0, 2) + ); + assert_eq!( + ( + r.std_date.month, + r.std_date.day, + r.std_date.dow, + r.std_date.hour + ), + (10, 5, 0, 3) + ); + } + + #[test] + fn blob_roundtrip() { + let b = blob_for(&chrono_tz::America::New_York, 2026); + let tz = tz_from_blob(&b, chrono_tz::Europe::Warsaw); + let r1 = rule_for(&tz, 2026); + assert_eq!(r1, rule_for(&chrono_tz::America::New_York, 2026)); + let w = blob_for(&chrono_tz::Europe::Warsaw, 2026); + assert_eq!( + tz_from_blob(&w, chrono_tz::Europe::Warsaw), + chrono_tz::Europe::Warsaw + ); + } + + #[test] + fn ical_times() { + let p = Property::new("DTSTART", "20260704T100000") + .with_param("TZID", "/mozilla.org/x/Europe/Warsaw"); + let t = parse_ical_time(&p, chrono_tz::UTC).unwrap(); + assert_eq!(fmt_compact(&t.utc), "20260704T080000Z"); + let d = parse_ical_time( + &Property::new("DTSTART", "20260704").with_param("VALUE", "DATE"), + chrono_tz::Europe::Warsaw, + ) + .unwrap(); + assert!(d.all_day); + assert_eq!(fmt_compact(&d.utc), "20260703T220000Z"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c012791 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,764 @@ +//! Runtime settings. Persisted as JSON in SQLite, editable from the admin panel and hot-applied. +//! Defaults mirror Z-Push `config.php`, `backend/*/config.php` and `policies.ini`. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TlsMode { + None, + #[default] + StartTls, + Tls, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LoginFormat { + /// Use the login exactly as the device sent it (after stripping a Windows `DOMAIN\`). + #[default] + AsGiven, + /// Use only the local part (`user` from `user@example.com`) - Z-Push USE_FULLEMAIL_FOR_LOGIN=false. + LocalPart, + /// Always use a full address; append `default_domain` when missing. + FullEmail, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +#[derive(Default)] +pub struct Settings { + pub general: General, + pub imap: ImapSettings, + pub smtp: SmtpSettings, + pub caldav: DavSettings, + pub carddav: CardDavSettings, + pub sync: SyncSettings, + pub ping: PingSettings, + pub provisioning: Provisioning, + pub autodiscover: Autodiscover, + pub security: Security, + pub logging: Logging, + pub panel: Panel, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct General { + /// Shown in the panel and used for autodiscover display name. + pub server_name: String, + /// IANA zone used for all-day events and recurring items when the device gives no zone. + pub default_timezone: String, + pub login_format: LoginFormat, + /// Domain appended for FullEmail login and used for `{domain}` in templates. + pub default_domain: String, + /// If non-empty, only these domains may log in (lowercase). + pub allowed_domains: Vec, + /// Template for the user's primary e-mail address. Placeholders: {login} {local} {domain}. + pub email_template: String, + /// Highest ActiveSync protocol version announced to devices. + pub max_protocol: String, +} + +impl Default for General { + fn default() -> Self { + General { + server_name: "ActivePush".into(), + default_timezone: "Europe/Warsaw".into(), + login_format: LoginFormat::AsGiven, + default_domain: String::new(), + allowed_domains: vec![], + email_template: "{login}".into(), + max_protocol: "14.1".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ImapSettings { + pub host: String, + pub port: u16, + pub security: TlsMode, + pub verify_tls: bool, + /// Optional namespace prefix (e.g. "INBOX.") - Z-Push IMAP_FOLDER_PREFIX. + pub folder_prefix: String, + pub folder_inbox: String, + pub folder_sent: String, + pub folder_drafts: String, + pub folder_trash: String, + pub folder_spam: String, + pub folder_archive: String, + /// Prefer RFC 6154 SPECIAL-USE flags over the names above. + pub use_special_use: bool, + /// Folders hidden from devices (exact names or `prefix*`). + pub excluded_folders: Vec, + /// Mark messages \Seen before moving them to Trash - IMAP_AUTOSEEN_ON_DELETE. + pub autoseen_on_delete: bool, + /// Inline (true) or attached (false) forward body - IMAP_INLINE_FORWARD. + pub inline_forward: bool, + pub timeout_secs: u64, + /// Keep authenticated connections for reuse between requests of the same user. + pub pool_idle_secs: u64, + pub pool_max_per_user: usize, +} + +impl Default for ImapSettings { + fn default() -> Self { + ImapSettings { + host: "127.0.0.1".into(), + port: 143, + security: TlsMode::None, + verify_tls: true, + folder_prefix: String::new(), + folder_inbox: "INBOX".into(), + folder_sent: "Sent".into(), + folder_drafts: "Drafts".into(), + folder_trash: "Trash".into(), + folder_spam: "Junk".into(), + folder_archive: "Archive".into(), + use_special_use: true, + excluded_folders: vec![], + autoseen_on_delete: false, + inline_forward: true, + timeout_secs: 60, + pool_idle_secs: 90, + pool_max_per_user: 4, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SendMethod { + #[default] + Smtp, + Sendmail, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SmtpAuth { + None, + /// Authenticate with the device user's own credentials (recommended with submission port). + #[default] + UserCredentials, + /// Authenticate with a fixed relay account. + Static, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SmtpSettings { + pub method: SendMethod, + pub host: String, + pub port: u16, + pub security: TlsMode, + pub verify_tls: bool, + pub auth: SmtpAuth, + pub username: String, + /// Secret - never returned by the API. + pub password: String, + pub sendmail_path: String, + pub timeout_secs: u64, + /// Store a copy of sent mail in the Sent folder when the device asks for it. + pub save_in_sent: bool, + /// Reject messages whose From is not one of the user's addresses (anti-spoofing). + pub enforce_from: bool, + pub max_message_mb: u64, +} + +impl Default for SmtpSettings { + fn default() -> Self { + SmtpSettings { + method: SendMethod::Smtp, + host: "127.0.0.1".into(), + port: 587, + security: TlsMode::StartTls, + verify_tls: false, + auth: SmtpAuth::UserCredentials, + username: String::new(), + password: String::new(), + sendmail_path: "/usr/sbin/sendmail".into(), + timeout_secs: 60, + save_in_sent: true, + enforce_from: true, + max_message_mb: 50, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct DavSettings { + pub enabled: bool, + /// Base URL of the server, e.g. `https://dav.example.com`. + pub base_url: String, + /// Path to the user's calendar home. Placeholders: %u login, %l local part, %d domain. + pub path: String, + pub verify_tls: bool, + /// Only collections whose path matches this regex (empty = all). + pub include_regex: String, + pub exclude_regex: String, + pub timeout_secs: u64, + /// Name of the default calendar (path segment); empty = first found. + pub default_collection: String, +} + +impl Default for DavSettings { + fn default() -> Self { + DavSettings { + enabled: false, + base_url: "https://127.0.0.1".into(), + path: "/dav/calendars/%u/".into(), + verify_tls: true, + include_regex: String::new(), + exclude_regex: String::new(), + timeout_secs: 60, + default_collection: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CardDavSettings { + pub enabled: bool, + pub base_url: String, + pub path: String, + pub verify_tls: bool, + pub include_regex: String, + pub exclude_regex: String, + pub timeout_secs: u64, + pub default_collection: String, + /// Global address list collection (GAL search); empty = search the user's books. + pub gal_path: String, + pub gal_min_length: usize, + pub max_picture_kb: usize, +} + +impl Default for CardDavSettings { + fn default() -> Self { + CardDavSettings { + enabled: false, + base_url: "https://127.0.0.1".into(), + path: "/dav/addressbooks/%u/".into(), + verify_tls: true, + include_regex: String::new(), + exclude_regex: String::new(), + timeout_secs: 60, + default_collection: String::new(), + gal_path: String::new(), + gal_min_length: 3, + max_picture_kb: 5120, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SyncSettings { + /// SYNC_MAX_ITEMS - upper bound for WindowSize. + pub max_items: u32, + /// SYNC_FILTERTIME_MAX - 0 = no limit, otherwise EAS filter type 1..7. + pub filter_time_max: u8, + /// SYNC_CONFLICT_DEFAULT: true = server wins (overwrite PIM), false = client wins. + pub server_wins: bool, + pub max_attachment_mb: u64, + pub max_request_mb: u64, + /// Hanging Sync (Wait / HeartbeatInterval) bounds in seconds. + pub heartbeat_min: u64, + pub heartbeat_max: u64, + /// Detect a device retrying the same SyncKey and shrink its window (loop detection). + pub loop_detection: bool, + /// Max seconds a Search waits - SEARCH_WAIT. + pub search_wait_secs: u64, + /// SEARCH_MAXRESULTS + pub search_max_results: u32, + /// Retry-After for 503 responses - RETRY_AFTER_DELAY. + pub retry_after_secs: u64, +} + +impl Default for SyncSettings { + fn default() -> Self { + SyncSettings { + max_items: 512, + filter_time_max: 0, + server_wins: true, + max_attachment_mb: 50, + max_request_mb: 60, + heartbeat_min: 60, + heartbeat_max: 3540, + loop_detection: true, + search_wait_secs: 10, + search_max_results: 50, + retry_after_secs: 300, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct PingSettings { + /// PING_INTERVAL - how often backend changes are checked while a Ping hangs. + pub check_interval_secs: u64, + /// PING_LOWER_BOUND_LIFETIME / PING_HIGHER_BOUND_LIFETIME. + pub lifetime_min: u64, + pub lifetime_max: u64, + pub max_folders: usize, +} + +impl Default for PingSettings { + fn default() -> Self { + PingSettings { + check_interval_secs: 30, + lifetime_min: 60, + lifetime_max: 3540, + max_folders: 300, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Provisioning { + pub enabled: bool, + /// LOOSE_PROVISIONING - allow devices that never provision. + pub loose: bool, + pub default_policy: String, + pub policies: Vec, + /// Per-user policy assignment: (username, policy name). + pub user_policies: Vec, +} + +impl Default for Provisioning { + fn default() -> Self { + Provisioning { + enabled: true, + loose: false, + default_policy: "default".into(), + policies: vec![Policy::default()], + user_policies: vec![], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UserPolicy { + pub username: String, + pub policy: String, +} + +/// All fields from Z-Push `policies.ini`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Policy { + pub name: String, + pub devpwenabled: bool, + pub alphanumpwreq: bool, + pub devencenabled: bool, + pub attenabled: bool, + pub mindevpwlenngth: u32, + pub maxinacttimedevlock: u32, + pub maxdevpwfailedattempts: u32, + /// -1 = unlimited + pub maxattsize: i64, + pub allowsimpledevpw: bool, + pub devpwexpiration: u32, + pub devpwhistory: u32, + pub allowstoragecard: bool, + pub allowcam: bool, + pub reqdevenc: bool, + pub allowunsignedapps: bool, + pub allowunsignedinstallationpackages: bool, + pub mindevcomplexchars: u32, + pub allowwifi: bool, + pub allowtextmessaging: bool, + pub allowpopimapemail: bool, + /// 0 disabled, 1 handsfree only, 2 allowed + pub allowbluetooth: u32, + pub allowirda: bool, + pub reqmansyncroam: bool, + pub allowdesktopsync: bool, + pub maxcalagefilter: u32, + pub allowhtmlemail: bool, + pub maxemailagefilter: u32, + pub maxemailbodytruncsize: i64, + pub maxemailhtmlbodytruncsize: i64, + pub reqsignedsmimemessages: bool, + pub reqencsmimemessages: bool, + pub reqsignedsmimealgorithm: u32, + pub reqencsmimealgorithm: u32, + pub allowsmimeencalgneg: u32, + pub allowsmimesoftcerts: bool, + pub allowbrowser: bool, + pub allowconsumeremail: bool, + pub allowremotedesktop: bool, + pub allowinternetsharing: bool, + pub pwrecoveryenabled: bool, +} + +impl Default for Policy { + fn default() -> Self { + Policy { + name: "default".into(), + devpwenabled: false, + alphanumpwreq: false, + devencenabled: false, + attenabled: true, + mindevpwlenngth: 4, + maxinacttimedevlock: 900, + maxdevpwfailedattempts: 8, + maxattsize: -1, + allowsimpledevpw: true, + devpwexpiration: 0, + devpwhistory: 0, + allowstoragecard: true, + allowcam: true, + reqdevenc: false, + allowunsignedapps: true, + allowunsignedinstallationpackages: true, + mindevcomplexchars: 3, + allowwifi: true, + allowtextmessaging: true, + allowpopimapemail: true, + allowbluetooth: 2, + allowirda: true, + reqmansyncroam: false, + allowdesktopsync: true, + maxcalagefilter: 0, + allowhtmlemail: true, + maxemailagefilter: 0, + maxemailbodytruncsize: -1, + maxemailhtmlbodytruncsize: -1, + reqsignedsmimemessages: false, + reqencsmimemessages: false, + reqsignedsmimealgorithm: 0, + reqencsmimealgorithm: 0, + allowsmimeencalgneg: 2, + allowsmimesoftcerts: true, + allowbrowser: true, + allowconsumeremail: true, + allowremotedesktop: true, + allowinternetsharing: true, + pwrecoveryenabled: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Autodiscover { + pub enabled: bool, + /// Public ActiveSync URL handed to devices; empty = derived from the request Host. + pub server_url: String, + /// Validate credentials against IMAP before answering (recommended). + pub require_auth: bool, +} + +impl Default for Autodiscover { + fn default() -> Self { + Autodiscover { + enabled: true, + server_url: String::new(), + require_auth: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Security { + /// Failed logins per IP / per user before lockout. + pub auth_fail_max: u32, + pub auth_fail_window_secs: u64, + pub lockout_secs: u64, + /// Proxies whose forwarded-for header is trusted (CIDR). Default: loopback. + pub trusted_proxies: Vec, + pub real_ip_header: String, + /// If non-empty, only these client networks may use ActiveSync (CIDR). + pub eas_allowed_networks: Vec, + /// Users that may never sync (lowercase logins). + pub blocked_users: Vec, + /// If non-empty, only these users may sync. + pub allowed_users: Vec, + /// New devices need administrator approval before they can sync. + pub require_device_approval: bool, + /// Device types (case-insensitive substring of DeviceType or User-Agent) that are refused. + pub blocked_device_types: Vec, + /// LOGAUTHFAIL - log failed logins (fail2ban friendly line). + pub log_auth_failures: bool, + /// Successful credentials are cached (HMAC only, never plaintext) for this many seconds. + pub auth_cache_secs: u64, +} + +impl Default for Security { + fn default() -> Self { + Security { + auth_fail_max: 8, + auth_fail_window_secs: 600, + lockout_secs: 900, + trusted_proxies: vec!["127.0.0.1/32".into(), "::1/128".into()], + real_ip_header: "X-Forwarded-For".into(), + eas_allowed_networks: vec![], + blocked_users: vec![], + allowed_users: vec![], + require_device_approval: false, + blocked_device_types: vec![], + log_auth_failures: true, + auth_cache_secs: 300, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Logging { + /// Log decoded WBXML of requests and responses (debug only; contains mail data!). + pub log_wbxml: bool, + /// Users with verbose (WBXML) logging regardless of global setting - LOGUSERLEVEL. + pub debug_users: Vec, + /// Lines kept in memory for the panel log viewer. + pub ring_size: usize, + /// Days of hourly statistics kept in the database. + pub stats_retention_days: u32, + /// Days of audit log kept. + pub audit_retention_days: u32, +} + +impl Default for Logging { + fn default() -> Self { + Logging { + log_wbxml: false, + debug_users: vec![], + ring_size: 2000, + stats_retention_days: 90, + audit_retention_days: 365, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Panel { + pub session_ttl_minutes: u64, + /// Bearer token for /metrics (Prometheus). Empty = metrics endpoint disabled. Secret. + pub prometheus_token: String, + /// Remove devices not seen for this many days (0 = never) - like `z-push-admin -a fixstates`. + pub device_inactive_days: u32, + /// If non-empty, the panel only answers these client networks (CIDR). + pub allowed_networks: Vec, +} + +impl Default for Panel { + fn default() -> Self { + Panel { + session_ttl_minutes: 480, + prometheus_token: String::new(), + device_inactive_days: 180, + allowed_networks: vec![], + } + } +} + +/// JSON pointers of secret values. They are blanked on read and kept when an update sends "". +pub const SECRET_FIELDS: &[&str] = &["/smtp/password", "/panel/prometheus_token"]; + +impl Settings { + pub fn validate(&self) -> Result<(), String> { + if self.imap.host.trim().is_empty() { + return Err("IMAP host is required".into()); + } + if self.imap.port == 0 { + return Err("IMAP port is invalid".into()); + } + if self + .general + .default_timezone + .parse::() + .is_err() + { + return Err(format!( + "Unknown time zone '{}'", + self.general.default_timezone + )); + } + if !["2.5", "12.0", "12.1", "14.0", "14.1"].contains(&self.general.max_protocol.as_str()) { + return Err("max_protocol must be one of 2.5, 12.0, 12.1, 14.0, 14.1".into()); + } + for (name, d) in [ + ("CalDAV", &self.caldav.base_url), + ("CardDAV", &self.carddav.base_url), + ] { + if !d.is_empty() && url::Url::parse(d).is_err() { + return Err(format!("{name} base URL is invalid")); + } + } + for r in [ + &self.caldav.include_regex, + &self.caldav.exclude_regex, + &self.carddav.include_regex, + &self.carddav.exclude_regex, + ] { + if !r.is_empty() && regex::Regex::new(r).is_err() { + return Err(format!("Invalid regular expression: {r}")); + } + } + for n in self + .security + .trusted_proxies + .iter() + .chain(self.security.eas_allowed_networks.iter()) + .chain(self.panel.allowed_networks.iter()) + { + if crate::util::Cidr::parse(n).is_none() { + return Err(format!( + "Invalid network '{n}' (expected CIDR, e.g. 10.0.0.0/8)" + )); + } + } + if self.provisioning.policies.is_empty() { + return Err("At least one policy is required".into()); + } + if !self + .provisioning + .policies + .iter() + .any(|p| p.name == self.provisioning.default_policy) + { + return Err("Default policy does not exist".into()); + } + if self.sync.max_items == 0 || self.sync.max_items > 2000 { + return Err("sync.max_items must be 1..2000".into()); + } + if self.ping.check_interval_secs < 5 { + return Err("ping.check_interval_secs must be >= 5".into()); + } + if self.ping.lifetime_min > self.ping.lifetime_max { + return Err("ping lifetime min > max".into()); + } + if self.smtp.method == SendMethod::Sendmail + && !std::path::Path::new(&self.smtp.sendmail_path).is_absolute() + { + return Err("sendmail_path must be absolute".into()); + } + Ok(()) + } + + pub fn policy_for(&self, user: &str) -> &Policy { + let name = self + .provisioning + .user_policies + .iter() + .find(|u| u.username.eq_ignore_ascii_case(user)) + .map(|u| u.policy.as_str()) + .unwrap_or(&self.provisioning.default_policy); + self.provisioning + .policies + .iter() + .find(|p| p.name == name) + .or_else(|| self.provisioning.policies.first()) + .expect("validated: at least one policy") + } + + pub fn timezone(&self) -> chrono_tz::Tz { + self.general + .default_timezone + .parse() + .unwrap_or(chrono_tz::Europe::Warsaw) + } + + /// Remove secrets for display. + pub fn redacted(&self) -> serde_json::Value { + let mut v = serde_json::to_value(self).unwrap_or_default(); + for p in SECRET_FIELDS { + if let Some(x) = v.pointer_mut(p) { + let set = x.as_str().map(|s| !s.is_empty()).unwrap_or(false); + *x = serde_json::Value::String(if set { "__SET__".into() } else { String::new() }); + } + } + v + } + + /// Merge an update from the panel: secret fields that are "__SET__" or missing keep the old value. + pub fn merge_update(&self, incoming: serde_json::Value) -> Result { + let old = serde_json::to_value(self).map_err(|e| e.to_string())?; + // deep-merge: a partial document only changes the keys it contains + let mut incoming = { + let mut base = old.clone(); + json_merge(&mut base, incoming); + base + }; + for p in SECRET_FIELDS { + let keep = match incoming.pointer(p) { + None => true, + Some(v) => v.as_str() == Some("__SET__"), + }; + if keep { + if let (Some(dst), Some(src)) = (incoming.pointer_mut(p), old.pointer(p)) { + *dst = src.clone(); + } + } + } + let s: Settings = + serde_json::from_value(incoming).map_err(|e| format!("Invalid settings: {e}"))?; + s.validate()?; + Ok(s) + } +} + +fn json_merge(base: &mut serde_json::Value, patch: serde_json::Value) { + match (base, patch) { + (serde_json::Value::Object(b), serde_json::Value::Object(p)) => { + for (k, v) in p { + json_merge(b.entry(k).or_insert(serde_json::Value::Null), v); + } + } + (b, p) => *b = p, + } +} + +pub fn expand_user_template(tpl: &str, login: &str, default_domain: &str) -> String { + let (local, domain) = match login.split_once('@') { + Some((l, d)) => (l.to_string(), d.to_string()), + None => (login.to_string(), default_domain.to_string()), + }; + tpl.replace("%u", login) + .replace("%l", &local) + .replace("%d", &domain) + .replace("{login}", login) + .replace("{local}", &local) + .replace("{domain}", &domain) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_validate() { + Settings::default().validate().unwrap(); + } + + #[test] + fn secrets_kept() { + let mut s = Settings::default(); + s.smtp.password = "secret".into(); + let red = s.redacted(); + assert_eq!(red.pointer("/smtp/password").unwrap(), "__SET__"); + let merged = s.merge_update(red).unwrap(); + assert_eq!(merged.smtp.password, "secret"); + } + + #[test] + fn template() { + assert_eq!( + expand_user_template("/dav/%u/", "a@b.pl", "x"), + "/dav/a@b.pl/" + ); + assert_eq!( + expand_user_template("{local}@{domain}", "jan", "firma.pl"), + "jan@firma.pl" + ); + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..44cfaed --- /dev/null +++ b/src/db.rs @@ -0,0 +1,735 @@ +//! SQLite persistence (WAL). All queries are parameterised. + +use std::path::Path; +use std::str::FromStr; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::Serialize; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::{FromRow, Row, SqlitePool}; + +use crate::config::Settings; +use crate::util::now; + +pub type Db = SqlitePool; + +pub async fn open(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir).with_context(|| format!("create {}", data_dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700)); + } + let path = data_dir.join("active-push.db"); + let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(15)) + // new databases release freed pages incrementally (see housekeeping) + .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental); + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(opts) + .await + .with_context(|| format!("open database {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for f in ["active-push.db", "active-push.db-wal", "active-push.db-shm"] { + let _ = + std::fs::set_permissions(data_dir.join(f), std::fs::Permissions::from_mode(0o600)); + } + } + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(pool) +} + +// ------------------------------------------------------------------ settings + +pub async fn load_settings(db: &Db) -> Result<(Settings, i64)> { + let row = sqlx::query("SELECT json, revision FROM settings WHERE id = 1") + .fetch_optional(db) + .await?; + match row { + Some(r) => { + let json: String = r.get(0); + let rev: i64 = r.get(1); + let s: Settings = serde_json::from_str(&json).context("stored settings are invalid")?; + Ok((s, rev)) + } + None => { + let s = Settings::default(); + save_settings(db, &s).await?; + Ok((s, 1)) + } + } +} + +pub async fn save_settings(db: &Db, s: &Settings) -> Result { + let json = serde_json::to_string(s)?; + let r = sqlx::query( + "INSERT INTO settings (id, json, revision, updated_at) VALUES (1, ?, 1, ?) + ON CONFLICT(id) DO UPDATE SET json = excluded.json, revision = settings.revision + 1, updated_at = excluded.updated_at + RETURNING revision", + ) + .bind(json) + .bind(now()) + .fetch_one(db) + .await?; + Ok(r.get(0)) +} + +// ------------------------------------------------------------------ devices + +#[derive(Debug, Clone, FromRow, Serialize, Default)] +pub struct Device { + pub id: i64, + pub device_id: String, + pub username: String, + pub device_type: String, + pub user_agent: String, + pub model: String, + pub imei: String, + pub friendly_name: String, + pub os: String, + pub os_language: String, + pub phone_number: String, + pub mobile_operator: String, + pub as_version: String, + pub policy_key: i64, + pub policy_temp_key: i64, + pub policy_name: String, + pub policy_hash: String, + pub wipe_status: i64, + pub wipe_requested_at: Option, + pub wipe_requested_by: Option, + pub wipe_completed_at: Option, + pub blocked: i64, + pub approved: i64, + pub first_seen: i64, + pub last_seen: i64, + pub last_ip: String, + pub last_command: String, + pub requests: i64, + #[serde(skip)] + pub recovery_password: String, + #[serde(skip)] + pub ping_folders: String, + pub ping_lifetime: i64, + #[serde(skip)] + pub last_sync_request: String, + pub notes: String, +} + +/// Wipe status values (Z-Push SYNC_PROVISION_RWSTATUS_*). +pub const WIPE_NA: i64 = 0; +pub const WIPE_OK: i64 = 1; +pub const WIPE_PENDING: i64 = 2; +pub const WIPE_REQUESTED: i64 = 4; +pub const WIPE_WIPED: i64 = 8; +pub const WIPE_ACCOUNT_PENDING: i64 = 16; +pub const WIPE_ACCOUNT_REQUESTED: i64 = 32; +pub const WIPE_ACCOUNT_WIPED: i64 = 64; + +pub async fn device_get_or_create( + db: &Db, + device_id: &str, + username: &str, + device_type: &str, + user_agent: &str, + approved_default: bool, +) -> Result<(Device, bool)> { + if let Some(d) = device_get(db, device_id, username).await? { + return Ok((d, false)); + } + let ts = now(); + sqlx::query( + "INSERT OR IGNORE INTO devices (device_id, username, device_type, user_agent, first_seen, last_seen, approved) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(device_id) + .bind(username) + .bind(device_type) + .bind(user_agent) + .bind(ts) + .bind(ts) + .bind(approved_default as i64) + .execute(db) + .await?; + let d = device_get(db, device_id, username) + .await? + .context("device insert failed")?; + Ok((d, true)) +} + +pub async fn device_get(db: &Db, device_id: &str, username: &str) -> Result> { + Ok( + sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE device_id = ? AND username = ?") + .bind(device_id) + .bind(username) + .fetch_optional(db) + .await?, + ) +} + +pub async fn device_by_id(db: &Db, id: i64) -> Result> { + Ok( + sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE id = ?") + .bind(id) + .fetch_optional(db) + .await?, + ) +} + +pub async fn device_list(db: &Db) -> Result> { + Ok( + sqlx::query_as::<_, Device>("SELECT * FROM devices ORDER BY last_seen DESC") + .fetch_all(db) + .await?, + ) +} + +pub async fn device_touch( + db: &Db, + id: i64, + ip: &str, + command: &str, + as_version: &str, + user_agent: &str, + device_type: &str, +) -> Result<()> { + sqlx::query( + "UPDATE devices SET last_seen = ?, last_ip = ?, last_command = ?, requests = requests + 1, + as_version = CASE WHEN ? = '' THEN as_version ELSE ? END, + user_agent = CASE WHEN ? = '' THEN user_agent ELSE ? END, + device_type = CASE WHEN ? = '' THEN device_type ELSE ? END + WHERE id = ?", + ) + .bind(now()) + .bind(ip) + .bind(command) + .bind(as_version) + .bind(as_version) + .bind(user_agent) + .bind(user_agent) + .bind(device_type) + .bind(device_type) + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +#[derive(Debug, Default, Clone)] +pub struct DeviceInfo { + pub model: Option, + pub imei: Option, + pub friendly_name: Option, + pub os: Option, + pub os_language: Option, + pub phone_number: Option, + pub mobile_operator: Option, + pub user_agent: Option, +} + +pub async fn device_set_info(db: &Db, id: i64, i: &DeviceInfo) -> Result<()> { + sqlx::query( + "UPDATE devices SET model = COALESCE(?, model), imei = COALESCE(?, imei), friendly_name = COALESCE(?, friendly_name), + os = COALESCE(?, os), os_language = COALESCE(?, os_language), phone_number = COALESCE(?, phone_number), + mobile_operator = COALESCE(?, mobile_operator), user_agent = COALESCE(?, user_agent) WHERE id = ?", + ) + .bind(&i.model) + .bind(&i.imei) + .bind(&i.friendly_name) + .bind(&i.os) + .bind(&i.os_language) + .bind(&i.phone_number) + .bind(&i.mobile_operator) + .bind(&i.user_agent) + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn device_set_field( + db: &Db, + id: i64, + field: DeviceField, + value: Option, + text: Option<&str>, +) -> Result<()> { + let col = match field { + DeviceField::PolicyKey => "policy_key", + DeviceField::PolicyTempKey => "policy_temp_key", + DeviceField::WipeStatus => "wipe_status", + DeviceField::Blocked => "blocked", + DeviceField::Approved => "approved", + DeviceField::PingLifetime => "ping_lifetime", + DeviceField::PolicyName => "policy_name", + DeviceField::PolicyHash => "policy_hash", + DeviceField::RecoveryPassword => "recovery_password", + DeviceField::PingFolders => "ping_folders", + DeviceField::LastSyncRequest => "last_sync_request", + DeviceField::Notes => "notes", + }; + let sql = format!("UPDATE devices SET {col} = ? WHERE id = ?"); + let q = sqlx::query(&sql); + let q = match (value, text) { + (Some(v), _) => q.bind(v), + (None, Some(t)) => q.bind(t.to_string()), + (None, None) => q.bind(Option::::None), + }; + q.bind(id).execute(db).await?; + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +pub enum DeviceField { + PolicyKey, + PolicyTempKey, + WipeStatus, + Blocked, + Approved, + PingLifetime, + PolicyName, + PolicyHash, + RecoveryPassword, + PingFolders, + LastSyncRequest, + Notes, +} + +pub async fn device_request_wipe(db: &Db, id: i64, by: &str, account_only: bool) -> Result<()> { + let st = if account_only { + WIPE_ACCOUNT_PENDING + } else { + WIPE_PENDING + }; + sqlx::query("UPDATE devices SET wipe_status = ?, wipe_requested_at = ?, wipe_requested_by = ?, wipe_completed_at = NULL WHERE id = ?") + .bind(st) + .bind(now()) + .bind(by) + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn device_wipe_done(db: &Db, id: i64, status: i64) -> Result<()> { + sqlx::query("UPDATE devices SET wipe_status = ?, wipe_completed_at = ? WHERE id = ?") + .bind(status) + .bind(now()) + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn device_delete(db: &Db, id: i64) -> Result<()> { + sqlx::query("DELETE FROM devices WHERE id = ?") + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn device_reset_states(db: &Db, id: i64) -> Result<()> { + sqlx::query("DELETE FROM sync_states WHERE device_rowid = ?") + .bind(id) + .execute(db) + .await?; + sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ?") + .bind(id) + .execute(db) + .await?; + sqlx::query("UPDATE devices SET ping_folders = '', last_sync_request = '' WHERE id = ?") + .bind(id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn devices_cleanup_inactive(db: &Db, days: u32) -> Result { + if days == 0 { + return Ok(0); + } + let cutoff = now() - days as i64 * 86400; + let r = sqlx::query( + "DELETE FROM devices WHERE last_seen < ? AND wipe_status NOT IN (2, 4, 16, 32)", + ) + .bind(cutoff) + .execute(db) + .await?; + Ok(r.rows_affected()) +} + +// ------------------------------------------------------------------ states + +pub async fn state_get( + db: &Db, + dev: i64, + collection: &str, + uuid: &str, + counter: i64, +) -> Result>> { + let r = sqlx::query( + "SELECT data FROM sync_states WHERE device_rowid = ? AND collection = ? AND uuid = ? AND counter = ?", + ) + .bind(dev) + .bind(collection) + .bind(uuid) + .bind(counter) + .fetch_optional(db) + .await?; + Ok(r.map(|r| r.get(0))) +} + +/// Store state `counter` and drop everything older than `counter - 1` +/// (the previous state is kept so a device may retry a lost response). +pub async fn state_put( + db: &Db, + dev: i64, + collection: &str, + uuid: &str, + counter: i64, + data: &[u8], +) -> Result<()> { + let mut tx = db.begin().await?; + sqlx::query( + "INSERT OR REPLACE INTO sync_states (device_rowid, collection, uuid, counter, data, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(dev) + .bind(collection) + .bind(uuid) + .bind(counter) + .bind(data) + .bind(now()) + .execute(&mut *tx) + .await?; + sqlx::query( + "DELETE FROM sync_states WHERE device_rowid = ? AND collection = ? AND (uuid != ? OR counter < ?)", + ) + .bind(dev) + .bind(collection) + .bind(uuid) + .bind(counter - 1) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub async fn state_delete_collection(db: &Db, dev: i64, collection: &str) -> Result<()> { + sqlx::query("DELETE FROM sync_states WHERE device_rowid = ? AND collection = ?") + .bind(dev) + .bind(collection) + .execute(db) + .await?; + sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ? AND collection = ?") + .bind(dev) + .bind(collection) + .execute(db) + .await?; + Ok(()) +} + +/// Latest state per collection for a device (used by Ping/GetItemEstimate when no key is given). +pub async fn state_latest( + db: &Db, + dev: i64, + collection: &str, +) -> Result)>> { + let r = sqlx::query( + "SELECT uuid, counter, data FROM sync_states WHERE device_rowid = ? AND collection = ? ORDER BY created_at DESC, counter DESC LIMIT 1", + ) + .bind(dev) + .bind(collection) + .fetch_optional(db) + .await?; + Ok(r.map(|r| (r.get(0), r.get(1), r.get(2)))) +} + +pub async fn state_collections(db: &Db, dev: i64) -> Result> { + let rows = sqlx::query("SELECT DISTINCT collection FROM sync_states WHERE device_rowid = ?") + .bind(dev) + .fetch_all(db) + .await?; + Ok(rows.into_iter().map(|r| r.get(0)).collect()) +} + +// ------------------------------------------------------------------ folder / item maps + +pub async fn folder_short_id(db: &Db, user: &str, backend_id: &str) -> Result { + if let Some(r) = + sqlx::query("SELECT short_id FROM folder_map WHERE username = ? AND backend_id = ?") + .bind(user) + .bind(backend_id) + .fetch_optional(db) + .await? + { + return Ok(r.get(0)); + } + let r = sqlx::query( + "INSERT INTO folder_map (username, backend_id, short_id) + VALUES (?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM folder_map WHERE username = ?)) + ON CONFLICT(username, backend_id) DO UPDATE SET short_id = folder_map.short_id + RETURNING short_id", + ) + .bind(user) + .bind(backend_id) + .bind(user) + .fetch_one(db) + .await?; + Ok(r.get(0)) +} + +pub async fn folder_backend_id(db: &Db, user: &str, short_id: &str) -> Result> { + let Ok(n) = short_id.parse::() else { + return Ok(None); + }; + let r = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ? AND short_id = ?") + .bind(user) + .bind(n) + .fetch_optional(db) + .await?; + Ok(r.map(|r| r.get(0))) +} + +pub async fn folder_rename_backend(db: &Db, user: &str, old: &str, new: &str) -> Result<()> { + // a stale mapping of a previously deleted folder with the new name must go + sqlx::query("DELETE FROM folder_map WHERE username = ? AND backend_id = ?") + .bind(user) + .bind(new) + .execute(db) + .await?; + sqlx::query("UPDATE folder_map SET backend_id = ? WHERE username = ? AND backend_id = ?") + .bind(new) + .bind(user) + .bind(old) + .execute(db) + .await?; + Ok(()) +} + +pub async fn item_short(db: &Db, user: &str, folder: &str, long_id: &str) -> Result { + if let Some(r) = sqlx::query( + "SELECT short_id FROM item_map WHERE username = ? AND folder = ? AND long_id = ?", + ) + .bind(user) + .bind(folder) + .bind(long_id) + .fetch_optional(db) + .await? + { + return Ok(r.get(0)); + } + let r = sqlx::query( + "INSERT INTO item_map (username, folder, long_id, short_id) + VALUES (?, ?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM item_map WHERE username = ? AND folder = ?)) + ON CONFLICT(username, folder, long_id) DO UPDATE SET short_id = item_map.short_id + RETURNING short_id", + ) + .bind(user) + .bind(folder) + .bind(long_id) + .bind(user) + .bind(folder) + .fetch_one(db) + .await?; + Ok(r.get(0)) +} + +pub async fn item_long(db: &Db, user: &str, folder: &str, short: i64) -> Result> { + let r = sqlx::query( + "SELECT long_id FROM item_map WHERE username = ? AND folder = ? AND short_id = ?", + ) + .bind(user) + .bind(folder) + .bind(short) + .fetch_optional(db) + .await?; + Ok(r.map(|r| r.get(0))) +} + +// ------------------------------------------------------------------ folder stats + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct FolderStat { + pub collection: String, + pub folder_name: String, + pub folder_class: String, + pub last_sync: i64, + pub items_out: i64, + pub items_in: i64, + pub item_count: i64, + pub last_status: i64, + pub window_size: i64, + pub loop_hits: i64, +} + +#[allow(clippy::too_many_arguments)] +pub async fn folder_stat_update( + db: &Db, + dev: i64, + collection: &str, + name: &str, + class: &str, + out: i64, + inn: i64, + count: i64, + status: i64, + window: i64, + loop_hit: bool, +) -> Result<()> { + sqlx::query( + "INSERT INTO folder_stats (device_rowid, collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_rowid, collection) DO UPDATE SET + folder_name = CASE WHEN excluded.folder_name = '' THEN folder_name ELSE excluded.folder_name END, + folder_class = CASE WHEN excluded.folder_class = '' THEN folder_class ELSE excluded.folder_class END, + last_sync = excluded.last_sync, items_out = items_out + excluded.items_out, items_in = items_in + excluded.items_in, + item_count = excluded.item_count, last_status = excluded.last_status, window_size = excluded.window_size, + loop_hits = loop_hits + excluded.loop_hits", + ) + .bind(dev) + .bind(collection) + .bind(name) + .bind(class) + .bind(now()) + .bind(out) + .bind(inn) + .bind(count) + .bind(status) + .bind(window) + .bind(loop_hit as i64) + .execute(db) + .await?; + Ok(()) +} + +pub async fn folder_stats(db: &Db, dev: i64) -> Result> { + Ok(sqlx::query_as::<_, FolderStat>( + "SELECT collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits + FROM folder_stats WHERE device_rowid = ? ORDER BY folder_class, folder_name", + ) + .bind(dev) + .fetch_all(db) + .await?) +} + +// ------------------------------------------------------------------ audit + +pub async fn audit(db: &Db, actor: &str, action: &str, target: &str, ip: &str, details: &str) { + let _ = sqlx::query( + "INSERT INTO audit_log (ts, actor, action, target, ip, details) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(now()) + .bind(actor) + .bind(action) + .bind(target) + .bind(ip) + .bind(details) + .execute(db) + .await; +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditEntry { + pub id: i64, + pub ts: i64, + pub actor: String, + pub action: String, + pub target: String, + pub ip: String, + pub details: String, +} + +pub async fn audit_list(db: &Db, limit: i64) -> Result> { + Ok( + sqlx::query_as::<_, AuditEntry>("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?") + .bind(limit) + .fetch_all(db) + .await?, + ) +} + +pub async fn housekeeping(db: &Db, s: &Settings) -> Result<()> { + let ts = now(); + sqlx::query("DELETE FROM admin_sessions WHERE expires_at < ?") + .bind(ts) + .execute(db) + .await?; + sqlx::query("DELETE FROM setup_token WHERE expires_at < ?") + .bind(ts) + .execute(db) + .await?; + sqlx::query("DELETE FROM stats_hourly WHERE hour < ?") + .bind(ts - s.logging.stats_retention_days as i64 * 86400) + .execute(db) + .await?; + sqlx::query("DELETE FROM audit_log WHERE ts < ?") + .bind(ts - s.logging.audit_retention_days as i64 * 86400) + .execute(db) + .await?; + let removed = devices_cleanup_inactive(db, s.panel.device_inactive_days).await?; + if removed > 0 { + tracing::info!(removed, "removed inactive devices"); + } + // id maps of accounts that no longer have any device + sqlx::query( + "DELETE FROM item_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)", + ) + .execute(db) + .await?; + sqlx::query( + "DELETE FROM folder_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)", + ) + .execute(db) + .await?; + // states of collections a device stopped syncing more than a year ago + sqlx::query("DELETE FROM sync_states WHERE created_at < ?") + .bind(ts - 400 * 86400) + .execute(db) + .await?; + compact(db).await; + Ok(()) +} + +/// Keep the database files small: checkpoint/truncate the WAL and give free pages back. +pub async fn compact(db: &Db) { + let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(db) + .await; + let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum") + .fetch_one(db) + .await + .unwrap_or(0); + let free: i64 = sqlx::query_scalar("PRAGMA freelist_count") + .fetch_one(db) + .await + .unwrap_or(0); + let pages: i64 = sqlx::query_scalar("PRAGMA page_count") + .fetch_one(db) + .await + .unwrap_or(1); + if mode == 0 { + // databases created by 0.1.0: switch to incremental vacuum once (one full VACUUM) + tracing::info!("converting database to incremental auto-vacuum"); + if let Ok(mut c) = db.acquire().await { + let _ = sqlx::query("PRAGMA auto_vacuum = INCREMENTAL") + .execute(&mut *c) + .await; + let _ = sqlx::query("VACUUM").execute(&mut *c).await; + } + } else if free > 256 && free * 5 > pages { + let _ = sqlx::query("PRAGMA incremental_vacuum").execute(db).await; + } + let _ = sqlx::query("PRAGMA optimize").execute(db).await; + let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(db) + .await; +} diff --git a/src/eas/convert/calendar.rs b/src/eas/convert/calendar.rs new file mode 100644 index 0000000..c19f2d0 --- /dev/null +++ b/src/eas/convert/calendar.rs @@ -0,0 +1,1159 @@ +//! iCalendar VEVENT <-> EAS Calendar. + +use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc, Weekday}; +use chrono_tz::Tz; + +use super::{categories, categories_element, incoming_body, pim_body, Options}; +use crate::backend::ical::{self, Component, Property}; +use crate::backend::tz::{self, IcalTime}; +use crate::wbxml::Element; + +const DOW: [&str; 7] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + +pub struct CalContext<'a> { + pub ver: u32, + pub opts: &'a Options, + pub default_tz: Tz, + pub user_email: &'a str, +} + +fn mail_of(p: &Property) -> String { + let v = p.value.trim(); + let v = v + .strip_prefix("mailto:") + .or_else(|| v.strip_prefix("MAILTO:")) + .or_else(|| v.strip_prefix("Mailto:")) + .unwrap_or(v); + v.to_string() +} + +fn duration_minutes(s: &str) -> Option { + // -PT15M, PT1H, -P1D, -PT1H30M, P1W + let neg = s.starts_with('-'); + let s = s.trim_start_matches(['-', '+']).strip_prefix('P')?; + let mut total = 0i64; + let mut num = String::new(); + let mut in_time = false; + for c in s.chars() { + match c { + 'T' => in_time = true, + '0'..='9' => num.push(c), + 'W' => { + total += num.parse::().ok()? * 7 * 1440; + num.clear() + } + 'D' => { + total += num.parse::().ok()? * 1440; + num.clear() + } + 'H' if in_time => { + total += num.parse::().ok()? * 60; + num.clear() + } + 'M' if in_time => { + total += num.parse::().ok()?; + num.clear() + } + 'S' if in_time => num.clear(), + _ => return None, + } + } + Some(if neg { -total } else { total }) +} + +pub fn reminder_minutes(ev: &Component) -> Option { + let alarm = ev.child("VALARM")?; + let trig = alarm.prop("TRIGGER")?; + if trig + .param("VALUE") + .map(|v| v.eq_ignore_ascii_case("DATE-TIME")) + .unwrap_or(false) + { + return Some(15); + } + let m = duration_minutes(trig.value.trim())?; + Some((-m).max(0)) +} + +fn busy_status(ev: &Component) -> u8 { + if let Some(b) = ev.text("X-MICROSOFT-CDO-BUSYSTATUS") { + return match b.to_ascii_uppercase().as_str() { + "FREE" => 0, + "TENTATIVE" => 1, + "OOF" => 3, + _ => 2, + }; + } + if ev + .text("TRANSP") + .map(|t| t.eq_ignore_ascii_case("TRANSPARENT")) + .unwrap_or(false) + { + return 0; + } + if ev + .text("STATUS") + .map(|t| t.eq_ignore_ascii_case("TENTATIVE")) + .unwrap_or(false) + { + return 1; + } + 2 +} + +fn sensitivity(ev: &Component) -> &'static str { + match ev + .text("CLASS") + .unwrap_or_default() + .to_ascii_uppercase() + .as_str() + { + "PRIVATE" => "2", + "CONFIDENTIAL" => "3", + _ => "0", + } +} + +fn partstat_to_eas(p: &str) -> &'static str { + match p.to_ascii_uppercase().as_str() { + "ACCEPTED" => "3", + "TENTATIVE" => "2", + "DECLINED" => "4", + "NEEDS-ACTION" => "5", + _ => "0", + } +} + +fn eas_to_partstat(s: &str) -> &'static str { + match s { + "3" => "ACCEPTED", + "2" => "TENTATIVE", + "4" => "DECLINED", + _ => "NEEDS-ACTION", + } +} + +fn end_of(ev: &Component, start: &IcalTime, dtz: Tz) -> DateTime { + if let Some(e) = ev.prop("DTEND").and_then(|p| tz::parse_ical_time(p, dtz)) { + return e.utc; + } + if let Some(d) = ev.text("DURATION").and_then(|d| duration_minutes(&d)) { + return start.utc + chrono::Duration::minutes(d); + } + if start.all_day { + start.utc + chrono::Duration::days(1) + } else { + start.utc + } +} + +pub fn masters_and_overrides(cal: &Component, comp: &str) -> (Option, Vec) { + let mut master = None; + let mut ovr = Vec::new(); + for c in cal.children_named(comp) { + if c.prop("RECURRENCE-ID").is_some() { + ovr.push(c.clone()); + } else if master.is_none() { + master = Some(c.clone()); + } + } + if master.is_none() && !ovr.is_empty() { + master = Some(ovr.remove(0)); + } + (master, ovr) +} + +// ------------------------------------------------------------------ RRULE -> EAS + +pub fn rrule_to_eas( + rrule: &str, + start: &DateTime, + tzone: &Tz, + ns: &str, + all_day: bool, +) -> Option { + let mut parts = std::collections::HashMap::new(); + for kv in rrule.split(';') { + if let Some((k, v)) = kv.split_once('=') { + parts.insert(k.trim().to_ascii_uppercase(), v.trim().to_string()); + } + } + let freq = parts.get("FREQ")?.to_ascii_uppercase(); + let local = start.with_timezone(tzone); + let interval: u32 = parts + .get("INTERVAL") + .and_then(|v| v.parse().ok()) + .unwrap_or(1); + let mut rec = Element::new(&format!("{ns}:Recurrence")); + let byday: Vec<(i32, u32)> = parts + .get("BYDAY") + .map(|v| { + v.split(',') + .filter_map(|d| { + let d = d.trim(); + if d.len() < 2 { + return None; + } + let (n, wd) = d.split_at(d.len() - 2); + let idx = DOW.iter().position(|x| x.eq_ignore_ascii_case(wd))? as u32; + Some((n.parse::().unwrap_or(0), idx)) + }) + .collect() + }) + .unwrap_or_default(); + let setpos: Option = parts + .get("BYSETPOS") + .and_then(|v| v.split(',').next()?.parse().ok()); + let mask = |days: &[(i32, u32)]| days.iter().fold(0u32, |m, (_, d)| m | (1 << d)); + let wom = |n: i32| if !(0..5).contains(&n) { 5 } else { n as u32 }; + let start_dow = local.weekday().num_days_from_sunday(); + let (typ, extra): (u32, Vec<(&str, String)>) = match freq.as_str() { + "DAILY" => { + if !byday.is_empty() { + (1, vec![("DayOfWeek", mask(&byday).to_string())]) + } else { + (0, vec![]) + } + } + "WEEKLY" => { + let m = if byday.is_empty() { + 1 << start_dow + } else { + mask(&byday) + }; + (1, vec![("DayOfWeek", m.to_string())]) + } + "MONTHLY" => { + let nth = byday + .iter() + .find(|(n, _)| *n != 0) + .map(|(n, _)| *n) + .or(setpos); + if let (Some(n), false) = (nth, byday.is_empty()) { + ( + 3, + vec![ + ("DayOfWeek", mask(&byday).to_string()), + ("WeekOfMonth", wom(n).to_string()), + ], + ) + } else { + let d = parts + .get("BYMONTHDAY") + .and_then(|v| v.split(',').next()?.parse::().ok()) + .map(|d| if d < 0 { 31 } else { d as u32 }) + .unwrap_or(local.day()); + (2, vec![("DayOfMonth", d.to_string())]) + } + } + "YEARLY" => { + let month = parts + .get("BYMONTH") + .and_then(|v| v.split(',').next()?.parse::().ok()) + .unwrap_or(local.month()); + let nth = byday + .iter() + .find(|(n, _)| *n != 0) + .map(|(n, _)| *n) + .or(setpos); + if let (Some(n), false) = (nth, byday.is_empty()) { + ( + 6, + vec![ + ("DayOfWeek", mask(&byday).to_string()), + ("WeekOfMonth", wom(n).to_string()), + ("MonthOfYear", month.to_string()), + ], + ) + } else { + let d = parts + .get("BYMONTHDAY") + .and_then(|v| v.split(',').next()?.parse::().ok()) + .unwrap_or(local.day()); + ( + 5, + vec![ + ("DayOfMonth", d.to_string()), + ("MonthOfYear", month.to_string()), + ], + ) + } + } + _ => return None, + }; + rec.push_text(&format!("{ns}:Type"), typ.to_string()); + if let Some(u) = parts.get("UNTIL") { + let until = if u.len() == 8 { + NaiveDate::parse_from_str(u, "%Y%m%d").ok().and_then(|d| { + let t = if all_day { + d.and_hms_opt(0, 0, 0) + } else { + d.and_hms_opt(23, 59, 59) + }?; + tzone + .from_local_datetime(&t) + .earliest() + .map(|x| x.with_timezone(&Utc)) + }) + } else { + tz::parse_eas_date(u).or_else(|| { + chrono::NaiveDateTime::parse_from_str(u, "%Y%m%dT%H%M%S") + .ok() + .and_then(|n| { + tzone + .from_local_datetime(&n) + .earliest() + .map(|x| x.with_timezone(&Utc)) + }) + }) + }; + if let Some(u) = until { + rec.push_text(&format!("{ns}:Until"), tz::fmt_compact(&u)); + } + } + if let Some(c) = parts.get("COUNT") { + rec.push_text(&format!("{ns}:Occurrences"), c.clone()); + } + rec.push_text(&format!("{ns}:Interval"), interval.to_string()); + for (k, v) in extra { + rec.push_text(&format!("{ns}:{k}"), v); + } + Some(rec) +} + +// ------------------------------------------------------------------ EAS -> RRULE + +pub fn eas_to_rrule(rec: &Element, ns: &str, all_day: bool, tzone: &Tz) -> Option { + let g = |n: &str| rec.child_u32(&format!("{ns}:{n}")); + let typ = g("Type")?; + let interval = g("Interval").unwrap_or(1).max(1); + let dow_mask = g("DayOfWeek").unwrap_or(0); + let days: Vec<&str> = (0..7) + .filter(|i| dow_mask & (1 << i) != 0) + .map(|i| DOW[i]) + .collect(); + let week = g("WeekOfMonth").unwrap_or(1); + let n = if week >= 5 { -1 } else { week as i32 }; + let mut r = match typ { + 0 => { + if days.is_empty() { + "FREQ=DAILY".to_string() + } else { + format!("FREQ=WEEKLY;BYDAY={}", days.join(",")) + } + } + 1 => { + if days.is_empty() { + "FREQ=WEEKLY".to_string() + } else { + format!("FREQ=WEEKLY;BYDAY={}", days.join(",")) + } + } + 2 => format!("FREQ=MONTHLY;BYMONTHDAY={}", g("DayOfMonth").unwrap_or(1)), + 3 => { + if days.len() == 1 { + format!("FREQ=MONTHLY;BYDAY={n}{}", days[0]) + } else { + format!("FREQ=MONTHLY;BYDAY={};BYSETPOS={n}", days.join(",")) + } + } + 5 => format!( + "FREQ=YEARLY;BYMONTH={};BYMONTHDAY={}", + g("MonthOfYear").unwrap_or(1), + g("DayOfMonth").unwrap_or(1) + ), + 6 => { + let m = g("MonthOfYear").unwrap_or(1); + if days.len() == 1 { + format!("FREQ=YEARLY;BYMONTH={m};BYDAY={n}{}", days[0]) + } else { + format!( + "FREQ=YEARLY;BYMONTH={m};BYDAY={};BYSETPOS={n}", + days.join(",") + ) + } + } + _ => return None, + }; + if interval > 1 { + r.push_str(&format!(";INTERVAL={interval}")); + } + if let Some(u) = rec + .child_text(&format!("{ns}:Until")) + .and_then(|u| tz::parse_eas_date(&u)) + { + if all_day { + r.push_str(&format!( + ";UNTIL={}", + u.with_timezone(tzone).format("%Y%m%d") + )); + } else { + r.push_str(&format!(";UNTIL={}", tz::fmt_compact(&u))); + } + } else if let Some(c) = g("Occurrences") { + r.push_str(&format!(";COUNT={c}")); + } + Some(r) +} + +// ------------------------------------------------------------------ iCal -> EAS + +pub fn to_eas(ics: &str, ctx: &CalContext) -> Option { + let cals = ical::parse(ics); + let cal = cals.iter().find(|c| c.name == "VCALENDAR")?; + let (master, overrides) = masters_and_overrides(cal, "VEVENT"); + let ev = master?; + let dtz = ctx.default_tz; + let start = ev + .prop("DTSTART") + .and_then(|p| tz::parse_ical_time(p, dtz))?; + let end = end_of(&ev, &start, dtz); + let tzone = start.tz.unwrap_or(dtz); + let mut app = Element::new("AirSync:ApplicationData"); + app.push_text("Calendar:TimeZone", tz::blob_for(&tzone, start.utc.year())); + app.push_text( + "Calendar:AllDayEvent", + if start.all_day { "1" } else { "0" }, + ); + let desc = ev.text("DESCRIPTION").unwrap_or_default(); + if ctx.ver >= 120 { + if let Some(b) = pim_body(&desc, ctx.opts, ctx.ver) { + app.push(b); + } + } else if !desc.is_empty() { + app.push_text("Calendar:Body", desc.clone()); + } + app.push_text("Calendar:BusyStatus", busy_status(&ev).to_string()); + let organizer = ev.prop("ORGANIZER"); + let org_mail = organizer.map(mail_of).unwrap_or_default(); + if let Some(o) = organizer { + app.push_opt( + "Calendar:OrganizerName", + o.param("CN").map(|s| s.to_string()), + ); + app.push_text("Calendar:OrganizerEmail", org_mail.clone()); + } + let stamp = ev + .prop("DTSTAMP") + .and_then(|p| tz::parse_ical_time(p, dtz)) + .map(|t| t.utc) + .unwrap_or_else(Utc::now); + app.push_text("Calendar:DtStamp", tz::fmt_compact(&stamp)); + app.push_text("Calendar:EndTime", tz::fmt_compact(&end)); + app.push_opt("Calendar:Location", ev.text("LOCATION")); + if let Some(r) = reminder_minutes(&ev) { + app.push_text("Calendar:Reminder", r.to_string()); + } + app.push_text("Calendar:Sensitivity", sensitivity(&ev)); + app.push_text("Calendar:Subject", ev.text("SUMMARY").unwrap_or_default()); + app.push_text("Calendar:StartTime", tz::fmt_compact(&start.utc)); + if let Some(uid) = ev.text("UID") { + if uid.len() <= 300 { + app.push_text("Calendar:UID", uid); + } + } + let attendees: Vec<&Property> = ev.props_named("ATTENDEE").collect(); + let is_org = org_mail.eq_ignore_ascii_case(ctx.user_email) || org_mail.is_empty(); + let meeting_status = if attendees.is_empty() { + 0 + } else if is_org { + 1 + } else { + 3 + }; + let cancelled = ev + .text("STATUS") + .map(|s| s.eq_ignore_ascii_case("CANCELLED")) + .unwrap_or(false); + let ms = if cancelled && meeting_status != 0 { + meeting_status + 4 + } else { + meeting_status + }; + app.push_text("Calendar:MeetingStatus", ms.to_string()); + if !attendees.is_empty() { + let mut list = Element::new("Calendar:Attendees"); + for a in &attendees { + let mail = mail_of(a); + if mail.is_empty() { + continue; + } + let mut e = Element::new("Calendar:Attendee").with_text("Calendar:Email", mail.clone()); + e.push_text( + "Calendar:Name", + a.param("CN") + .map(|s| s.to_string()) + .unwrap_or_else(|| mail.clone()), + ); + if ctx.ver >= 120 { + e.push_text( + "Calendar:AttendeeStatus", + partstat_to_eas(a.param("PARTSTAT").unwrap_or("")), + ); + let typ = match ( + a.param("CUTYPE") + .unwrap_or("") + .to_ascii_uppercase() + .as_str(), + a.param("ROLE").unwrap_or("").to_ascii_uppercase().as_str(), + ) { + ("RESOURCE", _) | ("ROOM", _) => "3", + (_, "OPT-PARTICIPANT") | (_, "NON-PARTICIPANT") => "2", + _ => "1", + }; + e.push_text("Calendar:AttendeeType", typ); + } + list.push(e); + } + app.push(list); + } + let cats: Vec = ev + .props_named("CATEGORIES") + .flat_map(|p| ical::split_list(&p.value)) + .collect(); + if let Some(c) = categories_element("Calendar", &cats) { + app.push(c); + } + if let Some(rr) = ev.prop("RRULE") { + if let Some(rec) = rrule_to_eas(&rr.value, &start.utc, &tzone, "Calendar", start.all_day) { + app.push(rec); + } + // exceptions + let mut exs = Element::new("Calendar:Exceptions"); + for ex in ev.props_named("EXDATE") { + for v in ex.value.split(',') { + let p = Property { + name: "EXDATE".into(), + params: ex.params.clone(), + value: v.to_string(), + }; + if let Some(t) = tz::parse_ical_time(&p, tzone) { + let t = if t.all_day { + start_on_date(&start, t.date.unwrap(), &tzone) + } else { + t.utc + }; + exs.push( + Element::new("Calendar:Exception") + .with_text("Calendar:Deleted", "1") + .with_text("Calendar:ExceptionStartTime", tz::fmt_compact(&t)), + ); + } + } + } + for o in &overrides { + let Some(rid) = o + .prop("RECURRENCE-ID") + .and_then(|p| tz::parse_ical_time(p, tzone)) + else { + continue; + }; + let rid_t = if rid.all_day { + start_on_date(&start, rid.date.unwrap(), &tzone) + } else { + rid.utc + }; + let mut e = Element::new("Calendar:Exception"); + let ostart = o.prop("DTSTART").and_then(|p| tz::parse_ical_time(p, dtz)); + if o.text("STATUS") + .map(|s| s.eq_ignore_ascii_case("CANCELLED")) + .unwrap_or(false) + { + e.push_text("Calendar:Deleted", "1"); + e.push_text("Calendar:ExceptionStartTime", tz::fmt_compact(&rid_t)); + exs.push(e); + continue; + } + e.push_text("Calendar:ExceptionStartTime", tz::fmt_compact(&rid_t)); + e.push_opt("Calendar:Subject", o.text("SUMMARY")); + if let Some(os) = &ostart { + e.push_text("Calendar:StartTime", tz::fmt_compact(&os.utc)); + e.push_text("Calendar:EndTime", tz::fmt_compact(&end_of(o, os, dtz))); + e.push_text("Calendar:AllDayEvent", if os.all_day { "1" } else { "0" }); + } + e.push_opt("Calendar:Location", o.text("LOCATION")); + if ctx.ver >= 120 { + if let Some(d) = o.text("DESCRIPTION") { + if let Some(b) = pim_body(&d, ctx.opts, ctx.ver) { + e.push(b); + } + } + } + e.push_text("Calendar:BusyStatus", busy_status(o).to_string()); + if let Some(r) = reminder_minutes(o) { + e.push_text("Calendar:Reminder", r.to_string()); + } + exs.push(e); + } + if !exs.children.is_empty() { + app.push(exs); + } + } + if ctx.ver >= 140 { + if meeting_status == 3 { + app.push_text("Calendar:ResponseRequested", "1"); + let me = attendees + .iter() + .find(|a| mail_of(a).eq_ignore_ascii_case(ctx.user_email)); + let rt = me + .and_then(|a| a.param("PARTSTAT")) + .map(|p| match partstat_to_eas(p) { + "3" => "3", + "2" => "2", + "4" => "4", + _ => "5", + }) + .unwrap_or("5"); + app.push_text("Calendar:ResponseType", rt); + } else if meeting_status == 1 { + app.push_text("Calendar:ResponseType", "1"); + } + } + if ctx.ver >= 120 { + app.push_text("AirSyncBase:NativeBodyType", "1"); + } + Some(app) +} + +fn start_on_date(start: &IcalTime, d: NaiveDate, tzone: &Tz) -> DateTime { + let local = start.utc.with_timezone(tzone); + let t = d.and_time(local.time()); + tzone + .from_local_datetime(&t) + .earliest() + .map(|x| x.with_timezone(&Utc)) + .unwrap_or(start.utc) +} + +// ------------------------------------------------------------------ EAS -> iCal + +const MANAGED: &[&str] = &[ + "DTSTART", + "DTEND", + "DURATION", + "SUMMARY", + "LOCATION", + "DESCRIPTION", + "CLASS", + "TRANSP", + "X-MICROSOFT-CDO-BUSYSTATUS", + "ATTENDEE", + "ORGANIZER", + "CATEGORIES", + "RRULE", + "EXDATE", + "RDATE", + "DTSTAMP", + "LAST-MODIFIED", + "SEQUENCE", + "UID", +]; + +fn ensure_vtimezone(cal: &mut Component, tzone: &Tz) { + let exists = cal + .children_named("VTIMEZONE") + .any(|c| c.text("TZID").map(|t| t == tzone.name()).unwrap_or(false)); + if !exists { + cal.children.insert(0, tz::vtimezone(tzone)); + } +} + +fn time_props( + cal: &mut Component, + ev: &mut Component, + start: DateTime, + end: DateTime, + all_day: bool, + tzone: &Tz, + use_tz: bool, +) { + ev.remove("DTSTART"); + ev.remove("DTEND"); + ev.remove("DURATION"); + if all_day { + let sd = start.with_timezone(tzone).date_naive(); + let mut ed = end.with_timezone(tzone).date_naive(); + if ed <= sd { + ed = sd.succ_opt().unwrap_or(sd); + } + ev.add(tz::ical_date("DTSTART", sd)); + ev.add(tz::ical_date("DTEND", ed)); + } else if use_tz && tzone.name() != "UTC" { + ensure_vtimezone(cal, tzone); + ev.add(tz::ical_local("DTSTART", &start, tzone)); + ev.add(tz::ical_local("DTEND", &end, tzone)); + } else { + ev.add(tz::ical_utc("DTSTART", &start)); + ev.add(tz::ical_utc("DTEND", &end)); + } +} + +fn set_alarm(ev: &mut Component, minutes: Option, summary: &str) { + ev.children.retain(|c| c.name != "VALARM"); + if let Some(m) = minutes { + let mut a = Component::new("VALARM"); + a.add(Property::new("ACTION", "DISPLAY")); + a.add(Property::text( + "DESCRIPTION", + if summary.is_empty() { + "Reminder" + } else { + summary + }, + )); + a.add(Property::new("TRIGGER", format!("-PT{m}M"))); + ev.children.push(a); + } +} + +fn apply_common(ev: &mut Component, app: &Element, ns: &str) { + let get = |n: &str| app.child_text(&format!("{ns}:{n}")); + ev.remove("SUMMARY"); + if let Some(s) = get("Subject").filter(|s| !s.is_empty()) { + ev.add(Property::text("SUMMARY", &s)); + } + ev.remove("LOCATION"); + let loc = get("Location").or_else(|| { + app.child("AirSyncBase:Location") + .and_then(|l| l.child_text("AirSyncBase:DisplayName")) + }); + if let Some(l) = loc.filter(|s| !s.is_empty()) { + ev.add(Property::text("LOCATION", &l)); + } + if app.has("AirSyncBase:Body") || app.has(&format!("{ns}:Body")) { + ev.remove("DESCRIPTION"); + if let Some(b) = incoming_body(app, ns).filter(|s| !s.trim().is_empty()) { + ev.add(Property::text("DESCRIPTION", &b)); + } + } + if let Some(s) = get("Sensitivity") { + ev.remove("CLASS"); + ev.add(Property::new( + "CLASS", + match s.as_str() { + "2" => "PRIVATE", + "3" => "CONFIDENTIAL", + _ => "PUBLIC", + }, + )); + } + if let Some(b) = get("BusyStatus") { + ev.remove("TRANSP"); + ev.remove("X-MICROSOFT-CDO-BUSYSTATUS"); + let (transp, cdo) = match b.as_str() { + "0" => ("TRANSPARENT", "FREE"), + "1" => ("OPAQUE", "TENTATIVE"), + "3" => ("OPAQUE", "OOF"), + "4" => ("OPAQUE", "WORKINGELSEWHERE"), + _ => ("OPAQUE", "BUSY"), + }; + ev.add(Property::new("TRANSP", transp)); + ev.add(Property::new("X-MICROSOFT-CDO-BUSYSTATUS", cdo)); + } +} + +/// Build/merge an iCalendar object from EAS ApplicationData. +pub fn from_eas( + app: &Element, + existing: Option<&str>, + default_tz: Tz, + user_email: &str, + user_name: &str, +) -> Option { + let mut cal = existing + .and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCALENDAR")) + .unwrap_or_else(|| { + let mut c = Component::new("VCALENDAR"); + c.add(Property::new("VERSION", "2.0")); + c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN")); + c.add(Property::new("CALSCALE", "GREGORIAN")); + c + }); + let (old_master, _old_ovr) = masters_and_overrides(&cal, "VEVENT"); + let mut ev = old_master + .clone() + .unwrap_or_else(|| Component::new("VEVENT")); + let tzone = app + .child_text("Calendar:TimeZone") + .map(|b| tz::tz_from_blob(&b, default_tz)) + .unwrap_or(default_tz); + let all_day = app.child_u32("Calendar:AllDayEvent").unwrap_or(0) == 1; + let start = app + .child_text("Calendar:StartTime") + .and_then(|s| tz::parse_eas_date(&s)) + .or_else(|| { + old_master + .as_ref() + .and_then(|m| m.prop("DTSTART")) + .and_then(|p| tz::parse_ical_time(p, default_tz)) + .map(|t| t.utc) + })?; + let end = app + .child_text("Calendar:EndTime") + .and_then(|s| tz::parse_eas_date(&s)) + .unwrap_or(start + chrono::Duration::hours(1)); + let recurring = app.has("Calendar:Recurrence"); + + // keep unmanaged properties of the old master + ev.props + .retain(|p| !MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m))); + let uid = old_master + .as_ref() + .and_then(|m| m.text("UID")) + .or_else(|| app.child_text("Calendar:UID").filter(|u| !u.is_empty())) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let seq = old_master + .as_ref() + .map(|m| { + m.text("SEQUENCE") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + + 1 + }) + .unwrap_or(0); + ev.props.insert(0, Property::text("UID", &uid)); + ev.add(tz::ical_utc("DTSTAMP", &Utc::now())); + ev.add(tz::ical_utc("LAST-MODIFIED", &Utc::now())); + ev.add(Property::new("SEQUENCE", seq.to_string())); + time_props( + &mut cal, + &mut ev, + start, + end, + all_day, + &tzone, + recurring || existing.map(|e| e.contains("TZID")).unwrap_or(false), + ); + apply_common(&mut ev, app, "Calendar"); + let summary = ev.text("SUMMARY").unwrap_or_default(); + set_alarm(&mut ev, app.child_u32("Calendar:Reminder"), &summary); + if let Some(cats) = categories(app, "Calendar") { + if !cats.is_empty() { + ev.add(Property::new( + "CATEGORIES", + cats.iter() + .map(|c| ical::escape_text(c)) + .collect::>() + .join(","), + )); + } + } else if let Some(m) = &old_master { + for c in m.props_named("CATEGORIES") { + ev.add(c.clone()); + } + } + + // attendees / organizer + let old_att: Vec = old_master + .as_ref() + .map(|m| m.props_named("ATTENDEE").cloned().collect()) + .unwrap_or_default(); + let old_org = old_master + .as_ref() + .and_then(|m| m.prop("ORGANIZER").cloned()); + if let Some(list) = app.child("Calendar:Attendees") { + let mut any = false; + for a in list.children_named("Calendar:Attendee") { + let Some(mail) = a.child_text("Calendar:Email").filter(|m| m.contains('@')) else { + continue; + }; + any = true; + let name = a.child_text("Calendar:Name").unwrap_or_default(); + let prev = old_att + .iter() + .find(|p| mail_of(p).eq_ignore_ascii_case(&mail)); + let partstat = a + .child_text("Calendar:AttendeeStatus") + .filter(|s| s != "0") + .map(|s| eas_to_partstat(&s).to_string()) + .or_else(|| { + prev.and_then(|p| p.param("PARTSTAT")) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "NEEDS-ACTION".into()); + let role = match a.child_text("Calendar:AttendeeType").as_deref() { + Some("2") => "OPT-PARTICIPANT", + _ => "REQ-PARTICIPANT", + }; + let mut p = Property::new("ATTENDEE", format!("mailto:{mail}")); + if !name.is_empty() && name != mail { + p = p.with_param("CN", &name); + } + p = p.with_param("PARTSTAT", &partstat).with_param("ROLE", role); + if a.child_text("Calendar:AttendeeType").as_deref() == Some("3") { + p = p.with_param("CUTYPE", "RESOURCE"); + } + if partstat == "NEEDS-ACTION" { + p = p.with_param("RSVP", "TRUE"); + } + ev.add(p); + } + if any { + let org = old_org.clone().unwrap_or_else(|| { + let mail = app + .child_text("Calendar:OrganizerEmail") + .unwrap_or_else(|| user_email.to_string()); + let name = app + .child_text("Calendar:OrganizerName") + .unwrap_or_else(|| user_name.to_string()); + let mut p = Property::new("ORGANIZER", format!("mailto:{mail}")); + if !name.is_empty() { + p = p.with_param("CN", &name); + } + p + }); + ev.add(org); + } + } else { + for a in old_att { + ev.add(a); + } + if let Some(o) = old_org { + ev.add(o); + } + } + + // recurrence + exceptions + let mut overrides: Vec = Vec::new(); + if let Some(rec) = app.child("Calendar:Recurrence") { + if let Some(rr) = eas_to_rrule(rec, "Calendar", all_day, &tzone) { + ev.add(Property::new("RRULE", rr)); + } + if let Some(exs) = app.child("Calendar:Exceptions") { + for ex in exs.children_named("Calendar:Exception") { + let Some(orig) = ex + .child_text("Calendar:ExceptionStartTime") + .and_then(|s| tz::parse_eas_date(&s)) + else { + continue; + }; + let rid = if all_day { + tz::ical_date("RECURRENCE-ID", orig.with_timezone(&tzone).date_naive()) + } else if tzone.name() != "UTC" { + tz::ical_local("RECURRENCE-ID", &orig, &tzone) + } else { + tz::ical_utc("RECURRENCE-ID", &orig) + }; + if ex.child_u32("Calendar:Deleted").unwrap_or(0) == 1 { + let mut p = rid.clone(); + p.name = "EXDATE".into(); + ev.add(p); + continue; + } + let mut o = Component::new("VEVENT"); + o.add(Property::text("UID", &uid)); + o.add(rid); + o.add(tz::ical_utc("DTSTAMP", &Utc::now())); + let os = ex + .child_text("Calendar:StartTime") + .and_then(|s| tz::parse_eas_date(&s)) + .unwrap_or(orig); + let oe = ex + .child_text("Calendar:EndTime") + .and_then(|s| tz::parse_eas_date(&s)) + .unwrap_or(os + (end - start)); + let oad = ex + .child_u32("Calendar:AllDayEvent") + .map(|v| v == 1) + .unwrap_or(all_day); + time_props(&mut cal, &mut o, os, oe, oad, &tzone, true); + // inherit subject/location when not overridden + let mut merged = ex.clone(); + if !merged.has("Calendar:Subject") { + if let Some(s) = app.child("Calendar:Subject") { + merged.push(s.clone()); + } + } + if !merged.has("Calendar:Location") { + if let Some(s) = app.child("Calendar:Location") { + merged.push(s.clone()); + } + } + apply_common(&mut o, &merged, "Calendar"); + let s = o.text("SUMMARY").unwrap_or_default(); + set_alarm( + &mut o, + ex.child_u32("Calendar:Reminder") + .or(app.child_u32("Calendar:Reminder")), + &s, + ); + overrides.push(o); + } + } + } + + cal.children.retain(|c| c.name != "VEVENT"); + cal.children.push(ev); + cal.children.extend(overrides); + Some(cal.serialize()) +} + +/// Update the PARTSTAT of `user_email` (MeetingResponse) and return the new iCalendar. +pub fn set_partstat(ics: &str, user_email: &str, partstat: &str) -> Option { + let mut cals = ical::parse(ics); + let cal = cals.iter_mut().find(|c| c.name == "VCALENDAR")?; + cal.remove("METHOD"); + for ev in cal.children.iter_mut().filter(|c| c.name == "VEVENT") { + let mut found = false; + for p in ev.props.iter_mut().filter(|p| p.name == "ATTENDEE") { + if mail_of(p).eq_ignore_ascii_case(user_email) { + p.params.retain(|(k, _)| k != "PARTSTAT" && k != "RSVP"); + p.params.push(("PARTSTAT".into(), partstat.into())); + found = true; + } + } + if !found { + ev.add( + Property::new("ATTENDEE", format!("mailto:{user_email}")) + .with_param("PARTSTAT", partstat), + ); + } + } + Some(cal.serialize()) +} + +/// Build an iMIP REPLY for the organizer. +pub fn imip_reply( + ics: &str, + user_email: &str, + user_name: &str, + partstat: &str, +) -> Option<(String, String, String)> { + let cals = ical::parse(ics); + let cal = cals.iter().find(|c| c.name == "VCALENDAR")?; + let (master, _) = masters_and_overrides(cal, "VEVENT"); + let ev = master?; + let org = ev.prop("ORGANIZER").map(mail_of)?; + let mut out = Component::new("VCALENDAR"); + out.add(Property::new("VERSION", "2.0")); + out.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN")); + out.add(Property::new("METHOD", "REPLY")); + for t in cal.children_named("VTIMEZONE") { + out.children.push(t.clone()); + } + let mut r = Component::new("VEVENT"); + for n in [ + "UID", + "DTSTART", + "DTEND", + "DURATION", + "SEQUENCE", + "SUMMARY", + "ORGANIZER", + "RECURRENCE-ID", + ] { + for p in ev.props_named(n) { + r.add(p.clone()); + } + } + r.add(tz::ical_utc("DTSTAMP", &Utc::now())); + let mut a = + Property::new("ATTENDEE", format!("mailto:{user_email}")).with_param("PARTSTAT", partstat); + if !user_name.is_empty() { + a = a.with_param("CN", user_name); + } + r.add(a); + out.children.push(r); + let summary = ev.text("SUMMARY").unwrap_or_default(); + Some((org, summary, out.serialize())) +} + +pub fn weekday_from(i: u32) -> Weekday { + match i { + 0 => Weekday::Sun, + 1 => Weekday::Mon, + 2 => Weekday::Tue, + 3 => Weekday::Wed, + 4 => Weekday::Thu, + 5 => Weekday::Fri, + _ => Weekday::Sat, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx<'a>(o: &'a Options) -> CalContext<'a> { + CalContext { + ver: 141, + opts: o, + default_tz: chrono_tz::Europe::Warsaw, + user_email: "jan@x.pl", + } + } + + #[test] + fn roundtrip_recurring() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:u1\r\nDTSTART;TZID=Europe/Warsaw:20261102T090000\r\nDTEND;TZID=Europe/Warsaw:20261102T100000\r\nSUMMARY:Daily\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10\r\nEXDATE;TZID=Europe/Warsaw:20261104T090000\r\nX-CUSTOM:keep\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let o = Options::default(); + let app = to_eas(ics, &ctx(&o)).unwrap(); + assert_eq!( + app.child_text("Calendar:StartTime").unwrap(), + "20261102T080000Z" + ); + assert_eq!(app.child_text("Calendar:Reminder").unwrap(), "10"); + let rec = app.child("Calendar:Recurrence").unwrap(); + assert_eq!(rec.child_text("Calendar:Type").unwrap(), "1"); + assert_eq!( + rec.child_text("Calendar:DayOfWeek").unwrap(), + (2 | 8).to_string() + ); + assert_eq!(rec.child_text("Calendar:Occurrences").unwrap(), "10"); + let ex = app + .path(&["Calendar:Exceptions", "Calendar:Exception"]) + .unwrap(); + assert_eq!( + ex.child_text("Calendar:ExceptionStartTime").unwrap(), + "20261104T080000Z" + ); + + let back = from_eas( + &app, + Some(ics), + chrono_tz::Europe::Warsaw, + "jan@x.pl", + "Jan", + ) + .unwrap(); + assert!(back.contains("X-CUSTOM:keep")); + assert!(back.contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10")); + assert!(back.contains("DTSTART;TZID=Europe/Warsaw:20261102T090000")); + assert!(back.contains("EXDATE;TZID=Europe/Warsaw:20261104T090000")); + assert!(back.contains("TRIGGER:-PT10M")); + assert!(back.contains("SEQUENCE:1")); + } + + #[test] + fn allday() { + let mut app = Element::new("AirSync:ApplicationData"); + app.push_text( + "Calendar:TimeZone", + tz::blob_for(&chrono_tz::Europe::Warsaw, 2026), + ); + app.push_text("Calendar:AllDayEvent", "1"); + app.push_text("Calendar:StartTime", "20261224T230000Z"); + app.push_text("Calendar:EndTime", "20261225T230000Z"); + app.push_text("Calendar:Subject", "Święta"); + let ics = from_eas(&app, None, chrono_tz::UTC, "jan@x.pl", "Jan").unwrap(); + assert!(ics.contains("DTSTART;VALUE=DATE:20261225")); + assert!(ics.contains("DTEND;VALUE=DATE:20261226")); + } + + #[test] + fn monthly_nth() { + let start = Utc.with_ymd_and_hms(2026, 11, 10, 9, 0, 0).unwrap(); + let e = rrule_to_eas( + "FREQ=MONTHLY;BYDAY=-1FR", + &start, + &chrono_tz::UTC, + "Calendar", + false, + ) + .unwrap(); + assert_eq!(e.child_text("Calendar:Type").unwrap(), "3"); + assert_eq!(e.child_text("Calendar:WeekOfMonth").unwrap(), "5"); + let r = eas_to_rrule(&e, "Calendar", false, &chrono_tz::UTC).unwrap(); + assert_eq!(r, "FREQ=MONTHLY;BYDAY=-1FR"); + } +} diff --git a/src/eas/convert/contact.rs b/src/eas/convert/contact.rs new file mode 100644 index 0000000..2a26218 --- /dev/null +++ b/src/eas/convert/contact.rs @@ -0,0 +1,650 @@ +//! vCard <-> EAS Contacts (with lossless merge of unmapped vCard data). + +use base64::Engine; +use chrono::{NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; + +use super::{categories, categories_element, incoming_body, pim_body, Options}; +use crate::backend::ical::{self, Component, Property}; +use crate::backend::tz; +use crate::wbxml::Element; + +/// Phone slots: (EAS tag, required types, excluded types) +const PHONE_SLOTS: &[(&str, &[&str], &[&str])] = &[ + ("Contacts:MobilePhoneNumber", &["cell"], &[]), + ("Contacts:BusinessFaxNumber", &["work", "fax"], &[]), + ("Contacts:HomeFaxNumber", &["fax"], &["work"]), + ("Contacts:PagerNumber", &["pager"], &[]), + ("Contacts:CarPhoneNumber", &["car"], &[]), + ( + "Contacts:BusinessPhoneNumber", + &["work"], + &["fax", "cell", "pager"], + ), + ( + "Contacts:Business2PhoneNumber", + &["work"], + &["fax", "cell", "pager"], + ), + ("Contacts:HomePhoneNumber", &["home"], &["fax", "cell"]), + ("Contacts:Home2PhoneNumber", &["home"], &["fax", "cell"]), + ("Contacts:RadioPhoneNumber", &["x-radio"], &[]), + ("Contacts:AssistantPhoneNumber", &["x-assistant"], &[]), + ("Contacts2:CompanyMainPhone", &["x-company"], &[]), + ("Contacts2:MMS", &["x-mms"], &[]), +]; + +fn tel_types(slot: &str) -> &'static str { + match slot { + "Contacts:MobilePhoneNumber" => "CELL", + "Contacts:BusinessFaxNumber" => "WORK,FAX", + "Contacts:HomeFaxNumber" => "HOME,FAX", + "Contacts:PagerNumber" => "PAGER", + "Contacts:CarPhoneNumber" => "CAR", + "Contacts:BusinessPhoneNumber" | "Contacts:Business2PhoneNumber" => "WORK,VOICE", + "Contacts:HomePhoneNumber" | "Contacts:Home2PhoneNumber" => "HOME,VOICE", + "Contacts:RadioPhoneNumber" => "X-RADIO", + "Contacts:AssistantPhoneNumber" => "X-ASSISTANT", + "Contacts2:CompanyMainPhone" => "X-COMPANY", + "Contacts2:MMS" => "X-MMS", + _ => "VOICE", + } +} + +/// Assign TEL properties to EAS slots. Returns (slot -> prop index) and leftover indices. +fn assign_tels(tels: &[&Property]) -> (Vec<(&'static str, usize)>, Vec) { + let mut used = vec![false; tels.len()]; + let mut out = Vec::new(); + for (slot, req, exc) in PHONE_SLOTS { + let pos = tels.iter().enumerate().position(|(i, p)| { + if used[i] { + return false; + } + let t = p.types(); + req.iter().all(|r| t.iter().any(|x| x == r)) + && !exc.iter().any(|e| t.iter().any(|x| x == e)) + }); + if let Some(i) = pos { + used[i] = true; + out.push((*slot, i)); + } + } + // untyped / voice-only numbers go to free home/business/mobile slots + for fallback in [ + "Contacts:HomePhoneNumber", + "Contacts:BusinessPhoneNumber", + "Contacts:MobilePhoneNumber", + "Contacts:Home2PhoneNumber", + "Contacts:Business2PhoneNumber", + ] { + if out.iter().any(|(s, _)| *s == fallback) { + continue; + } + if let Some(i) = (0..tels.len()).find(|i| { + !used[*i] && { + let t = tels[*i].types(); + t.is_empty() + || t.iter().all(|x| { + x == "voice" || x == "pref" || x == "other" || x.starts_with("pref") + }) + } + }) { + used[i] = true; + out.push((fallback, i)); + } + } + let left = (0..tels.len()).filter(|i| !used[*i]).collect(); + (out, left) +} + +const ADR_SLOTS: &[(&str, &str)] = &[("Business", "work"), ("Home", "home"), ("Other", "other")]; + +fn assign_adrs(adrs: &[&Property]) -> (Vec<(&'static str, usize)>, Vec) { + let mut used = vec![false; adrs.len()]; + let mut out = Vec::new(); + for (slot, typ) in ADR_SLOTS { + if let Some(i) = + (0..adrs.len()).find(|i| !used[*i] && adrs[*i].types().iter().any(|t| t == typ)) + { + used[i] = true; + out.push((*slot, i)); + } + } + // untyped address -> Other / Home + for (slot, _) in [("Other", ""), ("Home", "")] { + if out.iter().any(|(s, _)| *s == slot) { + continue; + } + if let Some(i) = (0..adrs.len()).find(|i| { + !used[*i] + && adrs[*i] + .types() + .iter() + .all(|t| t == "pref" || t == "postal" || t == "parcel") + }) { + used[i] = true; + out.push((slot, i)); + } + } + let left = (0..adrs.len()).filter(|i| !used[*i]).collect(); + (out, left) +} + +fn bday_to_eas(v: &str, dtz: &Tz) -> Option { + let v = v.trim(); + let d = if v.len() >= 10 && v.as_bytes()[4] == b'-' { + NaiveDate::parse_from_str(&v[..10], "%Y-%m-%d").ok() + } else if v.starts_with("--") { + // --MMDD (no year) -> use 1604 like Apple/Outlook + NaiveDate::parse_from_str(&format!("1604{}", &v[2..6.min(v.len())]), "%Y%m%d").ok() + } else { + NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok() + }?; + let local = dtz + .from_local_datetime(&d.and_hms_opt(0, 0, 0)?) + .earliest()?; + Some(tz::fmt_iso(&local.with_timezone(&Utc))) +} + +fn eas_to_date(s: &str, dtz: &Tz) -> Option { + let t = tz::parse_eas_date(s)?; + Some( + (t + chrono::Duration::hours(12)) + .with_timezone(dtz) + .date_naive(), + ) +} + +pub fn to_eas(vcf: &str, opts: &Options, ver: u32, dtz: Tz, max_picture: usize) -> Option { + let cards = ical::parse(vcf); + let c = cards.iter().find(|c| c.name == "VCARD")?; + let mut app = Element::new("AirSync:ApplicationData"); + let get = |n: &str| c.text(n); + if let Some(b) = get("ANNIVERSARY") + .or_else(|| get("X-ANNIVERSARY")) + .and_then(|v| bday_to_eas(&v, &dtz)) + { + app.push_text("Contacts:Anniversary", b); + } + app.push_opt( + "Contacts:AssistantName", + get("X-ASSISTANT").or_else(|| get("X-MS-ASSISTANT")), + ); + if let Some(b) = get("BDAY").and_then(|v| bday_to_eas(&v, &dtz)) { + app.push_text("Contacts:Birthday", b); + } + let note = get("NOTE").unwrap_or_default(); + if let Some(b) = pim_body(¬e, opts, ver) { + if !note.is_empty() { + app.push(b); + } + } else if !note.is_empty() { + app.push_text("Contacts:Body", note.clone()); + } + // phones + let tels: Vec<&Property> = c.props_named("TEL").collect(); + let (slots, _) = assign_tels(&tels); + // addresses + let adrs: Vec<&Property> = c.props_named("ADR").collect(); + let (aslots, _) = assign_adrs(&adrs); + for (slot, i) in &aslots { + let parts = ical::split_structured(&adrs[*i].value); + let p = |n: usize| parts.get(n).cloned().unwrap_or_default(); + let street = [p(1), p(2)] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join("\n"); + for (suffix, val) in [ + ("City", p(3)), + ("Country", p(6)), + ("PostalCode", p(5)), + ("State", p(4)), + ("Street", street), + ] { + if !val.is_empty() { + app.push_text(&format!("Contacts:{slot}{suffix}"), val); + } + } + } + for (slot, i) in &slots { + if !slot.starts_with("Contacts2") { + app.push_text(slot, tels[*i].text_value()); + } + } + let cats: Vec = c + .props_named("CATEGORIES") + .flat_map(|p| ical::split_list(&p.value)) + .collect(); + if let Some(e) = categories_element("Contacts", &cats) { + app.push(e); + } + if let Some(org) = c.prop("ORG") { + let parts = ical::split_structured(&org.value); + if let Some(n) = parts.first().filter(|s| !s.is_empty()) { + app.push_text("Contacts:CompanyName", n.clone()); + } + if let Some(d) = parts.get(1).filter(|s| !s.is_empty()) { + app.push_text("Contacts:Department", d.clone()); + } + } + for (i, e) in c.props_named("EMAIL").take(3).enumerate() { + app.push_text(&format!("Contacts:Email{}Address", i + 1), e.text_value()); + } + let n = c + .prop("N") + .map(|n| ical::split_structured(&n.value)) + .unwrap_or_default(); + let np = |i: usize| n.get(i).cloned().filter(|s| !s.is_empty()); + let fileas = get("X-EAS-FILEAS") + .or_else(|| get("FN")) + .or_else(|| match (np(0), np(1)) { + (Some(l), Some(f)) => Some(format!("{l}, {f}")), + (Some(l), None) => Some(l), + (None, Some(f)) => Some(f), + _ => None, + }); + app.push_opt("Contacts:FileAs", fileas); + app.push_opt("Contacts:FirstName", np(1)); + app.push_opt("Contacts:JobTitle", get("TITLE")); + app.push_opt("Contacts:LastName", np(0)); + app.push_opt("Contacts:MiddleName", np(2)); + app.push_opt("Contacts:OfficeLocation", get("X-OFFICE-LOCATION")); + app.push_opt("Contacts:Spouse", get("X-SPOUSE")); + app.push_opt("Contacts:Suffix", np(4)); + app.push_opt("Contacts:Title", np(3)); + app.push_opt("Contacts:WebPage", get("URL")); + app.push_opt("Contacts:YomiCompanyName", get("X-PHONETIC-ORG")); + app.push_opt("Contacts:YomiFirstName", get("X-PHONETIC-FIRST-NAME")); + app.push_opt("Contacts:YomiLastName", get("X-PHONETIC-LAST-NAME")); + if let Some(ph) = c.prop("PHOTO") { + let v = ph.value.trim(); + let data = if let Some(idx) = v.find("base64,") { + v[idx + 7..].to_string() + } else if ph + .param("ENCODING") + .map(|e| e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("BASE64")) + .unwrap_or(false) + || ph.params.iter().any(|(k, _)| k == "BASE64") + { + v.to_string() + } else { + String::new() + }; + let data: String = data.chars().filter(|c| !c.is_whitespace()).collect(); + if !data.is_empty() && data.len() * 3 / 4 <= max_picture { + app.push_text("Contacts:Picture", data); + } + } + // Contacts2 + app.push_opt("Contacts2:CustomerId", get("X-CUSTOMER-ID")); + app.push_opt("Contacts2:GovernmentId", get("X-GOVERNMENT-ID")); + for (i, im) in c.props_named("IMPP").take(3).enumerate() { + let tag = if i == 0 { + "Contacts2:IMAddress".to_string() + } else { + format!("Contacts2:IMAddress{}", i + 1) + }; + app.push_text(&tag, im.text_value()); + } + app.push_opt("Contacts2:ManagerName", get("X-MANAGER")); + for (slot, i) in &slots { + if slot.starts_with("Contacts2") { + app.push_text(slot, tels[*i].text_value()); + } + } + app.push_opt("Contacts2:AccountName", get("X-ACCOUNT-NAME")); + app.push_opt("Contacts2:NickName", get("NICKNAME")); + if ver >= 120 { + app.push_text("AirSyncBase:NativeBodyType", "1"); + } + Some(app) +} + +const MANAGED: &[&str] = &[ + "FN", + "N", + "ORG", + "TITLE", + "BDAY", + "ANNIVERSARY", + "X-ANNIVERSARY", + "URL", + "NOTE", + "CATEGORIES", + "NICKNAME", + "X-SPOUSE", + "X-ASSISTANT", + "X-MS-ASSISTANT", + "X-MANAGER", + "X-OFFICE-LOCATION", + "X-PHONETIC-ORG", + "X-PHONETIC-FIRST-NAME", + "X-PHONETIC-LAST-NAME", + "X-CUSTOMER-ID", + "X-GOVERNMENT-ID", + "X-ACCOUNT-NAME", + "X-EAS-FILEAS", + "REV", +]; + +pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option { + let mut card = existing + .and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCARD")) + .unwrap_or_else(|| { + let mut c = Component::new("VCARD"); + c.add(Property::new("VERSION", "3.0")); + c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN")); + c.add(Property::text("UID", &uuid::Uuid::new_v4().to_string())); + c + }); + let v4 = card + .text("VERSION") + .map(|v| v.starts_with('4')) + .unwrap_or(false); + let g = |n: &str| app.child_text(n).filter(|s| !s.trim().is_empty()); + + // keep overflow EMAIL/TEL/ADR/IMPP that EAS cannot represent + let old_emails: Vec = card.props_named("EMAIL").skip(3).cloned().collect(); + let old_tels: Vec = card.props_named("TEL").cloned().collect(); + let tel_refs: Vec<&Property> = old_tels.iter().collect(); + let (_, tel_left) = assign_tels(&tel_refs); + let keep_tels: Vec = tel_left.iter().map(|i| old_tels[*i].clone()).collect(); + let old_adrs: Vec = card.props_named("ADR").cloned().collect(); + let adr_refs: Vec<&Property> = old_adrs.iter().collect(); + let (_, adr_left) = assign_adrs(&adr_refs); + let keep_adrs: Vec = adr_left.iter().map(|i| old_adrs[*i].clone()).collect(); + let old_impp: Vec = card.props_named("IMPP").skip(3).cloned().collect(); + let old_photo = card.prop("PHOTO").cloned(); + + card.props.retain(|p| { + !MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m)) + && !["EMAIL", "TEL", "ADR", "IMPP", "PHOTO"].contains(&p.name.as_str()) + }); + + let (first, last, middle, title, suffix) = ( + g("Contacts:FirstName").unwrap_or_default(), + g("Contacts:LastName").unwrap_or_default(), + g("Contacts:MiddleName").unwrap_or_default(), + g("Contacts:Title").unwrap_or_default(), + g("Contacts:Suffix").unwrap_or_default(), + ); + let company = g("Contacts:CompanyName").unwrap_or_default(); + let fn_ = [ + title.as_str(), + first.as_str(), + middle.as_str(), + last.as_str(), + suffix.as_str(), + ] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(" "); + let fn_ = if fn_.is_empty() { + g("Contacts:FileAs") + .or_else(|| (!company.is_empty()).then(|| company.clone())) + .or_else(|| g("Contacts:Email1Address")) + .unwrap_or_else(|| "?".into()) + } else { + fn_ + }; + card.add(Property::text("FN", &fn_)); + card.add(Property::new( + "N", + [&last, &first, &middle, &title, &suffix] + .iter() + .map(|s| ical::escape_text(s)) + .collect::>() + .join(";"), + )); + if let Some(fa) = g("Contacts:FileAs") { + if fa != fn_ { + card.add(Property::text("X-EAS-FILEAS", &fa)); + } + } + let dept = g("Contacts:Department").unwrap_or_default(); + if !company.is_empty() || !dept.is_empty() { + card.add(Property::new( + "ORG", + format!( + "{};{}", + ical::escape_text(&company), + ical::escape_text(&dept) + ), + )); + } + if let Some(t) = g("Contacts:JobTitle") { + card.add(Property::text("TITLE", &t)); + } + for i in 1..=3 { + if let Some(e) = g(&format!("Contacts:Email{i}Address")) { + // devices send `"Name" ` + let addr = e + .rsplit_once('<') + .map(|(_, a)| a.trim_end_matches('>').to_string()) + .unwrap_or(e) + .trim() + .to_string(); + let typ = match i { + 1 => "INTERNET,WORK", + 2 => "INTERNET,HOME", + _ => "INTERNET,OTHER", + }; + card.add(Property::text("EMAIL", &addr).with_param("TYPE", typ)); + } + } + card.props.extend(old_emails); + for (slot, _, _) in PHONE_SLOTS { + if let Some(n) = g(slot) { + card.add(Property::text("TEL", &n).with_param("TYPE", tel_types(slot))); + } + } + card.props.extend(keep_tels); + for (slot, typ) in ADR_SLOTS { + let f = |s: &str| g(&format!("Contacts:{slot}{s}")).unwrap_or_default(); + let (street, city, state, zip, country) = ( + f("Street"), + f("City"), + f("State"), + f("PostalCode"), + f("Country"), + ); + if [&street, &city, &state, &zip, &country] + .iter() + .any(|s| !s.is_empty()) + { + let v = ["", "", &street, &city, &state, &zip, &country] + .iter() + .map(|s| ical::escape_text(s)) + .collect::>() + .join(";"); + card.add(Property::new("ADR", v).with_param("TYPE", &typ.to_ascii_uppercase())); + } + } + card.props.extend(keep_adrs); + if let Some(b) = g("Contacts:Birthday").and_then(|s| eas_to_date(&s, &dtz)) { + card.add(Property::new( + "BDAY", + b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(), + )); + } + if let Some(b) = g("Contacts:Anniversary").and_then(|s| eas_to_date(&s, &dtz)) { + let name = if v4 { "ANNIVERSARY" } else { "X-ANNIVERSARY" }; + card.add(Property::new( + name, + b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(), + )); + } + if let Some(u) = g("Contacts:WebPage") { + card.add(Property::new("URL", u)); + } + if let Some(n) = incoming_body(app, "Contacts").filter(|s| !s.trim().is_empty()) { + card.add(Property::text("NOTE", &n)); + } + if let Some(c) = categories(app, "Contacts").filter(|c| !c.is_empty()) { + card.add(Property::new( + "CATEGORIES", + c.iter() + .map(|x| ical::escape_text(x)) + .collect::>() + .join(","), + )); + } + for (tag, prop) in [ + ("Contacts:AssistantName", "X-ASSISTANT"), + ("Contacts:Spouse", "X-SPOUSE"), + ("Contacts:OfficeLocation", "X-OFFICE-LOCATION"), + ("Contacts:YomiCompanyName", "X-PHONETIC-ORG"), + ("Contacts:YomiFirstName", "X-PHONETIC-FIRST-NAME"), + ("Contacts:YomiLastName", "X-PHONETIC-LAST-NAME"), + ("Contacts2:CustomerId", "X-CUSTOMER-ID"), + ("Contacts2:GovernmentId", "X-GOVERNMENT-ID"), + ("Contacts2:ManagerName", "X-MANAGER"), + ("Contacts2:AccountName", "X-ACCOUNT-NAME"), + ("Contacts2:NickName", "NICKNAME"), + ] { + if let Some(v) = g(tag) { + card.add(Property::text(prop, &v)); + } + } + for t in [ + "Contacts2:IMAddress", + "Contacts2:IMAddress2", + "Contacts2:IMAddress3", + ] { + if let Some(v) = g(t) { + card.add(Property::text("IMPP", &v)); + } + } + card.props.extend(old_impp); + match app.child("Contacts:Picture") { + Some(p) => { + let data = p.text_value(); + if !data.is_empty() + && base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .is_ok() + { + if v4 { + card.add(Property::new( + "PHOTO", + format!("data:image/jpeg;base64,{}", data.trim()), + )); + } else { + card.add( + Property::new("PHOTO", data.trim().to_string()) + .with_param("ENCODING", "b") + .with_param("TYPE", "JPEG"), + ); + } + } + } + None => { + if let Some(p) = old_photo { + card.add(p); + } + } + } + card.add(Property::new( + "REV", + Utc::now().format("%Y%m%dT%H%M%SZ").to_string(), + )); + // VERSION must come first + if let Some(pos) = card.props.iter().position(|p| p.name == "VERSION") { + let v = card.props.remove(pos); + card.props.insert(0, v); + } + Some(card.serialize()) +} + +/// Short summary for GAL / ResolveRecipients. +pub struct CardSummary { + pub display_name: String, + pub first: String, + pub last: String, + pub emails: Vec, + pub phone: String, + pub mobile: String, + pub company: String, + pub title: String, + pub office: String, +} + +pub fn summary(vcf: &str) -> Option { + let cards = ical::parse(vcf); + let c = cards.iter().find(|c| c.name == "VCARD")?; + let n = c + .prop("N") + .map(|n| ical::split_structured(&n.value)) + .unwrap_or_default(); + let tels: Vec<&Property> = c.props_named("TEL").collect(); + let (slots, _) = assign_tels(&tels); + let slot = |s: &str| { + slots + .iter() + .find(|(x, _)| *x == s) + .map(|(_, i)| tels[*i].text_value()) + .unwrap_or_default() + }; + Some(CardSummary { + display_name: c.text("FN").unwrap_or_default(), + first: n.get(1).cloned().unwrap_or_default(), + last: n.first().cloned().unwrap_or_default(), + emails: c.props_named("EMAIL").map(|e| e.text_value()).collect(), + phone: slot("Contacts:BusinessPhoneNumber"), + mobile: slot("Contacts:MobilePhoneNumber"), + company: c + .prop("ORG") + .map(|o| { + ical::split_structured(&o.value) + .first() + .cloned() + .unwrap_or_default() + }) + .unwrap_or_default(), + title: c.text("TITLE").unwrap_or_default(), + office: c.text("X-OFFICE-LOCATION").unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_lossless() { + let v = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Jan Kowalski\r\nN:Kowalski;Jan;;;\r\nEMAIL;TYPE=WORK:a@x.pl\r\nEMAIL:b@x.pl\r\nEMAIL:c@x.pl\r\nEMAIL:d@x.pl\r\nTEL;TYPE=CELL:+48600\r\nTEL;TYPE=WORK:+4822\r\nTEL;TYPE=X-WEIRD:999\r\nADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\nBDAY:1985-05-12\r\nX-SOCIAL:keep\r\nEND:VCARD\r\n"; + let app = to_eas( + v, + &Options::default(), + 141, + chrono_tz::Europe::Warsaw, + 100000, + ) + .unwrap(); + assert_eq!( + app.child_text("Contacts:MobilePhoneNumber").unwrap(), + "+48600" + ); + assert_eq!( + app.child_text("Contacts:BusinessPhoneNumber").unwrap(), + "+4822" + ); + assert_eq!(app.child_text("Contacts:HomeCity").unwrap(), "Warszawa"); + assert_eq!( + app.child_text("Contacts:Birthday").unwrap(), + "1985-05-11T22:00:00.000Z" + ); + assert_eq!(app.child_text("Contacts:Email3Address").unwrap(), "c@x.pl"); + let back = from_eas(&app, Some(v), chrono_tz::Europe::Warsaw).unwrap(); + assert!(back.contains("d@x.pl"), "4th email kept"); + assert!(back.contains("X-WEIRD"), "unmapped phone kept"); + assert!(back.contains("X-SOCIAL:keep")); + assert!(back.contains("BDAY:1985-05-12")); + assert!(back.contains("UID:c1")); + assert!(back.starts_with("BEGIN:VCARD\r\nVERSION:3.0")); + } +} diff --git a/src/eas/convert/email.rs b/src/eas/convert/email.rs new file mode 100644 index 0000000..d534458 --- /dev/null +++ b/src/eas/convert/email.rs @@ -0,0 +1,580 @@ +//! RFC 5322 message -> EAS Email ApplicationData. + +use base64::Engine; +use mail_parser::{MessageParser, MimeHeaders, PartType}; + +use super::{body_element, legacy_trunc_size, BodyPref, Options, BODY_HTML, BODY_MIME, BODY_TEXT}; +use crate::backend::ical; +use crate::backend::imap::FetchedMessage; +use crate::backend::tz; +use crate::util::{html_to_text, text_to_html, truncate_bytes}; +use crate::wbxml::Element; + +pub struct MailContext<'a> { + pub collection: &'a str, + pub ver: u32, + pub opts: &'a Options, + pub default_tz: chrono_tz::Tz, +} + +fn fmt_addr(name: Option<&str>, addr: Option<&str>) -> String { + match (name.map(str::trim).filter(|n| !n.is_empty()), addr) { + (Some(n), Some(a)) => format!("\"{}\" <{}>", n.replace('"', "'"), a), + (None, Some(a)) => a.to_string(), + (Some(n), None) => n.to_string(), + _ => String::new(), + } +} + +fn addr_list(a: Option<&mail_parser::Address>) -> Option { + let a = a?; + let v: Vec = a + .iter() + .map(|x| fmt_addr(x.name(), x.address())) + .filter(|s| !s.is_empty()) + .collect(); + (!v.is_empty()).then(|| v.join(", ")) +} + +fn display_names(a: Option<&mail_parser::Address>) -> Option { + let a = a?; + let v: Vec = a + .iter() + .map(|x| x.name().or(x.address()).unwrap_or("").to_string()) + .filter(|s| !s.is_empty()) + .collect(); + (!v.is_empty()).then(|| v.join("; ")) +} + +pub fn file_reference(collection: &str, uid: u32, idx: usize) -> String { + format!("{collection}:{uid}:{idx}") +} + +pub fn parse_file_reference(r: &str) -> Option<(String, u32, usize)> { + let mut it = r.rsplitn(3, ':'); + let idx = it.next()?.parse().ok()?; + let uid = it.next()?.parse().ok()?; + let col = it.next()?.to_string(); + Some((col, uid, idx)) +} + +/// Outlook compatible GlobalObjId wrapping an iCalendar UID. +pub fn global_obj_id(uid: &str) -> String { + let mut v = + hex::decode("040000008200E00074C5B7101A82E0080000000000000000000000000000000000000000") + .unwrap(); + if uid.len() <= 64 { + v.extend_from_slice(&((13 + uid.len()) as u32).to_le_bytes()); + v.extend_from_slice(b"vCal-Uid"); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(uid.as_bytes()); + v.push(0); + } else if let Ok(b) = hex::decode(uid) { + v = b; + } + base64::engine::general_purpose::STANDARD.encode(v) +} + +pub fn uid_from_global_obj_id(b64: &str) -> Option { + let b = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .ok()?; + let pos = b + .windows(8) + .position(|w| w.eq_ignore_ascii_case(b"vCal-Uid"))?; + if pos < 4 { + return None; + } + let len = u32::from_le_bytes([b[pos - 4], b[pos - 3], b[pos - 2], b[pos - 1]]) as usize; + let start = pos + 12; + let end = (start + len.saturating_sub(13)).min(b.len()); + Some(String::from_utf8_lossy(&b[start..end]).into_owned()) +} + +fn conversation_id(subject: &str) -> Vec { + let s = subject.trim(); + let mut t = s; + loop { + let l = t.to_ascii_lowercase(); + let stripped = [ + "re:", "fw:", "fwd:", "aw:", "odp:", "pd:", "wg:", "sv:", "tr:", + ] + .iter() + .find(|p| l.starts_with(*p)) + .map(|p| t[p.len()..].trim_start()); + match stripped { + Some(n) => t = n, + None => break, + } + } + use md5::{Digest, Md5}; + Md5::digest(t.to_lowercase().as_bytes()).to_vec() +} + +pub struct Converted { + pub app: Element, + pub size: usize, +} + +/// Convert a fetched message to ApplicationData. +pub fn to_eas(m: &FetchedMessage, ctx: &MailContext) -> Converted { + let parser = MessageParser::default(); + let msg = parser.parse(&m.body); + let mut app = Element::new("AirSync:ApplicationData"); + let ver = ctx.ver; + let Some(msg) = msg else { + app.push_text("Email:Subject", "(unparseable message)"); + app.push_text("Email:Read", "0"); + app.push_text("Email:MessageClass", "IPM.Note"); + return Converted { + app, + size: m.body.len(), + }; + }; + + let subject = msg.subject().unwrap_or("").to_string(); + app.push_opt("Email:To", addr_list(msg.to())); + app.push_opt("Email:Cc", addr_list(msg.cc())); + app.push_opt("Email:From", addr_list(msg.from())); + app.push_text("Email:Subject", subject.clone()); + app.push_opt("Email:ReplyTo", addr_list(msg.reply_to())); + let date = m + .internal_date + .or_else(|| { + msg.date() + .and_then(|d| chrono::DateTime::from_timestamp(d.to_timestamp(), 0)) + }) + .unwrap_or_else(chrono::Utc::now); + app.push_text("Email:DateReceived", tz::fmt_iso(&date)); + app.push_opt("Email:DisplayTo", display_names(msg.to())); + app.push_text("Email:ThreadTopic", subject.clone()); + let importance = { + let xp = msg + .header_raw("X-Priority") + .unwrap_or("") + .trim() + .to_string(); + let imp = msg + .header_raw("Importance") + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if xp.starts_with('1') || xp.starts_with('2') || imp == "high" { + 2 + } else if xp.starts_with('4') || xp.starts_with('5') || imp == "low" { + 0 + } else { + 1 + } + }; + app.push_text("Email:Importance", importance.to_string()); + let read = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen")); + app.push_text("Email:Read", if read { "1" } else { "0" }); + + // ---- attachments + let mut atts = Vec::new(); + for (idx, part) in msg.attachments().enumerate() { + let name = part + .attachment_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| { + let ct = part.content_type(); + let ext = match ct.and_then(|c| c.subtype()).unwrap_or("bin") { + "calendar" => "ics", + "rfc822" => "eml", + s => s, + }; + format!("attachment{}.{}", idx + 1, ext) + }); + let size = part.contents().len(); + let cid = part + .content_id() + .map(|c| c.trim_matches(['<', '>']).to_string()); + let inline = part + .content_disposition() + .map(|d| d.ctype().eq_ignore_ascii_case("inline")) + .unwrap_or(false) + && cid.is_some(); + atts.push((idx, name, size, cid, inline, part.is_message())); + } + if !atts.is_empty() { + if ver >= 120 { + let mut a = Element::new("AirSyncBase:Attachments"); + for (idx, name, size, cid, inline, is_msg) in &atts { + let mut e = Element::new("AirSyncBase:Attachment") + .with_text("AirSyncBase:DisplayName", name.clone()) + .with_text( + "AirSyncBase:FileReference", + file_reference(ctx.collection, m.uid, *idx), + ) + .with_text("AirSyncBase:Method", if *is_msg { "5" } else { "1" }) + .with_text("AirSyncBase:EstimatedDataSize", size.to_string()); + if let Some(c) = cid { + e.push_text("AirSyncBase:ContentId", c.clone()); + } + if *inline { + e.push_text("AirSyncBase:IsInline", "1"); + } + a.push(e); + } + app.push(a); + } else { + let mut a = Element::new("Email:Attachments"); + for (idx, name, size, _, _, _) in &atts { + a.push( + Element::new("Email:Attachment") + .with_text("Email:AttMethod", "1") + .with_text("Email:AttSize", size.to_string()) + .with_text("Email:DisplayName", name.clone()) + .with_text("Email:AttName", file_reference(ctx.collection, m.uid, *idx)), + ); + } + app.push(a); + } + } + + // ---- body + let html = msg + .body_html(0) + .map(|s| s.into_owned()) + .filter(|_| msg.html_body_count() > 0 && has_real_html(&msg)); + let text = msg.body_text(0).map(|s| s.into_owned()); + let plain = text + .clone() + .unwrap_or_else(|| html.as_deref().map(html_to_text).unwrap_or_default()); + let ctype = msg + .content_type() + .map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("")).to_ascii_lowercase()) + .unwrap_or_default(); + let is_smime = ctype.contains("signed") || ctype.contains("pkcs7"); + let native = if html.is_some() { BODY_HTML } else { BODY_TEXT }; + let opts = ctx.opts; + if ver >= 120 { + let want_mime = opts.wants(BODY_MIME).is_some() + && (opts.mime_support == 2 || (opts.mime_support == 1 && is_smime)); + let (typ, data): (u8, String) = if want_mime { + (BODY_MIME, String::from_utf8_lossy(&m.body).into_owned()) + } else if opts.wants(BODY_HTML).is_some() + && (html.is_some() || opts.wants(BODY_TEXT).is_none()) + { + ( + BODY_HTML, + html.clone().unwrap_or_else(|| text_to_html(&plain)), + ) + } else { + (BODY_TEXT, plain.replace("\r\n", "\n").replace('\n', "\r\n")) + }; + let pref = opts.wants(typ).cloned().unwrap_or(BodyPref { + typ, + ..Default::default() + }); + app.push(body_element(typ, &data, &pref, Some(&plain))); + } else { + // AS 2.5 + if opts.mime_support == 2 || (opts.mime_support == 1 && is_smime) { + let raw = String::from_utf8_lossy(&m.body).into_owned(); + let limit = opts.mime_trunc.and_then(legacy_trunc_size); + let t = limit.map(|l| l < raw.len()).unwrap_or(false); + app.push_text("Email:MIMETruncated", if t { "1" } else { "0" }); + app.push_text("Email:MIMESize", raw.len().to_string()); + app.push_text( + "Email:MIMEData", + truncate_bytes(&raw, limit.unwrap_or(usize::MAX)), + ); + } else { + let limit = opts.legacy_trunc.and_then(legacy_trunc_size); + let t = limit.map(|l| l < plain.len()).unwrap_or(false); + app.push_text("Email:BodyTruncated", if t { "1" } else { "0" }); + app.push_text( + "Email:Body", + truncate_bytes(&plain, limit.unwrap_or(usize::MAX)), + ); + } + } + + // ---- class / meeting + let mut class = if is_smime { + if ctype.contains("signed") { + "IPM.Note.SMIME.MultipartSigned" + } else { + "IPM.Note.SMIME" + } + .to_string() + } else { + "IPM.Note".to_string() + }; + let mut meeting: Option = None; + if let Some(ics) = find_calendar_part(&msg) { + if let Some((cls, mr)) = meeting_request(&ics, ctx.default_tz) { + class = cls; + meeting = mr; + } + } + app.push_text("Email:MessageClass", class); + if let Some(mr) = meeting { + app.push(mr); + } + app.push_text("Email:InternetCPID", "65001"); + if ver >= 120 { + let flagged = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged")); + if flagged { + app.push( + Element::new("Email:Flag") + .with_text("Email:FlagStatus", "2") + .with_text("Email:FlagType", "Flag for follow up"), + ); + } else { + app.push(Element::new("Email:Flag")); + } + app.push_text("Email:ContentClass", "urn:content-classes:message"); + app.push_text("AirSyncBase:NativeBodyType", native.to_string()); + } + if ver >= 140 { + app.push(Element::opaque( + "Email2:ConversationId", + conversation_id(&subject), + )); + let mut idx = Vec::with_capacity(5); + idx.push(0x01); + idx.extend_from_slice(&(date.timestamp() as u32).to_be_bytes()); + app.push(Element::opaque("Email2:ConversationIndex", idx)); + let answered = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Answered")); + let forwarded = m + .flags + .iter() + .any(|f| f.eq_ignore_ascii_case("$Forwarded") || f.eq_ignore_ascii_case("Forwarded")); + if answered || forwarded { + app.push_text("Email2:LastVerbExecuted", if forwarded { "3" } else { "1" }); + app.push_text("Email2:LastVerbExecutionTime", tz::fmt_iso(&date)); + } + } + Converted { + app, + size: m.body.len(), + } +} + +/// Changes-only payload (flags) for Sync Change commands. +pub fn flags_change(flags: &[String], ver: u32) -> Element { + let mut app = Element::new("AirSync:ApplicationData"); + let read = flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen")); + app.push_text("Email:Read", if read { "1" } else { "0" }); + if ver >= 120 { + if flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged")) { + app.push( + Element::new("Email:Flag") + .with_text("Email:FlagStatus", "2") + .with_text("Email:FlagType", "Flag for follow up"), + ); + } else { + app.push(Element::new("Email:Flag")); + } + } + app +} + +pub fn flags_from_fingerprint(fp: &str) -> Vec { + let mut v = Vec::new(); + for c in fp.chars() { + match c { + 'S' => v.push("\\Seen".to_string()), + 'F' => v.push("\\Flagged".to_string()), + 'A' => v.push("\\Answered".to_string()), + 'W' => v.push("$Forwarded".to_string()), + _ => {} + } + } + v +} + +fn has_real_html(msg: &mail_parser::Message) -> bool { + msg.html_bodies() + .any(|p| matches!(p.body, PartType::Html(_))) +} + +fn find_calendar_part(msg: &mail_parser::Message) -> Option { + for part in msg.parts.iter() { + let ct = part.content_type(); + let is_cal = ct + .map(|c| { + c.ctype().eq_ignore_ascii_case("text") + && c.subtype() + .map(|s| s.eq_ignore_ascii_case("calendar")) + .unwrap_or(false) + }) + .unwrap_or(false); + if is_cal { + if let Some(t) = part.text_contents() { + return Some(t.to_string()); + } + return Some(String::from_utf8_lossy(part.contents()).into_owned()); + } + } + None +} + +/// Build Email:MeetingRequest from an iMIP calendar part. +fn meeting_request(ics: &str, default_tz: chrono_tz::Tz) -> Option<(String, Option)> { + let cals = ical::parse(ics); + let cal = cals.first()?; + let method = cal.text("METHOD").unwrap_or_default().to_ascii_uppercase(); + let ev = cal.child("VEVENT")?; + let class = match method.as_str() { + "REQUEST" => "IPM.Schedule.Meeting.Request", + "CANCEL" => "IPM.Schedule.Meeting.Canceled", + "REPLY" => { + let st = ev + .prop("ATTENDEE") + .and_then(|a| a.param("PARTSTAT")) + .unwrap_or("") + .to_ascii_uppercase(); + match st.as_str() { + "ACCEPTED" => "IPM.Schedule.Meeting.Resp.Pos", + "TENTATIVE" => "IPM.Schedule.Meeting.Resp.Tent", + "DECLINED" => "IPM.Schedule.Meeting.Resp.Neg", + _ => "IPM.Note", + } + } + _ => return None, + }; + if !(method == "REQUEST" || method == "CANCEL") { + return Some((class.to_string(), None)); + } + let start = ev + .prop("DTSTART") + .and_then(|p| tz::parse_ical_time(p, default_tz))?; + let end = ev + .prop("DTEND") + .and_then(|p| tz::parse_ical_time(p, default_tz)) + .map(|t| t.utc) + .unwrap_or(start.utc + chrono::Duration::hours(1)); + let mut mr = Element::new("Email:MeetingRequest"); + mr.push_text("Email:AllDayEvent", if start.all_day { "1" } else { "0" }); + mr.push_text("Email:StartTime", tz::fmt_iso(&start.utc)); + let stamp = ev + .prop("DTSTAMP") + .and_then(|p| tz::parse_ical_time(p, default_tz)) + .map(|t| t.utc) + .unwrap_or_else(chrono::Utc::now); + mr.push_text("Email:DtStamp", tz::fmt_iso(&stamp)); + mr.push_text("Email:EndTime", tz::fmt_iso(&end)); + mr.push_text( + "Email:InstanceType", + if ev.prop("RRULE").is_some() { "1" } else { "0" }, + ); + mr.push_opt("Email:Location", ev.text("LOCATION")); + if let Some(o) = ev.prop("ORGANIZER") { + let addr = o + .value + .trim_start_matches("mailto:") + .trim_start_matches("MAILTO:") + .to_string(); + mr.push_text("Email:Organizer", fmt_addr(o.param("CN"), Some(&addr))); + } + if ev.child("VALARM").is_some() { + mr.push_text("Email:Reminder", "15"); + } + mr.push_text("Email:ResponseRequested", "1"); + let sens = match ev + .text("CLASS") + .unwrap_or_default() + .to_ascii_uppercase() + .as_str() + { + "PRIVATE" => "2", + "CONFIDENTIAL" => "3", + _ => "0", + }; + mr.push_text("Email:Sensitivity", sens); + mr.push_text("Email:BusyStatus", "2"); + let tzone = start.tz.unwrap_or(default_tz); + mr.push_text( + "Email:TimeZone", + tz::blob_for(&tzone, chrono::Datelike::year(&start.utc)), + ); + if let Some(uid) = ev.text("UID") { + mr.push_text("Email:GlobalObjId", global_obj_id(&uid)); + } + mr.push_text("Email2:MeetingMessageType", "1"); + Some((class.to_string(), Some(mr))) +} + +/// Raw bytes of one attachment by index (as numbered in `to_eas`). +pub fn attachment(raw: &[u8], idx: usize) -> Option<(String, String, Vec)> { + let msg = MessageParser::default().parse(raw)?; + let part = msg.attachments().nth(idx)?; + let ct = part + .content_type() + .map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("octet-stream"))) + .unwrap_or_else(|| "application/octet-stream".into()); + let name = part.attachment_name().unwrap_or("attachment").to_string(); + let data = if part.is_message() { + // keep the embedded message raw + raw.get(part.raw_body_offset() as usize..part.raw_end_offset() as usize) + .map(|s| s.to_vec()) + .unwrap_or_else(|| part.contents().to_vec()) + } else { + part.contents().to_vec() + }; + Some((name, ct, data)) +} + +/// Calendar part of a message (for MeetingResponse). +pub fn calendar_of(raw: &[u8]) -> Option { + let msg = MessageParser::default().parse(raw)?; + find_calendar_part(&msg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn goid_roundtrip() { + let g = global_obj_id("abc-123@example.com"); + assert_eq!(uid_from_global_obj_id(&g).unwrap(), "abc-123@example.com"); + } + + #[test] + fn convert_simple() { + let raw = "From: Jan \r\nTo: Anna \r\nSubject: =?utf-8?q?Za=C5=BC=C3=B3=C5=82=C4=87?=\r\nDate: Thu, 05 Nov 2026 10:00:00 +0100\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości\r\n".as_bytes().to_vec(); + let m = FetchedMessage { + uid: 7, + flags: vec!["\\Seen".into()], + internal_date: None, + body: raw, + size: 0, + }; + let opts = Options { + prefs: vec![BodyPref { + typ: 1, + trunc: Some(5), + ..Default::default() + }], + ..Default::default() + }; + let c = to_eas( + &m, + &MailContext { + collection: "3", + ver: 141, + opts: &opts, + default_tz: chrono_tz::UTC, + }, + ); + assert_eq!(c.app.child_text("Email:Subject").unwrap(), "Zażółć"); + assert_eq!(c.app.child_text("Email:Read").unwrap(), "1"); + let body = c.app.child("AirSyncBase:Body").unwrap(); + assert_eq!(body.child_text("AirSyncBase:Truncated").unwrap(), "1"); + assert_eq!(body.child_text("AirSyncBase:Data").unwrap(), "Treś"); + assert!(c.app.child_text("Email:From").unwrap().contains("jan@x.pl")); + } + + #[test] + fn fileref() { + assert_eq!( + parse_file_reference("12:345:2"), + Some(("12".into(), 345, 2)) + ); + } +} diff --git a/src/eas/convert/mod.rs b/src/eas/convert/mod.rs new file mode 100644 index 0000000..a976ff7 --- /dev/null +++ b/src/eas/convert/mod.rs @@ -0,0 +1,169 @@ +//! Conversions between backend formats (RFC 5322, iCalendar, vCard) and EAS ApplicationData. + +pub mod calendar; +pub mod contact; +pub mod email; +pub mod task; + +use crate::util::{html_to_text, text_to_html, truncate_bytes}; +use crate::wbxml::Element; + +pub const BODY_TEXT: u8 = 1; +pub const BODY_HTML: u8 = 2; +pub const BODY_RTF: u8 = 3; +pub const BODY_MIME: u8 = 4; + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct BodyPref { + pub typ: u8, + pub trunc: Option, + pub all_or_none: bool, + pub preview: Option, +} + +/// Options of a collection (or ItemOperations/Search fetch). +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct Options { + pub prefs: Vec, + pub mime_support: u8, + /// AS 2.5 Truncation code (0..9) + pub legacy_trunc: Option, + pub mime_trunc: Option, + pub filter_type: u8, + pub conflict: Option, + pub max_items: Option, +} + +impl Options { + pub fn parse(opts: &Element) -> Options { + let mut o = Options::default(); + for bp in opts.children_named("AirSyncBase:BodyPreference") { + o.prefs.push(BodyPref { + typ: bp.child_u32("AirSyncBase:Type").unwrap_or(1) as u8, + trunc: bp.child_u32("AirSyncBase:TruncationSize"), + all_or_none: bp.child_u32("AirSyncBase:AllOrNone").unwrap_or(0) == 1, + preview: bp.child_u32("AirSyncBase:Preview"), + }); + } + o.mime_support = opts.child_u32("AirSync:MIMESupport").unwrap_or(0) as u8; + o.legacy_trunc = opts.child_u32("AirSync:Truncation").map(|v| v as u8); + o.mime_trunc = opts.child_u32("AirSync:MIMETruncation").map(|v| v as u8); + o.filter_type = opts.child_u32("AirSync:FilterType").unwrap_or(0) as u8; + o.conflict = opts.child_u32("AirSync:Conflict").map(|v| v as u8); + o.max_items = opts.child_u32("AirSync:MaxItems"); + o + } + + pub fn wants(&self, t: u8) -> Option<&BodyPref> { + self.prefs.iter().find(|p| p.typ == t) + } +} + +/// AS 2.5 truncation codes to byte sizes. +pub fn legacy_trunc_size(code: u8) -> Option { + match code { + 0 => Some(0), + 1 => Some(4096), + 2 => Some(5120), + 3 => Some(7168), + 4 => Some(10240), + 5 => Some(20480), + 6 => Some(51200), + 7 => Some(102400), + _ => None, + } +} + +/// Build an AirSyncBase:Body element for PIM items (calendar/contacts/tasks), which store +/// plain text natively. +pub fn pim_body(text: &str, opts: &Options, ver: u32) -> Option { + if ver < 120 { + return None; + } + let want_html = opts.wants(BODY_HTML).is_some() && opts.wants(BODY_TEXT).is_none(); + let pref = opts + .wants(if want_html { BODY_HTML } else { BODY_TEXT }) + .cloned() + .unwrap_or(BodyPref { + typ: BODY_TEXT, + ..Default::default() + }); + let data = if pref.typ == BODY_HTML { + text_to_html(text) + } else { + text.replace("\r\n", "\n").replace('\n', "\r\n") + }; + Some(body_element(pref.typ, &data, &pref, Some(text))) +} + +/// Create AirSyncBase:Body with truncation / preview handling. +pub fn body_element( + typ: u8, + data: &str, + pref: &BodyPref, + plain_for_preview: Option<&str>, +) -> Element { + let full = data.len(); + let mut body = Element::new("AirSyncBase:Body").with_text("AirSyncBase:Type", typ.to_string()); + let truncated = pref.trunc.map(|t| (t as usize) < full).unwrap_or(false); + body.push_text("AirSyncBase:EstimatedDataSize", full.to_string()); + if truncated { + body.push_text("AirSyncBase:Truncated", "1"); + if !pref.all_or_none { + let t = pref.trunc.unwrap_or(0) as usize; + body.push_text("AirSyncBase:Data", truncate_bytes(data, t)); + } + } else if full > 0 { + body.push_text("AirSyncBase:Data", data); + } + if let Some(p) = pref.preview.filter(|p| *p > 0) { + let src = plain_for_preview.map(|s| s.to_string()).unwrap_or_else(|| { + if typ == BODY_HTML { + html_to_text(data) + } else { + data.to_string() + } + }); + let flat: String = src.split_whitespace().collect::>().join(" "); + body.push_text( + "AirSyncBase:Preview", + truncate_bytes(&flat, p.min(255) as usize), + ); + } + body +} + +/// Extract plain text from a client supplied body (AirSyncBase:Body or legacy fields). +pub fn incoming_body(app: &Element, legacy_ns: &str) -> Option { + if let Some(b) = app.child("AirSyncBase:Body") { + let typ = b.child_u32("AirSyncBase:Type").unwrap_or(1); + let data = b.child_text("AirSyncBase:Data")?; + return Some(if typ == 2_u32 { + html_to_text(&data) + } else { + data.replace("\r\n", "\n") + }); + } + app.child_text(&format!("{legacy_ns}:Body")) + .map(|s| s.replace("\r\n", "\n")) +} + +pub fn categories(app: &Element, ns: &str) -> Option> { + app.child(&format!("{ns}:Categories")).map(|c| { + c.children_named(&format!("{ns}:Category")) + .map(|x| x.text_value()) + .filter(|s| !s.is_empty()) + .collect() + }) +} + +pub fn categories_element(ns: &str, cats: &[String]) -> Option { + if cats.is_empty() { + return None; + } + let mut c = Element::new(&format!("{ns}:Categories")); + for x in cats { + c.push_text(&format!("{ns}:Category"), x.clone()); + } + Some(c) +} diff --git a/src/eas/convert/task.rs b/src/eas/convert/task.rs new file mode 100644 index 0000000..806fee0 --- /dev/null +++ b/src/eas/convert/task.rs @@ -0,0 +1,299 @@ +//! iCalendar VTODO <-> EAS Tasks. + +use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; +use chrono_tz::Tz; + +use super::calendar::{eas_to_rrule, masters_and_overrides, rrule_to_eas}; +use super::{categories, categories_element, incoming_body, pim_body, Options}; +use crate::backend::ical::{self, Component, Property}; +use crate::backend::tz; +use crate::wbxml::Element; + +/// "Local" EAS task dates are local wall-clock times written with a Z suffix. +fn local_as_utc(d: &DateTime, tzone: &Tz) -> String { + tz::fmt_iso(&Utc.from_utc_datetime(&d.with_timezone(tzone).naive_local())) +} + +fn utc_from_local_field(s: &str, tzone: &Tz) -> Option> { + let fake = tz::parse_eas_date(s)?; + let n: NaiveDateTime = fake.naive_utc(); + tzone + .from_local_datetime(&n) + .earliest() + .map(|x| x.with_timezone(&Utc)) +} + +pub fn to_eas(ics: &str, opts: &Options, ver: u32, dtz: Tz) -> Option { + let cals = ical::parse(ics); + let cal = cals.iter().find(|c| c.name == "VCALENDAR")?; + let (master, _) = masters_and_overrides(cal, "VTODO"); + let t = master?; + let mut app = Element::new("AirSync:ApplicationData"); + let desc = t.text("DESCRIPTION").unwrap_or_default(); + if let Some(b) = pim_body(&desc, opts, ver) { + app.push(b); + } else if ver < 120 && !desc.is_empty() { + app.push_text("Tasks:Body", desc.clone()); + } + let cats: Vec = t + .props_named("CATEGORIES") + .flat_map(|p| ical::split_list(&p.value)) + .collect(); + if let Some(c) = categories_element("Tasks", &cats) { + app.push(c); + } + let completed = t + .text("STATUS") + .map(|s| s.eq_ignore_ascii_case("COMPLETED")) + .unwrap_or(false) + || t.prop("COMPLETED").is_some(); + app.push_text("Tasks:Complete", if completed { "1" } else { "0" }); + if completed { + let c = t + .prop("COMPLETED") + .and_then(|p| tz::parse_ical_time(p, dtz)) + .map(|x| x.utc) + .unwrap_or_else(Utc::now); + app.push_text("Tasks:DateCompleted", tz::fmt_iso(&c)); + } + let due = t.prop("DUE").and_then(|p| tz::parse_ical_time(p, dtz)); + if let Some(d) = &due { + app.push_text("Tasks:DueDate", local_as_utc(&d.utc, &d.tz.unwrap_or(dtz))); + app.push_text("Tasks:UtcDueDate", tz::fmt_iso(&d.utc)); + } + let prio: u32 = t.text("PRIORITY").and_then(|p| p.parse().ok()).unwrap_or(0); + let imp = match prio { + 1..=4 => 2, + 6..=9 => 0, + _ => 1, + }; + app.push_text("Tasks:Importance", imp.to_string()); + let start = t.prop("DTSTART").and_then(|p| tz::parse_ical_time(p, dtz)); + if let (Some(rr), Some(s)) = (t.prop("RRULE"), &start) { + if let Some(mut rec) = + rrule_to_eas(&rr.value, &s.utc, &s.tz.unwrap_or(dtz), "Tasks", s.all_day) + { + rec.push_text("Tasks:Start", local_as_utc(&s.utc, &s.tz.unwrap_or(dtz))); + app.push(rec); + } + } + // reminder + let mut reminder: Option> = None; + if let Some(a) = t.child("VALARM") { + if let Some(tr) = a.prop("TRIGGER") { + if tr + .param("VALUE") + .map(|v| v.eq_ignore_ascii_case("DATE-TIME")) + .unwrap_or(false) + || tr.value.ends_with('Z') + { + reminder = tz::parse_eas_date(&tr.value); + } else if let Some(m) = super::calendar::reminder_minutes(&t) { + let base = if tr + .param("RELATED") + .map(|r| r.eq_ignore_ascii_case("END")) + .unwrap_or(false) + { + due.as_ref().map(|d| d.utc) + } else { + start + .as_ref() + .map(|s| s.utc) + .or(due.as_ref().map(|d| d.utc)) + }; + reminder = base.map(|b| b - chrono::Duration::minutes(m)); + } + } + } + app.push_text( + "Tasks:ReminderSet", + if reminder.is_some() { "1" } else { "0" }, + ); + if let Some(r) = reminder { + app.push_text("Tasks:ReminderTime", tz::fmt_iso(&r)); + } + let sens = match t + .text("CLASS") + .unwrap_or_default() + .to_ascii_uppercase() + .as_str() + { + "PRIVATE" => "2", + "CONFIDENTIAL" => "3", + _ => "0", + }; + app.push_text("Tasks:Sensitivity", sens); + if let Some(s) = &start { + app.push_text( + "Tasks:StartDate", + local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)), + ); + app.push_text("Tasks:UtcStartDate", tz::fmt_iso(&s.utc)); + } + app.push_text("Tasks:Subject", t.text("SUMMARY").unwrap_or_default()); + Some(app) +} + +const MANAGED: &[&str] = &[ + "SUMMARY", + "DESCRIPTION", + "STATUS", + "COMPLETED", + "PERCENT-COMPLETE", + "DUE", + "DTSTART", + "PRIORITY", + "CLASS", + "CATEGORIES", + "RRULE", + "DTSTAMP", + "LAST-MODIFIED", + "UID", +]; + +pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option { + let mut cal = existing + .and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCALENDAR")) + .unwrap_or_else(|| { + let mut c = Component::new("VCALENDAR"); + c.add(Property::new("VERSION", "2.0")); + c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN")); + c + }); + let (old, _) = masters_and_overrides(&cal, "VTODO"); + let mut t = old.clone().unwrap_or_else(|| Component::new("VTODO")); + let uid = old + .as_ref() + .and_then(|o| o.text("UID")) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + t.props + .retain(|p| !MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m))); + t.props.insert(0, Property::text("UID", &uid)); + t.add(tz::ical_utc("DTSTAMP", &Utc::now())); + t.add(tz::ical_utc("LAST-MODIFIED", &Utc::now())); + if let Some(s) = app.child_text("Tasks:Subject") { + t.add(Property::text("SUMMARY", &s)); + } + if let Some(b) = incoming_body(app, "Tasks").filter(|b| !b.trim().is_empty()) { + t.add(Property::text("DESCRIPTION", &b)); + } else if !app.has("AirSyncBase:Body") { + if let Some(d) = old.as_ref().and_then(|o| o.prop("DESCRIPTION")) { + t.add(d.clone()); + } + } + let complete = app.child_u32("Tasks:Complete").unwrap_or(0) == 1; + if complete { + t.add(Property::new("STATUS", "COMPLETED")); + t.add(Property::new("PERCENT-COMPLETE", "100")); + let c = app + .child_text("Tasks:DateCompleted") + .and_then(|s| tz::parse_eas_date(&s)) + .unwrap_or_else(Utc::now); + t.add(tz::ical_utc("COMPLETED", &c)); + } else { + t.add(Property::new("STATUS", "NEEDS-ACTION")); + } + let due = app + .child_text("Tasks:UtcDueDate") + .and_then(|s| tz::parse_eas_date(&s)) + .or_else(|| { + app.child_text("Tasks:DueDate") + .and_then(|s| utc_from_local_field(&s, &dtz)) + }); + if let Some(d) = due { + t.add(tz::ical_utc("DUE", &d)); + } + let start = app + .child_text("Tasks:UtcStartDate") + .and_then(|s| tz::parse_eas_date(&s)) + .or_else(|| { + app.child_text("Tasks:StartDate") + .and_then(|s| utc_from_local_field(&s, &dtz)) + }); + if let Some(s) = start { + t.add(tz::ical_utc("DTSTART", &s)); + } + let prio = match app.child_u32("Tasks:Importance").unwrap_or(1) { + 2 => "1", + 0 => "9", + _ => "5", + }; + t.add(Property::new("PRIORITY", prio)); + if let Some(s) = app.child_text("Tasks:Sensitivity") { + t.add(Property::new( + "CLASS", + match s.as_str() { + "2" => "PRIVATE", + "3" => "CONFIDENTIAL", + _ => "PUBLIC", + }, + )); + } + if let Some(cats) = categories(app, "Tasks").filter(|c| !c.is_empty()) { + t.add(Property::new( + "CATEGORIES", + cats.iter() + .map(|c| ical::escape_text(c)) + .collect::>() + .join(","), + )); + } + if let Some(rec) = app.child("Tasks:Recurrence") { + if let Some(rr) = eas_to_rrule(rec, "Tasks", false, &dtz) { + t.add(Property::new("RRULE", rr)); + if start.is_none() { + if let Some(s) = rec + .child_text("Tasks:Start") + .and_then(|s| utc_from_local_field(&s, &dtz)) + { + t.add(tz::ical_utc("DTSTART", &s)); + } + } + } + } + t.children.retain(|c| c.name != "VALARM"); + if app.child_u32("Tasks:ReminderSet").unwrap_or(0) == 1 { + if let Some(r) = app + .child_text("Tasks:ReminderTime") + .and_then(|s| tz::parse_eas_date(&s)) + { + let mut a = Component::new("VALARM"); + a.add(Property::new("ACTION", "DISPLAY")); + a.add(Property::text( + "DESCRIPTION", + &t.text("SUMMARY").unwrap_or_else(|| "Reminder".into()), + )); + a.add(tz::ical_utc("TRIGGER", &r).with_param("VALUE", "DATE-TIME")); + t.children.push(a); + } + } + cal.children.retain(|c| c.name != "VTODO"); + cal.children.push(t); + Some(cal.serialize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:Kup mleko\r\nDUE:20261106T120000Z\r\nPRIORITY:1\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; + let app = to_eas(ics, &Options::default(), 141, chrono_tz::Europe::Warsaw).unwrap(); + assert_eq!( + app.child_text("Tasks:UtcDueDate").unwrap(), + "2026-11-06T12:00:00.000Z" + ); + assert_eq!( + app.child_text("Tasks:DueDate").unwrap(), + "2026-11-06T13:00:00.000Z" + ); + assert_eq!(app.child_text("Tasks:Importance").unwrap(), "2"); + let mut app2 = app.clone(); + app2.children.retain(|c| !c.is("Tasks:Complete")); + app2.push_text("Tasks:Complete", "1"); + let back = from_eas(&app2, Some(ics), chrono_tz::Europe::Warsaw).unwrap(); + assert!(back.contains("STATUS:COMPLETED")); + assert!(back.contains("UID:t1")); + } +} diff --git a/src/eas/folders.rs b/src/eas/folders.rs new file mode 100644 index 0000000..d22ff8a --- /dev/null +++ b/src/eas/folders.rs @@ -0,0 +1,340 @@ +//! FolderSync, FolderCreate, FolderUpdate, FolderDelete, GetHierarchy. + +use std::collections::BTreeMap; + +use anyhow::Result; +use sqlx::Row; + +use super::state::{self, HierarchyState, SyncKey, HIERARCHY}; +use super::{Ctx, Reply}; +use crate::backend::BFolder; +use crate::db; +use crate::wbxml::Element; + +pub async fn short_id(ctx: &Ctx, backend_id: &str) -> Result { + Ok(db::folder_short_id(&ctx.app.db, ctx.user(), backend_id) + .await? + .to_string()) +} + +/// Resolve a device-visible folder id to the backend folder. +pub async fn resolve(ctx: &mut Ctx, short: &str) -> Result> { + let Some(bid) = db::folder_backend_id(&ctx.app.db, ctx.user(), short).await? else { + return Ok(None); + }; + ctx.sess.folder(&bid).await +} + +pub async fn current_hierarchy(ctx: &mut Ctx) -> Result<(HierarchyState, Vec<(String, BFolder)>)> { + let folders = ctx.sess.folders().await?; + let mut st = HierarchyState::default(); + let mut list = Vec::new(); + let mut ids: BTreeMap = BTreeMap::new(); + for f in &folders { + let sid = short_id(ctx, &f.id).await?; + ids.insert(f.id.clone(), sid); + } + for f in folders { + let sid = ids[&f.id].clone(); + let parent = f + .parent + .as_ref() + .and_then(|p| ids.get(p).cloned()) + .unwrap_or_else(|| "0".to_string()); + st.folders + .insert(sid.clone(), (parent, f.name.clone(), f.ftype)); + list.push((sid, f)); + } + Ok((st, list)) +} + +fn folder_el(tag: &str, sid: &str, parent: &str, name: &str, ftype: u8) -> Element { + Element::new(tag) + .with_text("FolderHierarchy:ServerId", sid) + .with_text("FolderHierarchy:ParentId", parent) + .with_text("FolderHierarchy:DisplayName", name) + .with_text("FolderHierarchy:Type", ftype.to_string()) +} + +fn fs_status(root: &str, status: u32) -> Reply { + Reply::Wbxml(Element::new(root).with_text("FolderHierarchy:Status", status.to_string())) +} + +pub async fn folder_sync(ctx: &mut Ctx, req: Option) -> Result { + let Some(req) = req else { + return Ok(fs_status("FolderHierarchy:FolderSync", 10)); + }; + let key_s = req + .child_text("FolderHierarchy:SyncKey") + .unwrap_or_default(); + let Some(key) = SyncKey::parse(&key_s) else { + ctx.status = "invalid synckey".into(); + return Ok(fs_status("FolderHierarchy:FolderSync", 9)); + }; + let old: HierarchyState = match &key { + SyncKey::Initial => { + // full resync: forget every collection state of this device + db::device_reset_states(&ctx.app.db, ctx.dev.id).await?; + HierarchyState::default() + } + k => match state::load(ctx, HIERARCHY, k).await? { + Some(s) => s, + None => { + ctx.status = "unknown synckey".into(); + return Ok(fs_status("FolderHierarchy:FolderSync", 9)); + } + }, + }; + let (cur, _) = current_hierarchy(ctx).await?; + let mut adds = Vec::new(); + let mut updates = Vec::new(); + let mut deletes = Vec::new(); + for (sid, v) in &cur.folders { + match old.folders.get(sid) { + None => adds.push((sid.clone(), v.clone())), + Some(o) if o != v => updates.push((sid.clone(), v.clone())), + _ => {} + } + } + for sid in old.folders.keys() { + if !cur.folders.contains_key(sid) { + deletes.push(sid.clone()); + } + } + // collections the device still references but which never existed here (Z-Push leftovers) + let stale: Vec = ctx + .app + .stale_take(ctx.dev.id) + .into_iter() + .filter(|s| !cur.folders.contains_key(s) && !deletes.contains(s)) + .collect(); + if !stale.is_empty() { + tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, folders = %stale.join(","), "removing stale collections from device"); + deletes.extend(stale); + } + // parents before children + adds.sort_by_key(|(sid, (p, _, _))| (depth(&cur, sid), p.clone())); + let changed = !(adds.is_empty() && updates.is_empty() && deletes.is_empty()); + let new_key = match (&key, changed) { + (SyncKey::Key { .. }, false) => key_s.clone(), + (SyncKey::Key { uuid, counter }, true) => { + state::save(ctx, HIERARCHY, uuid, counter + 1, &cur).await?; + state::make_key(uuid, counter + 1) + } + (SyncKey::Initial, _) => { + let u = state::new_uuid(); + state::save(ctx, HIERARCHY, &u, 1, &cur).await?; + state::make_key(&u, 1) + } + }; + for sid in &deletes { + db::state_delete_collection(&ctx.app.db, ctx.dev.id, sid).await?; + } + let mut changes = Element::new("FolderHierarchy:Changes").with_text( + "FolderHierarchy:Count", + (adds.len() + updates.len() + deletes.len()).to_string(), + ); + for (sid, (p, n, t)) in &updates { + changes.push(folder_el("FolderHierarchy:Update", sid, p, n, *t)); + } + for sid in &deletes { + changes.push( + Element::new("FolderHierarchy:Delete") + .with_text("FolderHierarchy:ServerId", sid.clone()), + ); + } + for (sid, (p, n, t)) in &adds { + changes.push(folder_el("FolderHierarchy:Add", sid, p, n, *t)); + } + ctx.items_out = (adds.len() + updates.len() + deletes.len()) as u64; + Ok(Reply::Wbxml( + Element::new("FolderHierarchy:FolderSync") + .with_text("FolderHierarchy:Status", "1") + .with_text("FolderHierarchy:SyncKey", new_key) + .with(changes), + )) +} + +fn depth(st: &HierarchyState, sid: &str) -> usize { + let mut d = 0; + let mut cur = sid.to_string(); + while let Some((p, _, _)) = st.folders.get(&cur) { + if p == "0" || d > 32 { + break; + } + d += 1; + cur = p.clone(); + } + d +} + +pub async fn folder_change(ctx: &mut Ctx, req: Option) -> Result { + let cmd = ctx.req.cmd.clone(); + let root = format!("FolderHierarchy:{cmd}"); + let Some(req) = req else { + return Ok(fs_status(&root, 10)); + }; + let key_s = req + .child_text("FolderHierarchy:SyncKey") + .unwrap_or_default(); + let Some(key @ SyncKey::Key { .. }) = SyncKey::parse(&key_s) else { + return Ok(fs_status(&root, 9)); + }; + let SyncKey::Key { uuid, counter } = &key else { + unreachable!() + }; + let Some(mut st): Option = state::load(ctx, HIERARCHY, &key).await? else { + return Ok(fs_status(&root, 9)); + }; + let name = req + .child_text("FolderHierarchy:DisplayName") + .unwrap_or_default(); + let parent_sid = req + .child_text("FolderHierarchy:ParentId") + .unwrap_or_else(|| "0".into()); + let parent = if parent_sid == "0" { + None + } else { + resolve(ctx, &parent_sid).await? + }; + if parent_sid != "0" && parent.is_none() { + return Ok(fs_status(&root, 5)); + } + let res: Result = async { + match cmd.as_str() { + "FolderCreate" => { + if name.trim().is_empty() || name.len() > 256 { + return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10")); + } + if st + .folders + .values() + .any(|(p, n, _)| p == &parent_sid && n.eq_ignore_ascii_case(&name)) + { + return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "2")); + } + let ftype: u8 = req.child_u32("FolderHierarchy:Type").unwrap_or(12) as u8; + let f = ctx + .sess + .create_folder(parent.as_ref(), name.trim(), ftype) + .await?; + let sid = short_id(ctx, &f.id).await?; + st.folders + .insert(sid.clone(), (parent_sid.clone(), f.name.clone(), f.ftype)); + state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?; + db::audit(&ctx.app.db, ctx.user(), "folder_create", &f.id, &ctx.ip, "").await; + Ok(Element::new(&root) + .with_text("FolderHierarchy:Status", "1") + .with_text( + "FolderHierarchy:SyncKey", + state::make_key(uuid, counter + 1), + ) + .with_text("FolderHierarchy:ServerId", sid)) + } + "FolderUpdate" | "FolderDelete" => { + let sid = req + .child_text("FolderHierarchy:ServerId") + .unwrap_or_default(); + let Some(folder) = resolve(ctx, &sid).await? else { + return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "4")); + }; + if cmd == "FolderDelete" { + if folder.ftype != crate::backend::FT_USER_MAIL + && !matches!(folder.ftype, 13..=15) + { + return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3")); + } + ctx.sess.delete_folder(&folder).await?; + st.folders.remove(&sid); + db::state_delete_collection(&ctx.app.db, ctx.dev.id, &sid).await?; + db::audit( + &ctx.app.db, + ctx.user(), + "folder_delete", + &folder.id, + &ctx.ip, + "", + ) + .await; + } else { + if folder.ftype != crate::backend::FT_USER_MAIL + && !matches!(folder.ftype, 13..=15) + { + return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3")); + } + let nf = ctx + .sess + .rename_folder(&folder, parent.as_ref(), name.trim()) + .await?; + if nf.id != folder.id { + rename_mapping(ctx, &folder.id, &nf.id).await?; + } + st.folders + .insert(sid.clone(), (parent_sid.clone(), nf.name.clone(), nf.ftype)); + db::audit( + &ctx.app.db, + ctx.user(), + "folder_update", + &nf.id, + &ctx.ip, + "", + ) + .await; + } + state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?; + Ok(Element::new(&root) + .with_text("FolderHierarchy:Status", "1") + .with_text( + "FolderHierarchy:SyncKey", + state::make_key(uuid, counter + 1), + )) + } + _ => Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10")), + } + } + .await; + match res { + Ok(e) => Ok(Reply::Wbxml(e)), + Err(e) => { + tracing::warn!(user = %ctx.user(), "{cmd} failed: {e:#}"); + ctx.status = format!("error: {e}"); + let m = e.to_string().to_ascii_lowercase(); + let code = if m.contains("alreadyexists") || m.contains("already exists") { + 2 + } else { + 6 + }; + Ok(fs_status(&root, code)) + } + } +} + +/// After an IMAP rename, move the short-id mapping of the folder and all its children. +async fn rename_mapping(ctx: &Ctx, old: &str, new: &str) -> Result<()> { + let rows = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ?") + .bind(ctx.user()) + .fetch_all(&ctx.app.db) + .await?; + for r in rows { + let bid: String = r.get(0); + if bid == old { + db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, new).await?; + } else if let Some(rest) = bid.strip_prefix(old) { + if rest.starts_with(['/', '.']) { + db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, &format!("{new}{rest}")) + .await?; + } + } + } + Ok(()) +} + +/// Legacy AS 1.0/2.0 GetHierarchy. +pub async fn get_hierarchy(ctx: &mut Ctx) -> Result { + let (st, _) = current_hierarchy(ctx).await?; + let mut root = Element::new("FolderHierarchy:Folders"); + for (sid, (p, n, t)) in st.folders { + root.push(folder_el("FolderHierarchy:Folder", &sid, &p, &n, t)); + } + Ok(Reply::Wbxml(root)) +} diff --git a/src/eas/itemops.rs b/src/eas/itemops.rs new file mode 100644 index 0000000..47d2797 --- /dev/null +++ b/src/eas/itemops.rs @@ -0,0 +1,279 @@ +//! ItemOperations and GetAttachment. + +use anyhow::{anyhow, Result}; +use base64::Engine; + +use super::convert::{email, Options}; +use super::folders::resolve; +use super::sync::{convert_raw, parse_server_id}; +use super::{Ctx, Reply}; +use crate::backend::Kind; +use crate::wbxml::{self, Element}; + +/// Load one attachment by FileReference. Returns (name, content type, bytes). +async fn load_attachment(ctx: &mut Ctx, fref: &str) -> Result<(String, String, Vec)> { + let (col, uid, idx) = + email::parse_file_reference(fref).ok_or_else(|| anyhow!("invalid file reference"))?; + let folder = resolve(ctx, &col) + .await? + .ok_or_else(|| anyhow!("not found"))?; + if folder.kind() != Kind::Mail { + return Err(anyhow!("not found")); + } + let m = ctx + .sess + .fetch_mail(folder.path(), uid) + .await + .map_err(|_| anyhow!("not found"))?; + email::attachment(&m.body, idx).ok_or_else(|| anyhow!("not found")) +} + +fn attachments_allowed(ctx: &Ctx) -> bool { + !ctx.s.provisioning.enabled || ctx.s.policy_for(ctx.user()).attenabled +} + +fn max_att(ctx: &Ctx) -> usize { + let mut m = (ctx.s.sync.max_attachment_mb * 1024 * 1024) as usize; + if ctx.s.provisioning.enabled { + let p = ctx.s.policy_for(ctx.user()).maxattsize; + if p > 0 { + m = m.min(p as usize); + } + } + m +} + +pub async fn get_attachment(ctx: &mut Ctx) -> Result { + let name = ctx.req.attachment_name.clone().unwrap_or_default(); + if !attachments_allowed(ctx) { + return Ok(Reply::Status(axum::http::StatusCode::FORBIDDEN)); + } + match load_attachment(ctx, &name).await { + Ok((_, _, data)) if data.len() > max_att(ctx) => { + Ok(Reply::Status(axum::http::StatusCode::PAYLOAD_TOO_LARGE)) + } + Ok((_, _ct, data)) => { + ctx.items_out += 1; + Ok(Reply::Raw("application/octet-stream", data)) + } + Err(e) => { + ctx.status = format!("attachment: {e}"); + Ok(Reply::Status(axum::http::StatusCode::NOT_FOUND)) + } + } +} + +fn parse_range(r: &str, total: usize) -> Option<(usize, usize)> { + let (a, b) = r.split_once('-')?; + let a: usize = a.trim().parse().ok()?; + let b: usize = b.trim().parse().ok()?; + if a > b || a >= total.max(1) { + return None; + } + Some((a, b.min(total.saturating_sub(1)))) +} + +pub async fn item_operations(ctx: &mut Ctx, req: Option) -> Result { + let Some(req) = req else { + return Ok(Reply::Wbxml( + Element::new("ItemOperations:ItemOperations").with_text("ItemOperations:Status", "2"), + )); + }; + let multipart = ctx.req.accept_multipart; + let mut parts: Vec> = Vec::new(); + let mut response = Element::new("ItemOperations:Response"); + for op in &req.children { + match op.tag() { + "Fetch" => { + let mut f = Element::new("ItemOperations:Fetch"); + let opts = op + .child("ItemOperations:Options") + .map(Options::parse) + .unwrap_or_default(); + let range = op + .child("ItemOperations:Options") + .and_then(|o| o.child_text("ItemOperations:Range")); + if let Some(fref) = op.child_text("AirSyncBase:FileReference") { + if !attachments_allowed(ctx) { + f.push_text("ItemOperations:Status", "16"); + f.push_text("AirSyncBase:FileReference", fref); + response.push(f); + continue; + } + match load_attachment(ctx, &fref).await { + Ok((_, ct, data)) if data.len() > max_att(ctx) => { + let _ = ct; + f.push_text("ItemOperations:Status", "11"); + f.push_text("AirSyncBase:FileReference", fref); + } + Ok((_, ct, data)) => { + let total = data.len(); + let (slice, rng) = + match range.as_deref().and_then(|r| parse_range(r, total)) { + Some((a, b)) => ( + &data[a..=b.min(total.saturating_sub(1))], + Some(format!("{a}-{b}")), + ), + None if range.is_some() && total > 0 => { + f.push_text("ItemOperations:Status", "8"); + f.push_text("AirSyncBase:FileReference", fref); + response.push(f); + continue; + } + None => (&data[..], None), + }; + f.push_text("ItemOperations:Status", "1"); + f.push_text("AirSyncBase:FileReference", fref); + let mut props = Element::new("ItemOperations:Properties"); + props.push_text("AirSyncBase:ContentType", ct); + if let Some(r) = rng { + props.push_text("ItemOperations:Range", r); + props.push_text("ItemOperations:Total", total.to_string()); + } + if multipart { + parts.push(slice.to_vec()); + props.push_text("ItemOperations:Part", parts.len().to_string()); + } else { + props.push_text( + "ItemOperations:Data", + base64::engine::general_purpose::STANDARD.encode(slice), + ); + } + f.push(props); + ctx.items_out += 1; + } + Err(e) => { + tracing::info!(user = %ctx.user(), "attachment fetch failed: {e:#}"); + f.push_text( + "ItemOperations:Status", + if e.to_string().contains("invalid") { + "15" + } else { + "6" + }, + ); + f.push_text("AirSyncBase:FileReference", fref); + } + } + response.push(f); + continue; + } + // item fetch (by CollectionId + ServerId, or LongId from Search) + let (col, sid) = match ( + op.child_text("AirSync:CollectionId"), + op.child_text("AirSync:ServerId"), + op.child_text("Search:LongId"), + ) { + (Some(c), Some(s), _) => (c, s), + (_, _, Some(l)) => (super::sync::collection_of(&l).to_string(), l), + _ => { + f.push_text("ItemOperations:Status", "2"); + response.push(f); + continue; + } + }; + let res: Result = async { + let folder = resolve(ctx, &col) + .await? + .ok_or_else(|| anyhow!("not found"))?; + let id = parse_server_id(ctx, &sid, &folder.id) + .await? + .ok_or_else(|| anyhow!("not found"))?; + let raw = ctx + .sess + .fetch(&folder, std::slice::from_ref(&id)) + .await? + .into_iter() + .next() + .ok_or_else(|| anyhow!("not found"))?; + let app = convert_raw(ctx, &col, folder.kind(), &raw, &opts) + .ok_or_else(|| anyhow!("conversion failed"))?; + Ok(app) + } + .await; + match res { + Ok(app) => { + f.push_text("ItemOperations:Status", "1"); + if op.has("Search:LongId") { + f.push_text("Search:LongId", sid.clone()); + } else { + f.push_text("AirSync:CollectionId", col.clone()); + f.push_text("AirSync:ServerId", sid.clone()); + } + let mut props = Element::new("ItemOperations:Properties"); + props.children = app.children; + f.push(props); + ctx.items_out += 1; + } + Err(e) => { + f.push_text( + "ItemOperations:Status", + if e.to_string().contains("not found") { + "6" + } else { + "3" + }, + ); + f.push_text("AirSync:CollectionId", col); + f.push_text("AirSync:ServerId", sid); + } + } + response.push(f); + } + "EmptyFolderContents" => { + let col = op.child_text("AirSync:CollectionId").unwrap_or_default(); + let mut e = Element::new("ItemOperations:EmptyFolderContents"); + let res: Result<()> = async { + let folder = resolve(ctx, &col) + .await? + .ok_or_else(|| anyhow!("not found"))?; + match folder.kind() { + Kind::Mail => ctx.sess.mail_empty(&folder).await, + _ => { + let l = ctx.sess.list(&folder, None).await?; + for (id, _) in l.items { + ctx.sess.dav_delete(&folder, &id).await?; + } + Ok(()) + } + } + } + .await; + e.push_text("ItemOperations:Status", if res.is_ok() { "1" } else { "3" }); + e.push_text("AirSync:CollectionId", col); + response.push(e); + } + "Move" => { + let mut m = Element::new("ItemOperations:Move"); + m.push_text("ItemOperations:Status", "2"); + if let Some(c) = op.child("ItemOperations:ConversationId") { + m.push(c.clone()); + } + response.push(m); + } + _ => {} + } + } + let root = Element::new("ItemOperations:ItemOperations") + .with_text("ItemOperations:Status", "1") + .with(response); + if multipart { + let wb = wbxml::encode(&root); + let mut all = vec![wb]; + all.extend(parts); + let n = all.len(); + let mut body = Vec::new(); + body.extend_from_slice(&(n as i32).to_le_bytes()); + let mut offset = 4 + n * 8; + for p in &all { + body.extend_from_slice(&(offset as i32).to_le_bytes()); + body.extend_from_slice(&(p.len() as i32).to_le_bytes()); + offset += p.len(); + } + for p in all { + body.extend_from_slice(&p); + } + return Ok(Reply::Raw("application/vnd.ms-sync.multipart", body)); + } + Ok(Reply::Wbxml(root)) +} diff --git a/src/eas/mailcmd.rs b/src/eas/mailcmd.rs new file mode 100644 index 0000000..2136cfb --- /dev/null +++ b/src/eas/mailcmd.rs @@ -0,0 +1,481 @@ +//! SendMail, SmartReply, SmartForward, MeetingResponse. + +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use mail_parser::{MessageParser, MimeHeaders}; + +use super::convert::{calendar, email}; +use super::folders::resolve; +use super::sync::{parse_server_id, server_id}; +use super::{Ctx, Reply}; +use crate::backend::{mime, smtp, Kind, FT_CALENDAR, FT_SENT}; +use crate::db; +use crate::util::{html_escape, html_to_text}; +use crate::wbxml::Element; + +fn fail(ctx: &mut Ctx, status: u32, why: &str) -> Reply { + ctx.status = format!("error: {why}"); + tracing::warn!(user = %ctx.user(), cmd = %ctx.req.cmd, "send failed: {why}"); + if ctx.req.ver() >= 140 { + Reply::Wbxml( + Element::new(&format!("ComposeMail:{}", ctx.req.cmd)) + .with_text("ComposeMail:Status", status.to_string()), + ) + } else { + Reply::Status(StatusCode::INTERNAL_SERVER_ERROR) + } +} + +fn addrs(m: &mail_parser::Message, out: &mut Vec) { + for a in [m.to(), m.cc(), m.bcc()].into_iter().flatten() { + for x in a.iter() { + if let Some(ad) = x.address() { + let ad = ad.trim().to_string(); + if ad.contains('@') && !out.iter().any(|o| o.eq_ignore_ascii_case(&ad)) { + out.push(ad); + } + } + } + } +} + +pub async fn send_mail(ctx: &mut Ctx, req: Option, body: &[u8]) -> Result { + let cmd = ctx.req.cmd.clone(); + // ---- collect parameters (WBXML for AS 14+, query + raw MIME below) + let (mime_raw, save_sent, src_folder, src_item, src_long, replace_mime) = match &req { + Some(r) => { + let mime = r + .child("ComposeMail:MIME") + .map(|m| m.bytes_value()) + .unwrap_or_default(); + let src = r.child("ComposeMail:Source"); + ( + mime, + r.has("ComposeMail:SaveInSentItems"), + src.and_then(|s| s.child_text("ComposeMail:FolderId")), + src.and_then(|s| s.child_text("ComposeMail:ItemId")), + src.and_then(|s| s.child_text("ComposeMail:LongId")), + r.has("ComposeMail:ReplaceMime"), + ) + } + None => ( + body.to_vec(), + ctx.req.save_in_sent, + ctx.req.collection_id.clone(), + ctx.req.item_id.clone(), + ctx.req.long_id.clone(), + false, + ), + }; + if mime_raw.is_empty() { + return Ok(fail(ctx, 107, "empty MIME")); + } + let Some(msg) = MessageParser::default().parse(&mime_raw) else { + return Ok(fail(ctx, 107, "invalid MIME")); + }; + let mut rcpts = Vec::new(); + addrs(&msg, &mut rcpts); + if rcpts.is_empty() { + return Ok(fail(ctx, 107, "no recipients")); + } + + // ---- original message for smart reply / forward + let mut original: Option<(crate::backend::BFolder, u32, Vec)> = None; + if cmd != "SendMail" { + let (fid, iid) = if let Some(l) = src_long.clone() { + let mut p = l.splitn(2, ':'); + (p.next().map(|s| s.to_string()), Some(l)) + } else { + (src_folder.clone(), src_item.clone()) + }; + if let (Some(fid), Some(iid)) = (fid, iid) { + let iid = if iid.contains(':') { + iid + } else { + format!("{fid}:{iid}") + }; + if let Some(folder) = resolve(ctx, &fid).await? { + if let Some(uid) = parse_server_id(ctx, &iid, &folder.id) + .await? + .and_then(|u| u.parse::().ok()) + { + if let Ok(m) = ctx.sess.fetch_mail(folder.path(), uid).await { + original = Some((folder, uid, m.body)); + } + } + } + } + if original.is_none() { + tracing::info!(user = %ctx.user(), "smart reply/forward source not found, sending as is"); + } + } + + // ---- compose + let from_addr = ctx.sess.email.clone(); + let mut out: Vec = match (&original, replace_mime) { + (Some((_, _, orig)), false) => { + compose_smart(ctx, &msg, &mime_raw, orig, cmd == "SmartForward")? + } + _ => mime_raw.clone(), + }; + // sender handling + let from_hdr = msg + .from() + .and_then(|f| f.first()) + .and_then(|a| a.address()) + .map(|s| s.to_string()); + match from_hdr { + None => { + out = mime::prepend_headers(&out, &[("From".into(), from_addr.clone())]); + } + Some(f) if ctx.s.smtp.enforce_from && !f.eq_ignore_ascii_case(&from_addr) => { + let name = msg + .from() + .and_then(|f| f.first()) + .and_then(|a| a.name()) + .map(|n| n.to_string()); + out = mime::strip_headers(&out, &["From"]); + let v = match name { + Some(n) => format!("{} <{}>", mime::encode_word(&n), from_addr), + None => from_addr.clone(), + }; + out = mime::prepend_headers(&out, &[("From".into(), v)]); + tracing::info!(user = %ctx.user(), "From header {f} replaced with {from_addr}"); + } + _ => {} + } + if msg.date().is_none() { + out = mime::prepend_headers(&out, &[("Date".into(), chrono::Utc::now().to_rfc2822())]); + } + if msg.message_id().is_none() { + let dom = from_addr.split('@').nth(1).unwrap_or("activepush.local"); + out = mime::prepend_headers( + &out, + &[( + "Message-ID".into(), + format!("<{}@{}>", uuid::Uuid::new_v4(), dom), + )], + ); + } + let wire = mime::strip_headers(&out, &["Bcc"]); + + let password = ctx.sess.password().to_string(); + if let Err(e) = smtp::send( + &ctx.s.smtp, + &ctx.sess.login, + &password, + &from_addr, + &rcpts, + &wire, + ) + .await + { + return Ok(fail(ctx, 110, &format!("{e:#}"))); + } + ctx.app.metrics.mail_sent(); + ctx.items_in += 1; + tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, recipients = rcpts.len(), bytes = wire.len(), "{cmd} delivered to SMTP"); + + if save_sent && ctx.s.smtp.save_in_sent { + match ctx.sess.special_folder(FT_SENT).await { + Ok(Some(sent)) => { + if let Err(e) = ctx.sess.mail_append(sent.path(), "\\Seen", &out).await { + tracing::warn!(user = %ctx.user(), "saving to Sent failed: {e:#}"); + } + } + _ => tracing::warn!(user = %ctx.user(), "no Sent folder found; copy not saved"), + } + } + if let Some((folder, uid, _)) = &original { + let flag = if cmd == "SmartForward" { + "$Forwarded" + } else { + "\\Answered" + }; + let _ = ctx.sess.mail_add_flag(folder.path(), *uid, flag).await; + } + Ok(Reply::Empty) +} + +fn compose_smart( + ctx: &Ctx, + msg: &mail_parser::Message, + raw: &[u8], + orig_raw: &[u8], + forward: bool, +) -> Result> { + let orig = MessageParser::default() + .parse(orig_raw) + .ok_or_else(|| anyhow!("original message unparseable"))?; + // headers from the client message (minus MIME structure headers) + let (hblock, _) = mime::split_headers(raw); + let mut headers: Vec<(String, String)> = mime::header_lines(hblock) + .into_iter() + .filter(|(k, _)| { + ![ + "content-type", + "content-transfer-encoding", + "mime-version", + "content-disposition", + ] + .contains(&k.to_ascii_lowercase().as_str()) + }) + .collect(); + if !forward { + if let Some(mid) = orig.message_id() { + if !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("In-Reply-To")) + { + headers.push(("In-Reply-To".into(), format!("<{mid}>"))); + } + if !headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("References")) + { + let mut refs: Vec = orig + .references() + .as_text_list() + .map(|l| l.iter().map(|r| format!("<{r}>")).collect()) + .or_else(|| orig.references().as_text().map(|r| vec![format!("<{r}>")])) + .unwrap_or_default(); + refs.push(format!("<{mid}>")); + headers.push(("References".into(), refs.join(" "))); + } + } + } + let new_text = msg.body_text(0).map(|s| s.into_owned()).unwrap_or_default(); + let new_html = if msg.html_body_count() > 0 + && msg + .html_bodies() + .any(|p| matches!(p.body, mail_parser::PartType::Html(_))) + { + msg.body_html(0).map(|s| s.into_owned()) + } else { + None + }; + let o_text = orig + .body_text(0) + .map(|s| s.into_owned()) + .unwrap_or_else(|| { + orig.body_html(0) + .map(|h| html_to_text(&h)) + .unwrap_or_default() + }); + let o_html = if orig.html_body_count() > 0 + && orig + .html_bodies() + .any(|p| matches!(p.body, mail_parser::PartType::Html(_))) + { + orig.body_html(0).map(|s| s.into_owned()) + } else { + None + }; + let addr = |a: Option<&mail_parser::Address>| { + a.map(|a| { + a.iter() + .map(|x| match (x.name(), x.address()) { + (Some(n), Some(ad)) => format!("{n} <{ad}>"), + (None, Some(ad)) => ad.to_string(), + _ => String::new(), + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default() + }; + let date = orig.date().map(|d| d.to_rfc822()).unwrap_or_default(); + let subj = orig.subject().unwrap_or("").to_string(); + let from = addr(orig.from()); + let to = addr(orig.to()); + let (text, html, mut atts) = if forward && !ctx.s.imap.inline_forward { + ( + new_text.clone(), + new_html.clone(), + vec![mime::Attachment { + name: format!("{}.eml", if subj.is_empty() { "message" } else { &subj }), + content_type: "message/rfc822".into(), + data: orig_raw.to_vec(), + content_id: None, + inline: false, + }], + ) + } else if forward { + let head = format!("-------- Original Message --------\nSubject: {subj}\nDate: {date}\nFrom: {from}\nTo: {to}\n\n"); + let text = format!("{new_text}\n\n{head}{o_text}"); + let html = new_html.clone().map(|nh| { + let oh = o_html.clone().unwrap_or_else(|| format!("
{}
", html_escape(&o_text))); + format!( + "{nh}
-------- Original Message --------
Subject: {}
Date: {}
From: {}
To: {}

{oh}
", + html_escape(&subj), + html_escape(&date), + html_escape(&from), + html_escape(&to) + ) + }); + let mut a = Vec::new(); + for (i, part) in orig.attachments().enumerate() { + if let Some((name, ct, data)) = email::attachment(orig_raw, i) { + a.push(mime::Attachment { + name, + content_type: ct, + data, + content_id: part.content_id().map(|s| s.to_string()), + inline: false, + }); + } + } + (text, html, a) + } else { + let quoted: String = o_text.lines().map(|l| format!("> {l}\n")).collect(); + let text = format!("{new_text}\n\nOn {date}, {from} wrote:\n{quoted}"); + let html = new_html.clone().map(|nh| { + let oh = o_html.clone().unwrap_or_else(|| format!("
{}
", html_escape(&o_text))); + format!( + "{nh}
On {}, {} wrote:
{oh}
", + html_escape(&date), + html_escape(&from) + ) + }); + (text, html, Vec::new()) + }; + // attachments the client added itself + for (i, part) in msg.attachments().enumerate() { + if let Some((name, ct, data)) = email::attachment(raw, i) { + atts.push(mime::Attachment { + name, + content_type: ct, + data, + content_id: part.content_id().map(|s| s.to_string()), + inline: false, + }); + } + } + Ok(mime::build(&headers, Some(&text), html.as_deref(), &atts)) +} + +// ------------------------------------------------------------------ MeetingResponse + +pub async fn meeting_response(ctx: &mut Ctx, req: Option) -> Result { + let mut root = Element::new("MeetingResponse:MeetingResponse"); + let Some(req) = req else { + return Ok(Reply::Wbxml(root)); + }; + for r in req.children_named("MeetingResponse:Request") { + let resp_code = r.child_u32("MeetingResponse:UserResponse").unwrap_or(0); + let col = r + .child_text("MeetingResponse:CollectionId") + .unwrap_or_default(); + let rid = r + .child_text("MeetingResponse:RequestId") + .or_else(|| r.child_text("Search:LongId")) + .unwrap_or_default(); + let mut res = Element::new("MeetingResponse:Result") + .with_text("MeetingResponse:RequestId", rid.clone()); + let outcome: Result> = async { + let col = if col.is_empty() { + super::sync::collection_of(&rid).to_string() + } else { + col.clone() + }; + let folder = resolve(ctx, &col) + .await? + .ok_or_else(|| anyhow!("collection not found"))?; + let sid = if rid.contains(':') { + rid.clone() + } else { + format!("{col}:{rid}") + }; + match folder.kind() { + Kind::Mail => { + let uid: u32 = parse_server_id(ctx, &sid, &folder.id) + .await? + .and_then(|u| u.parse().ok()) + .ok_or_else(|| anyhow!("item not found"))?; + let m = ctx.sess.fetch_mail(folder.path(), uid).await?; + let ics = + email::calendar_of(&m.body).ok_or_else(|| anyhow!("no calendar part"))?; + let partstat = match resp_code { + 1 => "ACCEPTED", + 2 => "TENTATIVE", + _ => "DECLINED", + }; + let mut cal_id = None; + if resp_code != 3 && ctx.s.caldav.enabled { + if let Some(cal) = ctx.sess.special_folder(FT_CALENDAR).await? { + let updated = calendar::set_partstat(&ics, &ctx.sess.email, partstat) + .ok_or_else(|| anyhow!("bad calendar data"))?; + let (id, _) = ctx.sess.dav_put(&cal, None, updated, None).await?; + let cal_sid = folder_short(ctx, &cal.id).await?; + cal_id = Some(server_id(ctx, &cal_sid, &cal.id, &id).await?); + } + } + // the device sends the reply e-mail itself; remove the invitation like Z-Push + let _ = ctx.sess.mail_delete(&folder, uid, true).await; + Ok(cal_id) + } + Kind::Calendar => { + let id = parse_server_id(ctx, &sid, &folder.id) + .await? + .ok_or_else(|| anyhow!("item not found"))?; + let cur = ctx.sess.fetch_dav(&folder, &id).await?; + let partstat = match resp_code { + 1 => "ACCEPTED", + 2 => "TENTATIVE", + _ => "DECLINED", + }; + let updated = calendar::set_partstat( + cur.data.as_deref().unwrap_or(""), + &ctx.sess.email, + partstat, + ) + .ok_or_else(|| anyhow!("bad calendar data"))?; + ctx.sess + .dav_put(&folder, Some(&id), updated, Some(&cur.etag)) + .await?; + Ok(Some(sid)) + } + _ => Err(anyhow!("invalid collection")), + } + } + .await; + match outcome { + Ok(cal) => { + res.push_text("MeetingResponse:Status", "1"); + if let Some(c) = cal { + if resp_code != 3 { + res.push_text("MeetingResponse:CalendarId", c); + } + } + db::audit( + &ctx.app.db, + ctx.user(), + "meeting_response", + &rid, + &ctx.ip, + &resp_code.to_string(), + ) + .await; + } + Err(e) => { + tracing::warn!(user = %ctx.user(), "MeetingResponse failed: {e:#}"); + res.push_text( + "MeetingResponse:Status", + if e.to_string().contains("not found") { + "2" + } else { + "4" + }, + ); + } + } + root.push(res); + } + Ok(Reply::Wbxml(root)) +} + +async fn folder_short(ctx: &Ctx, bid: &str) -> Result { + Ok(db::folder_short_id(&ctx.app.db, ctx.user(), bid) + .await? + .to_string()) +} diff --git a/src/eas/misc.rs b/src/eas/misc.rs new file mode 100644 index 0000000..c904cc5 --- /dev/null +++ b/src/eas/misc.rs @@ -0,0 +1,445 @@ +//! Settings, Search (GAL + Mailbox), ResolveRecipients. + +use std::time::{Duration, Instant}; + +use anyhow::Result; + +use super::convert::{contact, Options}; +use super::folders::resolve; +use super::provision::device_info_from; +use super::sync::server_id; +use super::{Ctx, Reply}; +use crate::backend::{dav, imap, Kind}; +use crate::config::expand_user_template; +use crate::db::{self, DeviceField}; +use crate::wbxml::Element; + +// ------------------------------------------------------------------ Settings + +pub async fn settings(ctx: &mut Ctx, req: Option) -> Result { + let mut root = Element::new("Settings:Settings").with_text("Settings:Status", "1"); + let Some(req) = req else { + return Ok(Reply::Wbxml(root)); + }; + for item in &req.children { + match item.tag() { + "Oof" => { + let mut o = Element::new("Settings:Oof"); + if item.has("Settings:Get") { + o.push_text("Settings:Status", "1"); + o.push(Element::new("Settings:Get").with_text("Settings:OofState", "0")); + } else { + // out-of-office is managed by the mail server (e.g. Sieve vacation) + o.push_text("Settings:Status", "2"); + } + root.push(o); + } + "DeviceInformation" => { + if let Some(set) = item.child("Settings:Set") { + db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?; + } + root.push( + Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"), + ); + } + "DevicePassword" => { + if let Some(pw) = item.path(&["Settings:Set", "Settings:Password"]) { + let v = pw.text_value(); + if v.len() <= 256 { + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::RecoveryPassword, + None, + Some(&v), + ) + .await?; + db::audit( + &ctx.app.db, + ctx.user(), + "recovery_password_set", + &ctx.req.device_id, + &ctx.ip, + "", + ) + .await; + } + } + root.push( + Element::new("Settings:DevicePassword").with_text("Settings:Status", "1"), + ); + } + "UserInformation" => { + let email = ctx.sess.email.clone(); + let mut get = Element::new("Settings:Get"); + if ctx.req.ver() >= 141 { + get.push( + Element::new("Settings:Accounts").with( + Element::new("Settings:Account") + .with_text("Settings:UserDisplayName", email.clone()) + .with( + Element::new("Settings:EmailAddresses") + .with_text("Settings:PrimarySmtpAddress", email.clone()), + ), + ), + ); + } else { + get.push( + Element::new("Settings:EmailAddresses") + .with_text("Settings:SmtpAddress", email), + ); + } + root.push( + Element::new("Settings:UserInformation") + .with_text("Settings:Status", "1") + .with(get), + ); + } + "RightsManagementInformation" => { + root.push( + Element::new("Settings:RightsManagementInformation") + .with_text("Settings:Status", "1") + .with( + Element::new("Settings:Get") + .with(Element::new("RightsManagement:RightsManagementTemplates")), + ), + ); + } + _ => {} + } + } + Ok(Reply::Wbxml(root)) +} + +// ------------------------------------------------------------------ GAL helpers + +pub struct Person { + pub summary: contact::CardSummary, + pub from_gal: bool, +} + +pub async fn find_people(ctx: &mut Ctx, query: &str, limit: usize) -> Result> { + let mut out: Vec = Vec::new(); + let q = query.trim(); + if q.chars().count() < ctx.s.carddav.gal_min_length.max(1) { + return Ok(out); + } + let Some((client, home)) = ctx.sess.carddav()? else { + return Ok(out); + }; + let s = ctx.s.clone(); + let mut books: Vec<(String, bool)> = Vec::new(); + if !s.carddav.gal_path.is_empty() { + books.push(( + dav::encode_path(&expand_user_template( + &s.carddav.gal_path, + ctx.user(), + &s.general.default_domain, + )), + true, + )); + } + if let Ok(cols) = client.collections(&home, dav::DavKind::AddressBook).await { + for c in cols { + let p = dav::encode_path(&c.href); + if !books.iter().any(|(b, _)| *b == p) { + books.push((p, false)); + } + } + } + for (b, gal) in books { + let res = match client.search_cards(&b, q).await { + Ok(r) => r, + Err(e) => { + tracing::debug!("GAL search in {b} failed: {e:#}"); + continue; + } + }; + for r in res { + if let Some(sm) = r.data.as_deref().and_then(contact::summary) { + if sm.emails.is_empty() && sm.display_name.is_empty() { + continue; + } + let dup = out.iter().any(|p| { + !sm.emails.is_empty() && p.summary.emails.first() == sm.emails.first() + }); + if !dup { + out.push(Person { + summary: sm, + from_gal: gal, + }); + } + if out.len() >= limit { + return Ok(out); + } + } + } + } + Ok(out) +} + +fn parse_range(r: Option, def: usize) -> (usize, usize) { + r.and_then(|r| { + let (a, b) = r.split_once('-')?; + Some((a.trim().parse().ok()?, b.trim().parse().ok()?)) + }) + .filter(|(a, b)| a <= b) + .unwrap_or((0, def.saturating_sub(1))) +} + +// ------------------------------------------------------------------ Search + +pub async fn search(ctx: &mut Ctx, req: Option) -> Result { + let status_only = + |s: &str| Reply::Wbxml(Element::new("Search:Search").with_text("Search:Status", s)); + let Some(req) = req else { + return Ok(status_only("2")); + }; + let Some(store) = req.child("Search:Store") else { + return Ok(status_only("2")); + }; + let name = store.child_text("Search:Name").unwrap_or_default(); + let opts_el = store.child("Search:Options"); + let max = ctx.s.sync.search_max_results as usize; + let (from, to) = parse_range( + opts_el.and_then(|o| o.child_text("Search:Range")), + max.min(100), + ); + let to = to.min(from + max.max(1) - 1); + let mut st = Element::new("Search:Store"); + let started = Instant::now(); + let deadline = Duration::from_secs(ctx.s.sync.search_wait_secs.max(3)); + + match name.to_ascii_lowercase().as_str() { + "gal" => { + let q = store.child_text("Search:Query").unwrap_or_default(); + let people = find_people(ctx, &q, to + 1).await.unwrap_or_default(); + st.push_text("Search:Status", "1"); + let total = people.len(); + for p in people.into_iter().skip(from).take(to + 1 - from) { + let s = p.summary; + let mut props = Element::new("Search:Properties"); + props.push_text( + "GAL:DisplayName", + if s.display_name.is_empty() { + s.emails.first().cloned().unwrap_or_default() + } else { + s.display_name.clone() + }, + ); + props.push_opt("GAL:Phone", (!s.phone.is_empty()).then(|| s.phone.clone())); + props.push_opt( + "GAL:Office", + (!s.office.is_empty()).then(|| s.office.clone()), + ); + props.push_opt("GAL:Title", (!s.title.is_empty()).then(|| s.title.clone())); + props.push_opt( + "GAL:Company", + (!s.company.is_empty()).then(|| s.company.clone()), + ); + props.push_opt( + "GAL:FirstName", + (!s.first.is_empty()).then(|| s.first.clone()), + ); + props.push_opt("GAL:LastName", (!s.last.is_empty()).then(|| s.last.clone())); + props.push_opt( + "GAL:MobilePhone", + (!s.mobile.is_empty()).then(|| s.mobile.clone()), + ); + if let Some(e) = s.emails.first() { + props.push_text("GAL:EmailAddress", e.clone()); + } + st.push(Element::new("Search:Result").with(props)); + } + if total > 0 { + st.push_text( + "Search:Range", + format!( + "{from}-{}", + (from + total.min(to + 1 - from)).saturating_sub(1) + ), + ); + } + st.push_text("Search:Total", total.to_string()); + ctx.items_out += total as u64; + } + "mailbox" => { + let Some(query) = store.child("Search:Query") else { + return Ok(status_only("2")); + }; + let and = query.child("Search:And").unwrap_or(query); + let text = and.child_text("Search:FreeText").unwrap_or_default(); + let col = and.child_text("AirSync:CollectionId"); + let since = and + .child("Search:GreaterThan") + .and_then(|g| g.child_text("Search:Value")) + .and_then(|v| crate::backend::tz::parse_eas_date(&v)); + let deep = opts_el + .map(|o| o.has("Search:DeepTraversal")) + .unwrap_or(false); + let mut opts = opts_el.map(Options::parse).unwrap_or_default(); + if opts.prefs.is_empty() { + opts.prefs.push(super::convert::BodyPref { + typ: 1, + trunc: Some(5120), + preview: Some(255), + ..Default::default() + }); + } + // folders to search + let all = ctx.sess.folders().await?; + let mut folders = Vec::new(); + match &col { + Some(c) => { + if let Some(f) = resolve(ctx, c).await? { + if deep { + let pref = f.id.clone(); + for x in all.iter().filter(|x| { + x.kind() == Kind::Mail + && (x.id == pref + || x.id.starts_with(&format!("{pref}/")) + || x.id.starts_with(&format!("{pref}."))) + }) { + folders.push(x.clone()); + } + } else { + folders.push(f); + } + } + } + None => folders.extend(all.iter().filter(|x| x.kind() == Kind::Mail).cloned()), + } + let mut hits: Vec<(crate::backend::BFolder, u32)> = Vec::new(); + for f in folders { + if started.elapsed() > deadline { + break; + } + match ctx.sess.mail_search(&f, &text, since).await { + Ok(uids) => hits.extend(uids.into_iter().rev().map(|u| (f.clone(), u))), + Err(e) => tracing::debug!("search in {} failed: {e:#}", f.name), + } + } + let total = hits.len(); + st.push_text("Search:Status", "1"); + for (f, uid) in hits.into_iter().skip(from).take(to + 1 - from) { + let Ok(m) = ctx.sess.fetch_mail(f.path(), uid).await else { + continue; + }; + let cid = super::folders::short_id(ctx, &f.id).await?; + let sid = server_id(ctx, &cid, &f.id, &uid.to_string()).await?; + let conv = super::convert::email::to_eas( + &m, + &super::convert::email::MailContext { + collection: &cid, + ver: ctx.req.ver(), + opts: &opts, + default_tz: ctx.s.timezone(), + }, + ); + let mut props = Element::new("Search:Properties"); + props.children = conv.app.children; + st.push( + Element::new("Search:Result") + .with_text("AirSync:Class", "Email") + .with_text("Search:LongId", sid) + .with_text("AirSync:CollectionId", cid) + .with(props), + ); + ctx.items_out += 1; + } + if total > 0 { + st.push_text( + "Search:Range", + format!( + "{from}-{}", + (from + total.min(to + 1 - from)).saturating_sub(1) + ), + ); + } + st.push_text("Search:Total", total.to_string()); + } + _ => { + st.push_text("Search:Status", "3"); + } + } + let _ = imap::quote; // keep module linked for docs + Ok(Reply::Wbxml( + Element::new("Search:Search") + .with_text("Search:Status", "1") + .with(Element::new("Search:Response").with(st)), + )) +} + +// ------------------------------------------------------------------ ResolveRecipients + +pub async fn resolve_recipients(ctx: &mut Ctx, req: Option) -> Result { + let mut root = Element::new("ResolveRecipients:ResolveRecipients"); + let Some(req) = req else { + return Ok(Reply::Wbxml( + root.with_text("ResolveRecipients:Status", "5"), + )); + }; + let opts = req.child("ResolveRecipients:Options"); + let max_amb = opts + .and_then(|o| o.child_u32("ResolveRecipients:MaxAmbiguousRecipients")) + .unwrap_or(19) + .clamp(1, 100) as usize; + let want_certs = opts + .and_then(|o| o.child_u32("ResolveRecipients:CertificateRetrieval")) + .unwrap_or(1) + > 1; + let want_avail = opts + .map(|o| o.has("ResolveRecipients:Availability")) + .unwrap_or(false); + root.push_text("ResolveRecipients:Status", "1"); + for to in req.children_named("ResolveRecipients:To") { + let q = to.text_value(); + let people = find_people(ctx, &q, max_amb).await.unwrap_or_default(); + let mut resp = + Element::new("ResolveRecipients:Response").with_text("ResolveRecipients:To", q.clone()); + let mut recips: Vec<(u8, String, String)> = people + .into_iter() + .filter_map(|p| { + let e = p.summary.emails.first()?.clone(); + let n = if p.summary.display_name.is_empty() { + e.clone() + } else { + p.summary.display_name.clone() + }; + Some((if p.from_gal { 1 } else { 2 }, n, e)) + }) + .collect(); + if recips.is_empty() && q.contains('@') && !q.contains(' ') { + recips.push((1, q.clone(), q.clone())); + } + let status = match recips.len() { + 0 => "4", + 1 => "1", + _ => "2", + }; + resp.push_text("ResolveRecipients:Status", status); + resp.push_text("ResolveRecipients:RecipientCount", recips.len().to_string()); + for (t, n, e) in recips { + let mut r = Element::new("ResolveRecipients:Recipient") + .with_text("ResolveRecipients:Type", t.to_string()) + .with_text("ResolveRecipients:DisplayName", n) + .with_text("ResolveRecipients:EmailAddress", e); + if want_avail { + r.push( + Element::new("ResolveRecipients:Availability") + .with_text("ResolveRecipients:Status", "163"), + ); + } + if want_certs { + r.push( + Element::new("ResolveRecipients:Certificates") + .with_text("ResolveRecipients:Status", "7") + .with_text("ResolveRecipients:CertificateCount", "0"), + ); + } + resp.push(r); + } + root.push(resp); + } + Ok(Reply::Wbxml(root)) +} diff --git a/src/eas/mod.rs b/src/eas/mod.rs new file mode 100644 index 0000000..b734546 --- /dev/null +++ b/src/eas/mod.rs @@ -0,0 +1,836 @@ +//! Exchange ActiveSync HTTP endpoint (`/Microsoft-Server-ActiveSync`). + +pub mod convert; +pub mod folders; +pub mod itemops; +pub mod mailcmd; +pub mod misc; +pub mod ping; +pub mod provision; +pub mod state; +pub mod sync; + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Instant; + +use axum::body::Body; +use axum::extract::{ConnectInfo, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use http_body_util::BodyExt; + +use crate::app::App; +use crate::backend::{AuthResult, Session}; +use crate::config::{LoginFormat, Settings}; +use crate::db::{self, Device}; +use crate::metrics::{ActiveRequest, Event}; +use crate::util::{self, now}; +use crate::wbxml::{self, Element}; + +pub const SUPPORTED_VERSIONS: &[&str] = &["2.5", "12.0", "12.1", "14.0", "14.1"]; +pub const COMMANDS: &str = "Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert"; + +#[derive(Debug, Clone, Default)] +pub struct EasRequest { + pub cmd: String, + pub user: String, + pub device_id: String, + pub device_type: String, + pub version: String, + pub policy_key: Option, + pub attachment_name: Option, + pub collection_id: Option, + pub item_id: Option, + pub long_id: Option, + pub parent_id: Option, + pub occurrence: Option, + pub save_in_sent: bool, + pub accept_multipart: bool, + pub user_agent: String, + pub content_type: String, +} + +impl EasRequest { + /// Protocol version as integer: 25, 120, 121, 140, 141. + pub fn ver(&self) -> u32 { + match self.version.as_str() { + "2.5" => 25, + "12.0" => 120, + "12.1" => 121, + "14.0" => 140, + "14.1" => 141, + "16.0" => 160, + "16.1" => 161, + _ => 25, + } + } +} + +const BASE64_COMMANDS: &[(u8, &str)] = &[ + (0, "Sync"), + (1, "SendMail"), + (2, "SmartForward"), + (3, "SmartReply"), + (4, "GetAttachment"), + (9, "FolderSync"), + (10, "FolderCreate"), + (11, "FolderDelete"), + (12, "FolderUpdate"), + (13, "MoveItems"), + (14, "GetItemEstimate"), + (15, "MeetingResponse"), + (16, "Search"), + (17, "Settings"), + (18, "Ping"), + (19, "ItemOperations"), + (20, "Provision"), + (21, "ResolveRecipients"), + (22, "ValidateCert"), +]; + +pub fn parse_request(uri: &Uri, headers: &HeaderMap) -> Result { + let q = uri.query().unwrap_or(""); + let h = |n: &str| { + headers + .get(n) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .trim() + .to_string() + }; + let mut r = EasRequest { + user_agent: h("user-agent"), + content_type: h("content-type"), + ..Default::default() + }; + if q.contains("Cmd=") || q.contains("DeviceId=") || q.is_empty() { + for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { + let v = v.to_string(); + match k.as_ref() { + "Cmd" => r.cmd = v, + "User" => r.user = v, + "DeviceId" => r.device_id = v, + "DeviceType" => r.device_type = v, + "AttachmentName" => r.attachment_name = Some(v), + "CollectionId" => r.collection_id = Some(v), + "ItemId" => r.item_id = Some(v), + "LongId" => r.long_id = Some(v), + "ParentId" => r.parent_id = Some(v), + "Occurrence" => r.occurrence = Some(v), + "SaveInSent" => r.save_in_sent = v.eq_ignore_ascii_case("T"), + _ => {} + } + } + r.version = h("ms-asprotocolversion"); + let pk = h("x-ms-policykey"); + if !pk.is_empty() { + r.policy_key = pk.parse().ok(); + } + r.accept_multipart = h("ms-asacceptmultipart").eq_ignore_ascii_case("T"); + } else { + // base64 encoded query (MS-ASHTTP 2.2.1.1.1.1) + let raw = percent_encoding::percent_decode_str(q) + .decode_utf8_lossy() + .into_owned(); + let b = base64::engine::general_purpose::STANDARD + .decode(raw.trim()) + .map_err(|_| "invalid base64 query")?; + struct Cur<'a> { + b: &'a [u8], + p: usize, + } + impl<'a> Cur<'a> { + fn byte(&mut self) -> Result { + let v = *self.b.get(self.p).ok_or("truncated query")?; + self.p += 1; + Ok(v) + } + fn take(&mut self, n: usize) -> Result<&'a [u8], &'static str> { + let s = self.b.get(self.p..self.p + n).ok_or("truncated query")?; + self.p += n; + Ok(s) + } + } + let mut c = Cur { b: &b, p: 0 }; + let ver = c.byte()?; + r.version = format!("{}.{}", ver / 10, ver % 10); + let code = c.byte()?; + r.cmd = BASE64_COMMANDS + .iter() + .find(|(cc, _)| *cc == code) + .map(|(_, n)| n.to_string()) + .ok_or("unknown command code")?; + let _locale = c.take(2)?; + let dlen = c.byte()? as usize; + let dev = c.take(dlen)?; + r.device_id = if dev.iter().all(|x| x.is_ascii_graphic()) { + String::from_utf8_lossy(dev).into_owned() + } else { + hex::encode(dev) + }; + let pklen = c.byte()? as usize; + let pk = c.take(pklen)?; + if pklen == 4 { + r.policy_key = Some(u32::from_le_bytes([pk[0], pk[1], pk[2], pk[3]])); + } + let tlen = c.byte()? as usize; + r.device_type = String::from_utf8_lossy(c.take(tlen)?).into_owned(); + while c.p + 1 < c.b.len() { + let tag = c.byte()?; + let len = c.byte()? as usize; + let val = c.take(len)?; + let sv = String::from_utf8_lossy(val).into_owned(); + match tag { + 0 => r.attachment_name = Some(sv), + 1 => r.collection_id = Some(sv), + 3 => r.item_id = Some(sv), + 4 => r.long_id = Some(sv), + 5 => r.parent_id = Some(sv), + 6 => r.occurrence = Some(sv), + 7 => { + let o = val.first().copied().unwrap_or(0); + r.save_in_sent = o & 0x01 != 0; + r.accept_multipart = o & 0x02 != 0; + } + 8 => r.user = sv, + _ => {} + } + } + } + if r.version.is_empty() { + r.version = "2.5".into(); + } + Ok(r) +} + +fn valid_device_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 128 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +fn valid_device_type(s: &str) -> bool { + s.len() <= 64 && s.chars().all(|c| c.is_ascii_graphic()) +} + +/// Determine the real client IP honouring trusted proxies. +pub fn client_ip(peer: SocketAddr, headers: &HeaderMap, s: &Settings) -> IpAddr { + let peer_ip = util::normalize(peer.ip()); + if !util::ip_in_any(&peer_ip, &s.security.trusted_proxies) { + return peer_ip; + } + let hv = headers + .get(s.security.real_ip_header.as_str()) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + // walk right-to-left skipping trusted proxies + for part in hv.split(',').rev() { + if let Ok(ip) = part.trim().parse::() { + let ip = util::normalize(ip); + if !util::ip_in_any(&ip, &s.security.trusted_proxies) { + return ip; + } + } + } + peer_ip +} + +/// Parse `Authorization: Basic` and normalise the login according to settings. +pub fn basic_auth(headers: &HeaderMap, s: &Settings) -> Option<(String, String)> { + let v = headers.get("authorization")?.to_str().ok()?; + let (scheme, data) = v.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("basic") { + return None; + } + let dec = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .ok()?; + let dec = String::from_utf8(dec).ok()?; + let (user, pass) = dec.split_once(':')?; + let login = normalize_login(user, s)?; + if pass.is_empty() || pass.len() > 1024 { + return None; + } + Some((login, pass.to_string())) +} + +pub fn normalize_login(user: &str, s: &Settings) -> Option { + let mut u = user.trim().to_string(); + if let Some((_, rest)) = u.split_once('\\') { + u = rest.to_string(); + } + let u = match s.general.login_format { + LoginFormat::AsGiven => u, + LoginFormat::LocalPart => u.split('@').next().unwrap_or("").to_string(), + LoginFormat::FullEmail => { + if u.contains('@') || s.general.default_domain.is_empty() { + u + } else { + format!("{u}@{}", s.general.default_domain) + } + } + }; + let u = u.to_lowercase(); + util::valid_login(&u).then_some(u) +} + +/// Access policy checks shared by EAS and Autodiscover. +pub fn user_allowed(login: &str, s: &Settings) -> bool { + let sec = &s.security; + if sec + .blocked_users + .iter() + .any(|b| b.eq_ignore_ascii_case(login)) + { + return false; + } + if !sec.allowed_users.is_empty() + && !sec + .allowed_users + .iter() + .any(|b| b.eq_ignore_ascii_case(login)) + { + return false; + } + if !s.general.allowed_domains.is_empty() { + let dom = login.split('@').nth(1).unwrap_or(&s.general.default_domain); + if !s + .general + .allowed_domains + .iter() + .any(|d| d.eq_ignore_ascii_case(dom)) + { + return false; + } + } + true +} + +pub enum Reply { + Wbxml(Element), + Raw(&'static str, Vec), + Empty, + Status(StatusCode), +} + +pub struct Ctx { + pub app: Arc, + pub s: Arc, + pub req: EasRequest, + pub dev: Device, + pub sess: Session, + pub ip: String, + pub items_out: u64, + pub items_in: u64, + pub status: String, + pub debug: bool, +} + +impl Ctx { + pub fn ping_key(&self) -> String { + format!("{}\u{0}{}", self.req.device_id, self.sess.login) + } + + pub fn user(&self) -> &str { + &self.sess.login + } +} + +fn unauthorized() -> Response { + let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response(); + r.headers_mut().insert( + "WWW-Authenticate", + HeaderValue::from_static("Basic realm=\"ActiveSync\""), + ); + r +} + +fn server_headers(r: &mut Response, s: &Settings) { + let h = r.headers_mut(); + h.insert("MS-Server-ActiveSync", HeaderValue::from_static("14.1")); + h.insert("Cache-Control", HeaderValue::from_static("private")); + let _ = s; +} + +fn versions(s: &Settings) -> String { + let max = s.general.max_protocol.as_str(); + let idx = SUPPORTED_VERSIONS + .iter() + .position(|v| *v == max) + .unwrap_or(SUPPORTED_VERSIONS.len() - 1); + SUPPORTED_VERSIONS[..=idx].join(",") +} + +/// Axum handler for the ActiveSync endpoint. +pub async fn handler( + State(app): State>, + ConnectInfo(peer): ConnectInfo, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Response { + let s = app.settings(); + let ip = client_ip(peer, &headers, &s); + let ip_s = ip.to_string(); + if !s.security.eas_allowed_networks.is_empty() + && !util::ip_in_any(&ip, &s.security.eas_allowed_networks) + { + tracing::warn!(ip = %ip_s, "ActiveSync request from network that is not allowed"); + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + if method == Method::GET { + let mut r = ( + StatusCode::OK, + [("Content-Type", "text/html; charset=utf-8")], + "ActiveSync

This is an Exchange ActiveSync endpoint and can only be accessed by ActiveSync-capable devices.

", + ) + .into_response(); + server_headers(&mut r, &s); + return r; + } + if method != Method::POST && method != Method::OPTIONS { + return StatusCode::METHOD_NOT_ALLOWED.into_response(); + } + + // ---- authentication + let Some((login, password)) = basic_auth(&headers, &s) else { + return unauthorized(); + }; + let ip_key = format!("ip:{ip_s}"); + let user_key = format!("user:{login}"); + for k in [&ip_key, &user_key] { + if let Some(until) = app.auth.locked_until(k) { + app.metrics.auth_failure(); + let mut r = (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response(); + if let Ok(v) = HeaderValue::from_str(&(until - now()).max(1).to_string()) { + r.headers_mut().insert("Retry-After", v); + } + return r; + } + } + if !user_allowed(&login, &s) { + tracing::warn!(user = %login, ip = %ip_s, "ActiveSync access denied by policy"); + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + let mut sess = app.session(&login, &password); + let cache_key = app.pool.key(&login, &password); + if !app.auth.is_cached(&cache_key) { + match sess.authenticate().await { + Ok(AuthResult::Ok) => { + app.auth.success(&user_key); + app.auth.cache_ok(cache_key, s.security.auth_cache_secs); + } + Ok(AuthResult::Denied) => { + app.metrics.auth_failure(); + if s.security.log_auth_failures { + tracing::warn!(target: "active_push::auth", "authentication failed user={login} ip={ip_s} service=activesync"); + } + let max = s.security.auth_fail_max; + let w = s.security.auth_fail_window_secs; + let l = s.security.lockout_secs; + if app.auth.fail(&ip_key, max * 3, w, l) || app.auth.fail(&user_key, max, w, l) { + tracing::warn!(user = %login, ip = %ip_s, "login locked out after repeated failures"); + db::audit( + &app.db, + "system", + "lockout", + &login, + &ip_s, + "repeated ActiveSync login failures", + ) + .await; + } + return unauthorized(); + } + Err(e) => { + tracing::error!(user = %login, "backend unavailable during authentication: {e:#}"); + let mut r = + (StatusCode::SERVICE_UNAVAILABLE, "Backend unavailable").into_response(); + if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) { + r.headers_mut().insert("Retry-After", v); + } + return r; + } + } + } + + if method == Method::OPTIONS { + let mut r = StatusCode::OK.into_response(); + server_headers(&mut r, &s); + let h = r.headers_mut(); + h.insert( + "MS-ASProtocolVersions", + HeaderValue::from_str(&versions(&s)).unwrap(), + ); + h.insert("MS-ASProtocolCommands", HeaderValue::from_static(COMMANDS)); + h.insert("Allow", HeaderValue::from_static("OPTIONS,POST")); + h.insert("Public", HeaderValue::from_static("OPTIONS,POST")); + sess.close().await; + return r; + } + + // ---- request + let req = match parse_request(&uri, &headers) { + Ok(r) => r, + Err(e) => { + tracing::info!(ip = %ip_s, "bad ActiveSync request: {e}"); + return (StatusCode::BAD_REQUEST, e).into_response(); + } + }; + let mut req = req; + if !COMMANDS.split(',').any(|c| c == req.cmd) { + req.cmd = "Unknown".into(); + } + if !valid_device_id(&req.device_id) + || !valid_device_type(&req.device_type) + || req.cmd.is_empty() + { + return (StatusCode::BAD_REQUEST, "Missing or invalid parameters").into_response(); + } + let blocked_type = s.security.blocked_device_types.iter().any(|b| { + let b = b.to_lowercase(); + !b.is_empty() + && (req.device_type.to_lowercase().contains(&b) + || req.user_agent.to_lowercase().contains(&b)) + }); + if blocked_type { + tracing::warn!(user = %login, device = %req.device_id, "device type blocked by policy"); + return (StatusCode::FORBIDDEN, "Device type not allowed").into_response(); + } + + let max_body = (s.sync.max_request_mb * 1024 * 1024) as usize; + let body = match http_body_util::Limited::new(body, max_body).collect().await { + Ok(b) => b.to_bytes(), + Err(_) => return (StatusCode::PAYLOAD_TOO_LARGE, "Request too large").into_response(), + }; + + let (dev, created) = match db::device_get_or_create( + &app.db, + &req.device_id, + &login, + &req.device_type, + &req.user_agent, + !s.security.require_device_approval, + ) + .await + { + Ok(d) => d, + Err(e) => { + tracing::error!("database error: {e:#}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + if created { + tracing::info!(user = %login, device = %req.device_id, kind = %req.device_type, "new device registered"); + db::audit( + &app.db, + &login, + "device_new", + &req.device_id, + &ip_s, + &req.user_agent, + ) + .await; + } + if dev.blocked != 0 { + return (StatusCode::FORBIDDEN, "Device blocked").into_response(); + } + if dev.approved == 0 { + tracing::info!(user = %login, device = %req.device_id, "device awaiting approval"); + return ( + StatusCode::FORBIDDEN, + "Device awaiting administrator approval", + ) + .into_response(); + } + let _ = db::device_touch( + &app.db, + dev.id, + &ip_s, + &req.cmd, + &req.version, + &req.user_agent, + &req.device_type, + ) + .await; + + let debug = s.logging.log_wbxml + || s.logging + .debug_users + .iter() + .any(|u| u.eq_ignore_ascii_case(&login)); + let started = Instant::now(); + let active_id = app.metrics.begin(ActiveRequest { + id: 0, + user: login.clone(), + device_id: req.device_id.clone(), + device_type: req.device_type.clone(), + command: req.cmd.clone(), + ip: ip_s.clone(), + started: now(), + version: req.version.clone(), + }); + + let mut ctx = Ctx { + app: app.clone(), + s: s.clone(), + req: req.clone(), + dev, + sess, + ip: ip_s.clone(), + items_out: 0, + items_in: 0, + status: String::new(), + debug, + }; + // A new request from the device ends its hanging Ping/Sync. + if ctx.req.cmd != "Ping" && ctx.req.cmd != "Sync" { + app.metrics.ping_interrupt(&ctx.ping_key()); + } + + let reply = dispatch(&mut ctx, &body).await; + let (mut resp, http, out_len) = match reply { + Ok(Reply::Wbxml(el)) => { + if ctx.debug { + tracing::info!(user = %login, cmd = %req.cmd, "response:\n{}", el.to_xml()); + } + let bytes = wbxml::encode(&el); + let n = bytes.len(); + let mut r = Response::new(Body::from(bytes)); + r.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("application/vnd.ms-sync.wbxml"), + ); + (r, 200u16, n) + } + Ok(Reply::Raw(ct, bytes)) => { + let n = bytes.len(); + let mut r = Response::new(Body::from(bytes)); + r.headers_mut() + .insert("Content-Type", HeaderValue::from_static(ct)); + (r, 200, n) + } + Ok(Reply::Empty) => (StatusCode::OK.into_response(), 200, 0), + Ok(Reply::Status(code)) => (code.into_response(), code.as_u16(), 0), + Err(e) => { + tracing::error!(user = %login, device = %req.device_id, cmd = %req.cmd, "request failed: {e:#}"); + ctx.status = format!("error: {e}"); + let mut r = StatusCode::INTERNAL_SERVER_ERROR.into_response(); + if e.to_string().contains("IMAP") || e.to_string().contains("connect") { + r = StatusCode::SERVICE_UNAVAILABLE.into_response(); + if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) { + r.headers_mut().insert("Retry-After", v); + } + } + let code = r.status().as_u16(); + (r, code, 0) + } + }; + server_headers(&mut resp, &s); + let ms = started.elapsed().as_millis() as u64; + if ms > 5000 && req.cmd != "Ping" && req.cmd != "Sync" { + tracing::info!(user = %login, cmd = %req.cmd, ms, "slow request"); + } + app.metrics.end( + active_id, + Event { + ts: now(), + user: login.clone(), + device_id: req.device_id.clone(), + device_type: req.device_type.clone(), + command: req.cmd.clone(), + ip: ip_s, + http, + status: if ctx.status.is_empty() { + "ok".into() + } else { + ctx.status.clone() + }, + ms, + bytes_in: body.len() as u64, + bytes_out: out_len as u64, + items_out: ctx.items_out, + items_in: ctx.items_in, + }, + ); + ctx.sess.close().await; + resp +} + +fn needs_provisioning(cmd: &str) -> bool { + !matches!(cmd, "Provision" | "Ping") +} + +/// WBXML root/status for "device must provision" answers (AS 14+). +fn provision_status_reply(cmd: &str, status: u32) -> Option { + let (root, st) = match cmd { + "Sync" => ("AirSync:Sync", "AirSync:Status"), + "FolderSync" => ("FolderHierarchy:FolderSync", "FolderHierarchy:Status"), + "FolderCreate" => ("FolderHierarchy:FolderCreate", "FolderHierarchy:Status"), + "FolderUpdate" => ("FolderHierarchy:FolderUpdate", "FolderHierarchy:Status"), + "FolderDelete" => ("FolderHierarchy:FolderDelete", "FolderHierarchy:Status"), + "GetItemEstimate" => ("GetItemEstimate:GetItemEstimate", "GetItemEstimate:Status"), + "Ping" => ("Ping:Ping", "Ping:Status"), + "ItemOperations" => ("ItemOperations:ItemOperations", "ItemOperations:Status"), + "Settings" => ("Settings:Settings", "Settings:Status"), + "Search" => ("Search:Search", "Search:Status"), + "SendMail" => ("ComposeMail:SendMail", "ComposeMail:Status"), + "SmartReply" => ("ComposeMail:SmartReply", "ComposeMail:Status"), + "SmartForward" => ("ComposeMail:SmartForward", "ComposeMail:Status"), + "MeetingResponse" => ("MeetingResponse:MeetingResponse", "MeetingResponse:Status"), + "ResolveRecipients" => ( + "ResolveRecipients:ResolveRecipients", + "ResolveRecipients:Status", + ), + "MoveItems" => ("Move:MoveItems", "Move:Status"), + _ => return None, + }; + Some(Element::new(root).with_text(st, status.to_string())) +} + +async fn dispatch(ctx: &mut Ctx, body: &[u8]) -> anyhow::Result { + // ---- provisioning enforcement + let wipe_pending = matches!( + ctx.dev.wipe_status, + db::WIPE_PENDING + | db::WIPE_REQUESTED + | db::WIPE_ACCOUNT_PENDING + | db::WIPE_ACCOUNT_REQUESTED + ); + if ctx.s.provisioning.enabled + && (needs_provisioning(&ctx.req.cmd) || (wipe_pending && ctx.req.cmd != "Provision")) + { + let sent = ctx.req.policy_key; + let current = ctx.dev.policy_key as u32; + let policy_changed = !provision::policy_hash_matches(ctx); + let ok = match sent { + Some(k) => k != 0 && k == current && !policy_changed && !wipe_pending, + None => ctx.s.provisioning.loose && !wipe_pending, + }; + if !ok { + ctx.status = "provisioning required".into(); + tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, "provisioning required"); + if ctx.req.ver() >= 140 { + let code = if wipe_pending { + 140 + } else if sent.map(|k| k != 0).unwrap_or(false) { + 144 + } else { + 142 + }; + if let Some(e) = provision_status_reply(&ctx.req.cmd, code) { + return Ok(Reply::Wbxml(e)); + } + } + return Ok(Reply::Status(StatusCode::from_u16(449).unwrap())); + } + } + + // ---- plain-input commands (AS < 14 SendMail family uses raw MIME) + let is_mime = ctx + .req + .content_type + .to_ascii_lowercase() + .starts_with("message/rfc822"); + let req_el = if body.is_empty() || is_mime || ctx.req.cmd == "GetAttachment" { + None + } else { + match wbxml::decode(body) { + Ok(e) => { + if ctx.debug { + tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "request:\n{}", e.to_xml()); + } + Some(e) + } + Err(err) => { + tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "invalid WBXML: {err}"); + ctx.status = "invalid wbxml".into(); + return Ok(Reply::Status(StatusCode::BAD_REQUEST)); + } + } + }; + + match ctx.req.cmd.as_str() { + "Provision" => provision::provision(ctx, req_el).await, + "FolderSync" => folders::folder_sync(ctx, req_el).await, + "FolderCreate" | "FolderUpdate" | "FolderDelete" => { + folders::folder_change(ctx, req_el).await + } + "GetHierarchy" => folders::get_hierarchy(ctx).await, + "Sync" => sync::sync(ctx, req_el).await, + "GetItemEstimate" => sync::get_item_estimate(ctx, req_el).await, + "Ping" => ping::ping(ctx, req_el).await, + "MoveItems" => sync::move_items(ctx, req_el).await, + "SendMail" | "SmartReply" | "SmartForward" => mailcmd::send_mail(ctx, req_el, body).await, + "GetAttachment" => itemops::get_attachment(ctx).await, + "ItemOperations" => itemops::item_operations(ctx, req_el).await, + "Settings" => misc::settings(ctx, req_el).await, + "Search" => misc::search(ctx, req_el).await, + "ResolveRecipients" => misc::resolve_recipients(ctx, req_el).await, + "MeetingResponse" => mailcmd::meeting_response(ctx, req_el).await, + "ValidateCert" => Ok(Reply::Wbxml( + Element::new("ValidateCert:ValidateCert").with_text("ValidateCert:Status", "1"), + )), + other => { + tracing::info!(cmd = other, "unsupported ActiveSync command"); + ctx.status = "not implemented".into(); + Ok(Reply::Status(StatusCode::NOT_IMPLEMENTED)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_query() { + // version 14.1, Sync (0), locale 0x0409, devid "abc", pk 4 bytes, type "iPhone", User "jan" + let mut b = vec![141u8, 0, 0x09, 0x04, 3]; + b.extend_from_slice(b"abc"); + b.push(4); + b.extend_from_slice(&7u32.to_le_bytes()); + b.push(6); + b.extend_from_slice(b"iPhone"); + b.extend_from_slice(&[8, 3]); + b.extend_from_slice(b"jan"); + let q = base64::engine::general_purpose::STANDARD.encode(&b); + let uri: Uri = format!("/Microsoft-Server-ActiveSync?{q}").parse().unwrap(); + let r = parse_request(&uri, &HeaderMap::new()).unwrap(); + assert_eq!(r.cmd, "Sync"); + assert_eq!(r.version, "14.1"); + assert_eq!(r.device_id, "abc"); + assert_eq!(r.policy_key, Some(7)); + assert_eq!(r.device_type, "iPhone"); + assert_eq!(r.user, "jan"); + } + + #[test] + fn plain_query() { + let uri: Uri = "/Microsoft-Server-ActiveSync?Cmd=FolderSync&User=jan&DeviceId=ABC123&DeviceType=Android" + .parse() + .unwrap(); + let mut h = HeaderMap::new(); + h.insert("MS-ASProtocolVersion", HeaderValue::from_static("14.0")); + h.insert("X-MS-PolicyKey", HeaderValue::from_static("123")); + let r = parse_request(&uri, &h).unwrap(); + assert_eq!(r.cmd, "FolderSync"); + assert_eq!(r.ver(), 140); + assert_eq!(r.policy_key, Some(123)); + } + + #[test] + fn login_formats() { + let mut s = Settings::default(); + s.general.default_domain = "firma.pl".into(); + s.general.login_format = LoginFormat::FullEmail; + assert_eq!(normalize_login("DOM\\Jan", &s).unwrap(), "jan@firma.pl"); + s.general.login_format = LoginFormat::LocalPart; + assert_eq!(normalize_login("jan@firma.pl", &s).unwrap(), "jan"); + assert!(normalize_login("a b", &s).is_none()); + } +} diff --git a/src/eas/ping.rs b/src/eas/ping.rs new file mode 100644 index 0000000..5f72c3a --- /dev/null +++ b/src/eas/ping.rs @@ -0,0 +1,238 @@ +//! Ping (push) and the shared "wait for changes" loop used by hanging Sync. + +use std::time::{Duration, Instant}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::folders::resolve; +use super::state::{self, CollectionState}; +use super::sync::{diff, effective_filter, filter_since}; +use super::{Ctx, Reply}; +use crate::backend::{BFolder, Kind}; +use crate::db; +use crate::metrics::PingInfo; +use crate::util::now; +use crate::wbxml::Element; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PingFolder { + id: String, + class: String, +} + +fn ping_status(status: u32) -> Element { + Element::new("Ping:Ping").with_text("Ping:Status", status.to_string()) +} + +pub async fn ping(ctx: &mut Ctx, req: Option) -> Result { + let s = ctx.s.clone(); + let mut lifetime = ctx.dev.ping_lifetime.max(0) as u64; + let mut folders: Vec = + serde_json::from_str(&ctx.dev.ping_folders).unwrap_or_default(); + if let Some(r) = &req { + if let Some(h) = r.child_u32("Ping:HeartbeatInterval") { + lifetime = h as u64; + } + if let Some(f) = r.child("Ping:Folders") { + folders = f + .children_named("Ping:Folder") + .map(|x| PingFolder { + id: x.child_text("Ping:Id").unwrap_or_default(), + class: x.child_text("Ping:Class").unwrap_or_default(), + }) + .filter(|f| !f.id.is_empty()) + .collect(); + } + } + if lifetime == 0 || folders.is_empty() { + ctx.status = "ping: missing parameters".into(); + return Ok(Reply::Wbxml(ping_status(3))); + } + if lifetime < s.ping.lifetime_min || lifetime > s.ping.lifetime_max { + let lim = if lifetime < s.ping.lifetime_min { + s.ping.lifetime_min + } else { + s.ping.lifetime_max + }; + return Ok(Reply::Wbxml( + ping_status(5).with_text("Ping:HeartbeatInterval", lim.to_string()), + )); + } + if folders.len() > s.ping.max_folders { + return Ok(Reply::Wbxml( + ping_status(6).with_text("Ping:MaxFolders", s.ping.max_folders.to_string()), + )); + } + // persist for subsequent empty Pings + let fj = serde_json::to_string(&folders)?; + if fj != ctx.dev.ping_folders { + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + db::DeviceField::PingFolders, + None, + Some(&fj), + ) + .await?; + } + if lifetime as i64 != ctx.dev.ping_lifetime { + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + db::DeviceField::PingLifetime, + Some(lifetime as i64), + None, + ) + .await?; + } + let ids: Vec = folders.iter().map(|f| f.id.clone()).collect(); + match wait_for_changes(ctx, &ids, lifetime, "ping").await { + Err(e) if e.to_string() == "hierarchy" => { + ctx.status = "ping: folder sync required".into(); + Ok(Reply::Wbxml(ping_status(7))) + } + Err(e) => Err(e), + Ok(None) => { + ctx.status = "ping: no changes".into(); + Ok(Reply::Wbxml(ping_status(1))) + } + Ok(Some(changed)) => { + ctx.status = format!("ping: {} changed", changed.len()); + let mut f = Element::new("Ping:Folders"); + for c in changed { + f.push_text("Ping:Folder", c); + } + Ok(Reply::Wbxml(ping_status(2).with(f))) + } + } +} + +struct Watched { + id: String, + folder: BFolder, + sig: String, +} + +/// Does the device's latest state for `cid` differ from the backend? +async fn has_changes(ctx: &mut Ctx, cid: &str, folder: &BFolder) -> Result { + let Some((_, _, st)) = state::load_latest::(ctx, cid).await? else { + // never synced: nothing to report until the device primes the folder + return Ok(false); + }; + let filter = effective_filter(ctx, folder.kind(), st.filter); + let listing = match folder.kind() { + Kind::Tasks if filter == 8 => ctx.sess.list(folder, None).await?, + _ => ctx.sess.list(folder, filter_since(filter)).await?, + }; + if !st.meta.is_empty() && !listing.meta.is_empty() && st.meta != listing.meta { + return Ok(true); + } + let d = diff(&st.items, &listing, folder.kind()); + Ok(!(d.adds.is_empty() && d.changes.is_empty() && d.deletes.is_empty())) +} + +/// Wait until one of the collections changes. Returns Ok(None) on timeout/interruption, +/// Err("hierarchy") if a collection does not exist anymore. +pub async fn wait_for_changes( + ctx: &mut Ctx, + ids: &[String], + lifetime: u64, + kind: &'static str, +) -> Result>> { + let mut watched = Vec::new(); + for id in ids { + match resolve(ctx, id).await? { + Some(f) => watched.push(Watched { + id: id.clone(), + folder: f, + sig: String::new(), + }), + None => { + ctx.app.stale_add(ctx.dev.id, id); + if kind == "ping" { + return Err(anyhow::anyhow!("hierarchy")); + } + } + } + } + let key = ctx.ping_key(); + let (cancel, gen) = ctx.app.metrics.ping_start( + &key, + PingInfo { + user: ctx.user().to_string(), + device_id: ctx.req.device_id.clone(), + device_type: ctx.req.device_type.clone(), + started: now(), + lifetime, + folders: watched.len(), + kind, + }, + ); + let result = async { + // initial check: changes that happened while the device was not pinging + let mut changed = Vec::new(); + for w in watched.iter_mut() { + w.sig = ctx.sess.signature(&w.folder).await.unwrap_or_default(); + let st = state::load_latest::(ctx, &w.id).await?; + let stored = st.map(|(_, _, s)| s.signature).unwrap_or_default(); + if (stored.is_empty() || stored != w.sig) + && has_changes(ctx, &w.id.clone(), &w.folder.clone()).await? + { + changed.push(w.id.clone()); + } + } + if !changed.is_empty() { + return Ok(Some(changed)); + } + let deadline = Instant::now() + Duration::from_secs(lifetime.saturating_sub(5).max(5)); + let interval = Duration::from_secs(ctx.s.ping.check_interval_secs.max(5)); + let mut checks = 0u32; + loop { + let now_i = Instant::now(); + if now_i >= deadline { + return Ok(None); + } + let sleep = interval.min(deadline - now_i); + tokio::select! { + _ = tokio::time::sleep(sleep) => {} + _ = cancel.notified() => return Ok(None), + _ = ctx.app.shutdown.cancelled() => return Ok(None), + } + checks += 1; + let mut changed = Vec::new(); + for w in watched.iter_mut() { + let sig = match ctx.sess.signature(&w.folder).await { + Ok(s) => s, + Err(e) => { + tracing::debug!("ping signature failed: {e:#}"); + continue; + } + }; + if sig != w.sig { + w.sig = sig; + if has_changes(ctx, &w.id.clone(), &w.folder.clone()).await? { + changed.push(w.id.clone()); + } + } + } + if !changed.is_empty() { + return Ok(Some(changed)); + } + // every ~10 checks verify the folders still exist + if checks % 10 == 0 && kind == "ping" { + ctx.sess.invalidate_folders(); + let all = ctx.sess.folders().await?; + if watched + .iter() + .any(|w| !all.iter().any(|f| f.id == w.folder.id)) + { + return Err(anyhow::anyhow!("hierarchy")); + } + } + } + } + .await; + ctx.app.metrics.ping_end(&key, gen); + result +} diff --git a/src/eas/provision.rs b/src/eas/provision.rs new file mode 100644 index 0000000..d6cacda --- /dev/null +++ b/src/eas/provision.rs @@ -0,0 +1,346 @@ +//! Provision command (policies + remote wipe). + +use anyhow::Result; +use rand::Rng; + +use super::{Ctx, Reply}; +use crate::config::Policy; +use crate::db::{self, DeviceField, DeviceInfo}; +use crate::util::sha256_hex; +use crate::wbxml::Element; + +pub fn policy_hash(p: &Policy) -> String { + sha256_hex(serde_json::to_string(p).unwrap_or_default().as_bytes())[..16].to_string() +} + +pub fn policy_hash_matches(ctx: &Ctx) -> bool { + let p = ctx.s.policy_for(ctx.user()); + ctx.dev.policy_hash.is_empty() || ctx.dev.policy_hash == policy_hash(p) +} + +fn b(v: bool) -> &'static str { + if v { + "1" + } else { + "0" + } +} + +/// MS-ASPROV EASProvisionDoc. +pub fn provision_doc(p: &Policy, ver: u32) -> Element { + let mut d = Element::new("Provision:EASProvisionDoc"); + d.push_text("Provision:DevicePasswordEnabled", b(p.devpwenabled)); + d.push_text( + "Provision:AlphanumericDevicePasswordRequired", + b(p.alphanumpwreq), + ); + d.push_text("Provision:PasswordRecoveryEnabled", b(p.pwrecoveryenabled)); + d.push_text("Provision:DeviceEncryptionEnabled", b(p.devencenabled)); + d.push_text("Provision:AttachmentsEnabled", b(p.attenabled)); + d.push_text( + "Provision:MinDevicePasswordLength", + p.mindevpwlenngth.to_string(), + ); + d.push_text( + "Provision:MaxInactivityTimeDeviceLock", + p.maxinacttimedevlock.to_string(), + ); + d.push_text( + "Provision:MaxDevicePasswordFailedAttempts", + p.maxdevpwfailedattempts.to_string(), + ); + if p.maxattsize >= 0 { + d.push_text("Provision:MaxAttachmentSize", p.maxattsize.to_string()); + } else { + d.push(Element::new("Provision:MaxAttachmentSize")); + } + d.push_text("Provision:AllowSimpleDevicePassword", b(p.allowsimpledevpw)); + if p.devpwexpiration > 0 { + d.push_text( + "Provision:DevicePasswordExpiration", + p.devpwexpiration.to_string(), + ); + } else { + d.push(Element::new("Provision:DevicePasswordExpiration")); + } + d.push_text( + "Provision:DevicePasswordHistory", + p.devpwhistory.to_string(), + ); + if ver >= 121 { + d.push_text("Provision:AllowStorageCard", b(p.allowstoragecard)); + d.push_text("Provision:AllowCamera", b(p.allowcam)); + d.push_text("Provision:RequireDeviceEncryption", b(p.reqdevenc)); + d.push_text( + "Provision:AllowUnsignedApplications", + b(p.allowunsignedapps), + ); + d.push_text( + "Provision:AllowUnsignedInstallationPackages", + b(p.allowunsignedinstallationpackages), + ); + d.push_text( + "Provision:MinDevicePasswordComplexCharacters", + p.mindevcomplexchars.to_string(), + ); + d.push_text("Provision:AllowWiFi", b(p.allowwifi)); + d.push_text("Provision:AllowTextMessaging", b(p.allowtextmessaging)); + d.push_text("Provision:AllowPOPIMAPEmail", b(p.allowpopimapemail)); + d.push_text("Provision:AllowBluetooth", p.allowbluetooth.to_string()); + d.push_text("Provision:AllowIrDA", b(p.allowirda)); + d.push_text( + "Provision:RequireManualSyncWhenRoaming", + b(p.reqmansyncroam), + ); + d.push_text("Provision:AllowDesktopSync", b(p.allowdesktopsync)); + d.push_text( + "Provision:MaxCalendarAgeFilter", + p.maxcalagefilter.to_string(), + ); + d.push_text("Provision:AllowHTMLEmail", b(p.allowhtmlemail)); + d.push_text( + "Provision:MaxEmailAgeFilter", + p.maxemailagefilter.to_string(), + ); + if p.maxemailbodytruncsize >= 0 { + d.push_text( + "Provision:MaxEmailBodyTruncationSize", + p.maxemailbodytruncsize.to_string(), + ); + } else { + d.push(Element::new("Provision:MaxEmailBodyTruncationSize")); + } + if p.maxemailhtmlbodytruncsize >= 0 { + d.push_text( + "Provision:MaxEmailHTMLBodyTruncationSize", + p.maxemailhtmlbodytruncsize.to_string(), + ); + } else { + d.push(Element::new("Provision:MaxEmailHTMLBodyTruncationSize")); + } + d.push_text( + "Provision:RequireSignedSMIMEMessages", + b(p.reqsignedsmimemessages), + ); + d.push_text( + "Provision:RequireEncryptedSMIMEMessages", + b(p.reqencsmimemessages), + ); + d.push_text( + "Provision:RequireSignedSMIMEAlgorithm", + p.reqsignedsmimealgorithm.to_string(), + ); + d.push_text( + "Provision:RequireEncryptionSMIMEAlgorithm", + p.reqencsmimealgorithm.to_string(), + ); + d.push_text( + "Provision:AllowSMIMEEncryptionAlgorithmNegotiation", + p.allowsmimeencalgneg.to_string(), + ); + d.push_text("Provision:AllowSMIMESoftCerts", b(p.allowsmimesoftcerts)); + d.push_text("Provision:AllowBrowser", b(p.allowbrowser)); + d.push_text("Provision:AllowConsumerEmail", b(p.allowconsumeremail)); + d.push_text("Provision:AllowRemoteDesktop", b(p.allowremotedesktop)); + d.push_text("Provision:AllowInternetSharing", b(p.allowinternetsharing)); + d.push(Element::new("Provision:UnapprovedInROMApplicationList")); + d.push(Element::new("Provision:ApprovedApplicationList")); + } + d +} + +pub fn device_info_from(set: &Element) -> DeviceInfo { + let g = |n: &str| { + set.child_text(n) + .map(|s| crate::util::truncate_bytes(&s, 200).to_string()) + }; + DeviceInfo { + model: g("Settings:Model"), + imei: g("Settings:IMEI"), + friendly_name: g("Settings:FriendlyName"), + os: g("Settings:OS"), + os_language: g("Settings:OSLanguage"), + phone_number: g("Settings:PhoneNumber"), + mobile_operator: g("Settings:MobileOperator"), + user_agent: g("Settings:UserAgent"), + } +} + +fn new_key() -> u32 { + rand::thread_rng().gen_range(1_000_000..u32::MAX) +} + +pub async fn provision(ctx: &mut Ctx, req: Option) -> Result { + let Some(req) = req else { + return Ok(Reply::Wbxml( + Element::new("Provision:Provision").with_text("Provision:Status", "2"), + )); + }; + let ver = ctx.req.ver(); + let mut resp = Element::new("Provision:Provision").with_text("Provision:Status", "1"); + + if let Some(di) = req.child("Settings:DeviceInformation") { + if let Some(set) = di.child("Settings:Set") { + db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?; + } + resp.push(Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1")); + } + + // ---- remote wipe acknowledgement + if let Some(rw) = req + .child("Provision:RemoteWipe") + .or_else(|| req.child("Provision:AccountOnlyRemoteWipe")) + { + let st = rw.child_u32("Provision:Status").unwrap_or(0); + tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, status = st, "device acknowledged remote wipe"); + db::device_wipe_done( + &ctx.app.db, + ctx.dev.id, + if st == 1 { + db::WIPE_WIPED + } else { + db::WIPE_REQUESTED + }, + ) + .await?; + db::audit( + &ctx.app.db, + ctx.user(), + "wipe_ack", + &ctx.req.device_id, + &ctx.ip, + &format!("status {st}"), + ) + .await; + return Ok(Reply::Wbxml(resp)); + } + + let wipe = matches!(ctx.dev.wipe_status, db::WIPE_PENDING | db::WIPE_REQUESTED); + + if let Some(pol) = req.path(&["Provision:Policies", "Provision:Policy"]) { + let ptype = pol.child_text("Provision:PolicyType").unwrap_or_default(); + let mut pe = + Element::new("Provision:Policy").with_text("Provision:PolicyType", ptype.clone()); + if ptype != "MS-EAS-Provisioning-WBXML" && ptype != "MS-WAP-Provisioning-XML" { + pe.push_text("Provision:Status", "3"); + resp.push(Element::new("Provision:Policies").with(pe)); + return Ok(Reply::Wbxml(resp)); + } + if !ctx.s.provisioning.enabled { + pe.push_text("Provision:Status", "2"); + resp.push(Element::new("Provision:Policies").with(pe)); + return Ok(Reply::Wbxml(resp)); + } + let policy = ctx.s.policy_for(ctx.user()).clone(); + match pol + .child_text("Provision:PolicyKey") + .and_then(|k| k.parse::().ok()) + { + None => { + // phase 1: deliver the policy with a temporary key + let temp = new_key(); + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::PolicyTempKey, + Some(temp as i64), + None, + ) + .await?; + pe.push_text("Provision:Status", "1"); + pe.push_text("Provision:PolicyKey", temp.to_string()); + let data = if ptype == "MS-WAP-Provisioning-XML" { + Element::text( + "Provision:Data", + format!( + "", + if policy.devpwenabled { 0 } else { 1 } + ), + ) + } else { + Element::new("Provision:Data").with(provision_doc(&policy, ver)) + }; + pe.push(data); + ctx.status = "policy sent".into(); + } + Some(key) => { + // phase 2: acknowledgement + let client_status = pol.child_u32("Provision:Status").unwrap_or(1); + let ok_key = key == ctx.dev.policy_temp_key as u32 + || key == ctx.dev.policy_key as u32 + || ctx.dev.policy_temp_key == 0; + if !ok_key { + pe.push_text("Provision:Status", "5"); + } else if client_status == 1 || (client_status == 2 && ctx.s.provisioning.loose) { + let fin = new_key(); + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::PolicyKey, + Some(fin as i64), + None, + ) + .await?; + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::PolicyTempKey, + Some(0), + None, + ) + .await?; + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::PolicyName, + None, + Some(&policy.name), + ) + .await?; + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::PolicyHash, + None, + Some(&policy_hash(&policy)), + ) + .await?; + pe.push_text("Provision:Status", "1"); + pe.push_text("Provision:PolicyKey", fin.to_string()); + ctx.status = "provisioned".into(); + tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, policy = %policy.name, "device provisioned"); + } else { + tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, client_status, "device could not apply policy"); + pe.push_text("Provision:Status", "1"); + pe.push_text("Provision:PolicyKey", "0"); + ctx.status = format!("device rejected policy ({client_status})"); + } + } + } + resp.push(Element::new("Provision:Policies").with(pe)); + } + + if wipe { + resp.push(Element::new("Provision:RemoteWipe")); + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + DeviceField::WipeStatus, + Some(db::WIPE_REQUESTED), + None, + ) + .await?; + db::audit( + &ctx.app.db, + "system", + "wipe_sent", + &ctx.req.device_id, + &ctx.ip, + ctx.user(), + ) + .await; + tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, "remote wipe command sent to device"); + ctx.status = "remote wipe sent".into(); + } + Ok(Reply::Wbxml(resp)) +} diff --git a/src/eas/state.rs b/src/eas/state.rs new file mode 100644 index 0000000..32b6fe7 --- /dev/null +++ b/src/eas/state.rs @@ -0,0 +1,128 @@ +//! Sync keys and persisted per-device state (folder hierarchy + item fingerprints). + +use std::collections::BTreeMap; + +use anyhow::Result; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::db; + +use super::convert::Options; +use super::Ctx; + +pub const HIERARCHY: &str = "h"; + +#[derive(Debug, Clone, PartialEq)] +pub enum SyncKey { + Initial, + Key { uuid: String, counter: i64 }, +} + +impl SyncKey { + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s == "0" { + return Some(SyncKey::Initial); + } + let rest = s.strip_prefix('{')?; + let (uuid, counter) = rest.split_once('}')?; + if uuid.len() != 36 || !uuid.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { + return None; + } + Some(SyncKey::Key { + uuid: uuid.to_string(), + counter: counter.parse().ok()?, + }) + } +} + +pub fn make_key(uuid: &str, counter: i64) -> String { + format!("{{{uuid}}}{counter}") +} + +pub fn new_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HierarchyState { + /// short id -> (parent short id, name, type) + pub folders: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CollectionState { + pub backend_id: String, + pub class: String, + pub meta: String, + pub filter: u8, + /// item id -> fingerprint, as known by the device + pub items: BTreeMap, + /// client id -> server id of the last processed Adds (idempotent retries) + pub client_ids: Vec<(String, String)>, + /// Options remembered for empty/partial Sync requests + pub options: Option, + /// last backend signature (Ping) + pub signature: String, + /// number of consecutive retries of the same key (loop detection) + pub retries: u32, + pub window: u32, +} + +pub async fn load( + ctx: &Ctx, + collection: &str, + key: &SyncKey, +) -> Result> { + let SyncKey::Key { uuid, counter } = key else { + return Ok(None); + }; + let Some(data) = db::state_get(&ctx.app.db, ctx.dev.id, collection, uuid, *counter).await? + else { + return Ok(None); + }; + Ok(serde_json::from_slice(&data).ok()) +} + +pub async fn load_latest( + ctx: &Ctx, + collection: &str, +) -> Result> { + let Some((uuid, counter, data)) = db::state_latest(&ctx.app.db, ctx.dev.id, collection).await? + else { + return Ok(None); + }; + Ok(serde_json::from_slice(&data) + .ok() + .map(|t| (uuid, counter, t))) +} + +pub async fn save( + ctx: &Ctx, + collection: &str, + uuid: &str, + counter: i64, + v: &T, +) -> Result<()> { + let data = serde_json::to_vec(v)?; + db::state_put(&ctx.app.db, ctx.dev.id, collection, uuid, counter, &data).await +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn keys() { + let u = new_uuid(); + let k = make_key(&u, 5); + assert_eq!( + SyncKey::parse(&k), + Some(SyncKey::Key { + uuid: u, + counter: 5 + }) + ); + assert_eq!(SyncKey::parse("0"), Some(SyncKey::Initial)); + assert_eq!(SyncKey::parse("garbage"), None); + } +} diff --git a/src/eas/sync.rs b/src/eas/sync.rs new file mode 100644 index 0000000..8b197a6 --- /dev/null +++ b/src/eas/sync.rs @@ -0,0 +1,1081 @@ +//! Sync, GetItemEstimate, MoveItems. + +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::convert::{self, calendar, contact, email, task, Options}; +use super::folders::resolve; +use super::state::{self, CollectionState, SyncKey}; +use super::{Ctx, Reply}; +use crate::backend::{BFolder, Kind, Listing, RawItem}; +use crate::db; +use crate::wbxml::Element; + +pub const DEFAULT_WINDOW: u32 = 100; + +// ------------------------------------------------------------------ server ids + +pub async fn server_id( + ctx: &Ctx, + collection: &str, + folder_bid: &str, + item: &str, +) -> Result { + if item.len() > 40 || item.contains(':') || item.starts_with('~') { + let n = db::item_short(&ctx.app.db, ctx.user(), folder_bid, item).await?; + Ok(format!("{collection}:~{n}")) + } else { + Ok(format!("{collection}:{item}")) + } +} + +pub async fn parse_server_id(ctx: &Ctx, sid: &str, folder_bid: &str) -> Result> { + let Some((_, item)) = sid.split_once(':') else { + return Ok(None); + }; + if let Some(n) = item.strip_prefix('~') { + let Ok(n) = n.parse::() else { + return Ok(None); + }; + return db::item_long(&ctx.app.db, ctx.user(), folder_bid, n).await; + } + Ok(Some(item.to_string())) +} + +pub fn collection_of(sid: &str) -> &str { + sid.split(':').next().unwrap_or("") +} + +// ------------------------------------------------------------------ filters + +pub fn filter_since(filter: u8) -> Option> { + let days = match filter { + 1 => 1, + 2 => 3, + 3 => 7, + 4 => 14, + 5 => 31, + 6 => 92, + 7 => 183, + _ => return None, + }; + Some(Utc::now() - chrono::Duration::days(days)) +} + +pub fn effective_filter(ctx: &Ctx, kind: Kind, requested: u8) -> u8 { + let mut f = requested; + let cap = |f: u8, max: u8| -> u8 { + if max == 0 { + f + } else if f == 0 || f > max { + max + } else { + f + } + }; + if matches!(kind, Kind::Mail | Kind::Calendar) { + f = cap(f, ctx.s.sync.filter_time_max); + let pol = ctx.s.policy_for(ctx.user()); + let pmax = if kind == Kind::Mail { + pol.maxemailagefilter + } else { + pol.maxcalagefilter + } as u8; + if ctx.s.provisioning.enabled { + f = cap(f, pmax); + } + } + if kind == Kind::Contacts { + 0 + } else if kind == Kind::Tasks { + if f == 8 { + 8 + } else { + 0 + } + } else { + f + } +} + +// ------------------------------------------------------------------ request model + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ColReq { + pub id: String, + pub key: String, + pub class: Option, + pub get_changes: Option, + pub deletes_as_moves: Option, + pub window: Option, + pub options: Option, + #[serde(skip)] + pub commands: Option, +} + +fn parse_collection(c: &Element) -> ColReq { + let flag = |n: &str| { + c.child(n).map(|e| { + let t = e.text_value(); + t.is_empty() || t == "1" + }) + }; + ColReq { + id: c.child_text("AirSync:CollectionId").unwrap_or_default(), + key: c.child_text("AirSync:SyncKey").unwrap_or_default(), + class: c.child_text("AirSync:Class"), + get_changes: flag("AirSync:GetChanges"), + deletes_as_moves: flag("AirSync:DeletesAsMoves"), + window: c.child_u32("AirSync:WindowSize"), + options: c + .children_named("AirSync:Options") + .find(|o| { + o.child_text("AirSync:Class") + .map(|cl| cl != "SMS") + .unwrap_or(true) + }) + .map(Options::parse), + commands: c.child("AirSync:Commands").cloned(), + } +} + +struct ColResult { + el: Element, + changed: bool, +} + +// ------------------------------------------------------------------ Sync + +pub async fn sync(ctx: &mut Ctx, req: Option) -> Result { + let cached: Vec = serde_json::from_str(&ctx.dev.last_sync_request).unwrap_or_default(); + let mut wait: Option = None; + let mut global_window: Option = None; + let mut cols: Vec = Vec::new(); + match &req { + None => { + // empty Sync: repeat the previous request with current keys + if cached.is_empty() { + return Ok(Reply::Wbxml( + Element::new("AirSync:Sync").with_text("AirSync:Status", "13"), + )); + } + cols = cached.clone(); + } + Some(r) => { + wait = r + .child_u32("AirSync:Wait") + .map(|m| m as u64 * 60) + .or_else(|| r.child_u32("AirSync:HeartbeatInterval").map(|s| s as u64)); + global_window = r.child_u32("AirSync:WindowSize"); + if let Some(c) = r.child("AirSync:Collections") { + for col in c.children_named("AirSync:Collection") { + cols.push(parse_collection(col)); + } + } + let partial = r.has("AirSync:Partial"); + if partial || cols.is_empty() { + for c in &cached { + if !cols.iter().any(|x| x.id == c.id) { + cols.push(ColReq { + key: String::new(), + ..c.clone() + }); + } + } + } + if cols.is_empty() { + return Ok(Reply::Wbxml( + Element::new("AirSync:Sync").with_text("AirSync:Status", "13"), + )); + } + } + } + if let Some(w) = wait { + if w < ctx.s.sync.heartbeat_min || w > ctx.s.sync.heartbeat_max { + let lim = if w < ctx.s.sync.heartbeat_min { + ctx.s.sync.heartbeat_min + } else { + ctx.s.sync.heartbeat_max + }; + let tag = if req.as_ref().map(|r| r.has("AirSync:Wait")).unwrap_or(false) { + Element::text("AirSync:Wait", (lim / 60).to_string()) + } else { + Element::text("AirSync:HeartbeatInterval", lim.to_string()) + }; + return Ok(Reply::Wbxml( + Element::new("AirSync:Sync") + .with_text("AirSync:Status", "14") + .with(tag), + )); + } + } + // fill in missing keys / options from the latest state + for c in cols.iter_mut() { + if c.key.is_empty() { + if let Some((u, n, st)) = state::load_latest::(ctx, &c.id).await? { + c.key = state::make_key(&u, n); + if c.options.is_none() { + c.options = st.options.clone(); + } + } + } + if c.options.is_none() { + c.options = cached + .iter() + .find(|x| x.id == c.id) + .and_then(|x| x.options.clone()); + } + } + // remember the request (without commands) for empty/partial syncs + let to_cache: Vec = cols + .iter() + .map(|c| ColReq { + commands: None, + key: String::new(), + ..c.clone() + }) + .collect(); + if let Ok(j) = serde_json::to_string(&to_cache) { + if j != ctx.dev.last_sync_request { + db::device_set_field( + &ctx.app.db, + ctx.dev.id, + db::DeviceField::LastSyncRequest, + None, + Some(&j), + ) + .await?; + ctx.dev.last_sync_request = j; + } + } + + let mut budget = global_window + .unwrap_or(u32::MAX) + .min(ctx.s.sync.max_items * 4); + let mut results = Vec::new(); + let mut any_change = false; + for c in cols.iter() { + let r = sync_collection(ctx, c, &mut budget).await?; + any_change |= r.changed; + results.push(r.el); + } + + // hanging sync: no changes -> wait for backend changes + if let (false, Some(lifetime), true) = (any_change, wait, req.is_some()) { + let changed = super::ping::wait_for_changes( + ctx, + &cols.iter().map(|c| c.id.clone()).collect::>(), + lifetime, + "sync", + ) + .await?; + match changed { + None => { + ctx.status = "hanging sync: no changes".into(); + return Ok(Reply::Empty); + } + Some(_) => { + // re-run with the current keys (the device keeps its keys until it gets a response) + results.clear(); + let mut budget = global_window + .unwrap_or(u32::MAX) + .min(ctx.s.sync.max_items * 4); + for c in cols.iter() { + let mut c2 = c.clone(); + c2.commands = None; + let r = sync_collection(ctx, &c2, &mut budget).await?; + results.push(r.el); + } + } + } + } + + let mut colls = Element::new("AirSync:Collections"); + for r in results { + colls.push(r); + } + Ok(Reply::Wbxml(Element::new("AirSync:Sync").with(colls))) +} + +fn col_status(c: &ColReq, ver: u32, key: &str, status: u32) -> Element { + let mut e = Element::new("AirSync:Collection"); + if ver <= 121 { + if let Some(cl) = &c.class { + e.push_text("AirSync:Class", cl.clone()); + } + } + e.push_text("AirSync:SyncKey", key); + e.push_text("AirSync:CollectionId", c.id.clone()); + e.push_text("AirSync:Status", status.to_string()); + e +} + +async fn sync_collection(ctx: &mut Ctx, c: &ColReq, budget: &mut u32) -> Result { + let ver = ctx.req.ver(); + let Some(folder) = resolve(ctx, &c.id).await? else { + ctx.status = format!("collection {} not found", c.id); + ctx.app.stale_add(ctx.dev.id, &c.id); + return Ok(ColResult { + el: col_status(c, ver, &c.key, 12), + changed: true, + }); + }; + let kind = folder.kind(); + let Some(key) = SyncKey::parse(&c.key) else { + return Ok(ColResult { + el: col_status(c, ver, "0", 3), + changed: true, + }); + }; + let opts = c.options.clone().unwrap_or_default(); + let filter = effective_filter(ctx, kind, opts.filter_type); + + // ---- initial sync + let (uuid, counter) = match &key { + SyncKey::Initial => { + let st = CollectionState { + backend_id: folder.id.clone(), + class: kind.class().to_string(), + filter, + options: c.options.clone(), + window: c.window.unwrap_or(DEFAULT_WINDOW), + ..Default::default() + }; + let u = state::new_uuid(); + state::save(ctx, &c.id, &u, 1, &st).await?; + db::folder_stat_update( + &ctx.app.db, + ctx.dev.id, + &c.id, + &folder.name, + kind.class(), + 0, + 0, + 0, + 1, + 0, + false, + ) + .await?; + return Ok(ColResult { + el: col_status(c, ver, &state::make_key(&u, 1), 1), + changed: true, + }); + } + SyncKey::Key { uuid, counter } => (uuid.clone(), *counter), + }; + let Some(mut st): Option = state::load(ctx, &c.id, &key).await? else { + ctx.status = format!("invalid synckey for {}", c.id); + return Ok(ColResult { + el: col_status(c, ver, "0", 3), + changed: true, + }); + }; + if st.backend_id != folder.id { + return Ok(ColResult { + el: col_status(c, ver, "0", 3), + changed: true, + }); + } + if c.options.is_some() { + st.options = c.options.clone(); + } + + // ---- loop detection: same key requested again and again + let mut loop_hit = false; + let mut window = c + .window + .unwrap_or(DEFAULT_WINDOW) + .clamp(1, ctx.s.sync.max_items); + let retrying_old_key = match db::state_latest(&ctx.app.db, ctx.dev.id, &c.id).await? { + Some((u, n, _)) => u == uuid && n > counter, + None => false, + }; + if ctx.s.sync.loop_detection && retrying_old_key { + let lk = format!("{}\u{0}{}\u{0}{}", ctx.dev.id, c.id, c.key); + let n = ctx.app.loop_hit(&lk); + if n >= 3 && c.commands.is_none() { + window = (window / (1 << (n - 2).min(6))).max(1); + loop_hit = true; + tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, collection = %c.id, retries = n, window, "sync loop detected, reducing window size"); + } + } + + let mut responses = Element::new("AirSync:Responses"); + let mut items_in = 0u64; + let mut dirty = false; + + // ---- client changes + if let Some(cmds) = &c.commands { + let deletes_as_moves = c.deletes_as_moves.unwrap_or(true); + for cmd in &cmds.children { + items_in += 1; + let tag = cmd.tag(); + match tag { + "Add" => { + let client_id = cmd.child_text("AirSync:ClientId").unwrap_or_default(); + if let Some((_, sid)) = st.client_ids.iter().find(|(cid, _)| *cid == client_id) + { + responses.push( + Element::new("AirSync:Add") + .with_text("AirSync:ClientId", client_id.clone()) + .with_text("AirSync:ServerId", sid.clone()) + .with_text("AirSync:Status", "1"), + ); + continue; + } + let app = cmd.child("AirSync:ApplicationData"); + let res = match (kind, app) { + (Kind::Mail, _) | (_, None) => Err(anyhow::anyhow!("unsupported add")), + (_, Some(app)) => client_add(ctx, &folder, app).await, + }; + match res { + Ok((id, etag)) => { + let sid = server_id(ctx, &c.id, &folder.id, &id).await?; + st.items.insert(id, etag); + st.client_ids.push((client_id.clone(), sid.clone())); + dirty = true; + responses.push( + Element::new("AirSync:Add") + .with_text("AirSync:ClientId", client_id) + .with_text("AirSync:ServerId", sid) + .with_text("AirSync:Status", "1"), + ); + } + Err(e) => { + tracing::warn!(user = %ctx.user(), collection = %c.id, "client Add failed: {e:#}"); + responses.push( + Element::new("AirSync:Add") + .with_text("AirSync:ClientId", client_id) + .with_text("AirSync:Status", "6"), + ); + } + } + } + "Change" => { + let sid = cmd.child_text("AirSync:ServerId").unwrap_or_default(); + let Some(id) = parse_server_id(ctx, &sid, &folder.id).await? else { + responses.push( + Element::new("AirSync:Change") + .with_text("AirSync:ServerId", sid) + .with_text("AirSync:Status", "8"), + ); + continue; + }; + let Some(app) = cmd.child("AirSync:ApplicationData") else { + continue; + }; + match client_change(ctx, &folder, &id, app, st.items.get(&id).cloned()).await { + Ok(Some(fp)) => { + st.items.insert(id, fp); + dirty = true; + } + Ok(None) => { + // conflict: server wins, the server version will be re-sent + st.items.insert(id, "__conflict__".into()); + dirty = true; + responses.push( + Element::new("AirSync:Change") + .with_text("AirSync:ServerId", sid) + .with_text("AirSync:Status", "7"), + ); + } + Err(e) => { + tracing::warn!(user = %ctx.user(), collection = %c.id, "client Change failed: {e:#}"); + let status = if e.to_string().contains("not found") { + "8" + } else { + "6" + }; + responses.push( + Element::new("AirSync:Change") + .with_text("AirSync:ServerId", sid) + .with_text("AirSync:Status", status), + ); + } + } + } + "Delete" => { + let sid = cmd.child_text("AirSync:ServerId").unwrap_or_default(); + let Some(id) = parse_server_id(ctx, &sid, &folder.id).await? else { + continue; + }; + let r = match kind { + Kind::Mail => match id.parse::() { + Ok(uid) => ctx.sess.mail_delete(&folder, uid, deletes_as_moves).await, + Err(_) => Ok(()), + }, + _ => ctx.sess.dav_delete(&folder, &id).await, + }; + if let Err(e) = r { + tracing::warn!(user = %ctx.user(), collection = %c.id, "client Delete failed: {e:#}"); + responses.push( + Element::new("AirSync:Delete") + .with_text("AirSync:ServerId", sid) + .with_text("AirSync:Status", "8"), + ); + } + st.items.remove(&id); + dirty = true; + } + "Fetch" => { + let sid = cmd.child_text("AirSync:ServerId").unwrap_or_default(); + let id = parse_server_id(ctx, &sid, &folder.id) + .await? + .unwrap_or_default(); + let fetched = ctx + .sess + .fetch(&folder, std::slice::from_ref(&id)) + .await + .unwrap_or_default(); + let mut el = + Element::new("AirSync:Fetch").with_text("AirSync:ServerId", sid.clone()); + match fetched + .into_iter() + .next() + .and_then(|raw| convert_raw(ctx, &c.id, kind, &raw, &opts)) + { + Some(app) => { + el.push_text("AirSync:Status", "1"); + el.push(app); + ctx.items_out += 1; + } + None => el.push_text("AirSync:Status", "8"), + } + responses.push(el); + } + _ => {} + } + } + // keep only the latest batch of client ids + let keep = st.client_ids.len().saturating_sub(200); + st.client_ids.drain(..keep); + } + ctx.items_in += items_in; + + // ---- server changes + let get_changes = c.get_changes.unwrap_or(true); + let mut commands = Element::new("AirSync:Commands"); + let mut more = false; + let mut sent = 0u64; + let mut item_count = st.items.len() as i64; + let mut sig_changed = false; + if get_changes && *budget > 0 { + let sig = ctx.sess.signature(&folder).await.unwrap_or_default(); + if sig != st.signature { + st.signature = sig; + sig_changed = true; + } + let listing = match kind { + Kind::Tasks if filter == 8 => ctx.sess.list(&folder, None).await?, + _ => ctx.sess.list(&folder, filter_since(filter)).await?, + }; + if !st.meta.is_empty() && !listing.meta.is_empty() && st.meta != listing.meta { + // UIDVALIDITY changed: the device must resync this folder + tracing::info!(user = %ctx.user(), collection = %c.id, "UIDVALIDITY changed, forcing resync"); + db::state_delete_collection(&ctx.app.db, ctx.dev.id, &c.id).await?; + return Ok(ColResult { + el: col_status(c, ver, "0", 3), + changed: true, + }); + } + st.meta = listing.meta.clone(); + item_count = listing.items.len() as i64; + let soft = filter != 0 && filter != 8; + let diff = diff(&st.items, &listing, kind); + let limit = window.min(*budget) as usize; + let total = diff.deletes.len() + diff.changes.len() + diff.adds.len(); + more = total > limit; + let mut room = limit; + // deletes + for id in diff.deletes.iter().take(room) { + let sid = server_id(ctx, &c.id, &folder.id, id).await?; + commands.push( + Element::new(if soft && kind == Kind::Mail { + "AirSync:SoftDelete" + } else { + "AirSync:Delete" + }) + .with_text("AirSync:ServerId", sid), + ); + st.items.remove(id); + room -= 1; + sent += 1; + } + // changes + adds need data + let mut todo: Vec<(String, String, bool)> = Vec::new(); + for (id, fp) in diff.changes.iter() { + if todo.len() >= room { + break; + } + todo.push((id.clone(), fp.clone(), false)); + } + for (id, fp) in diff.adds.iter() { + if todo.len() >= room { + break; + } + todo.push((id.clone(), fp.clone(), true)); + } + // flag-only mail changes don't need a fetch + let mut fetch_ids = Vec::new(); + for (id, fp, add) in &todo { + if kind == Kind::Mail + && !add + && st + .items + .get(id) + .map(|o| o != "__conflict__") + .unwrap_or(false) + { + let sid = server_id(ctx, &c.id, &folder.id, id).await?; + commands.push( + Element::new("AirSync:Change") + .with_text("AirSync:ServerId", sid) + .with(email::flags_change(&email::flags_from_fingerprint(fp), ver)), + ); + st.items.insert(id.clone(), fp.clone()); + sent += 1; + } else { + fetch_ids.push(id.clone()); + } + } + let fps: HashMap = todo + .iter() + .map(|(i, f, a)| (i.clone(), (f.clone(), *a))) + .collect(); + let mut fetched: HashMap = HashMap::new(); + if !fetch_ids.is_empty() { + for raw in ctx.sess.fetch(&folder, &fetch_ids).await? { + let id = match &raw { + RawItem::Mail(m) => m.uid.to_string(), + RawItem::Dav { id, .. } => id.clone(), + }; + fetched.insert(id, raw); + } + } + for id in fetch_ids { + let (fp, is_add) = fps.get(&id).cloned().unwrap_or_default(); + let sid = server_id(ctx, &c.id, &folder.id, &id).await?; + match fetched + .get(&id) + .and_then(|raw| convert_raw(ctx, &c.id, kind, raw, &opts)) + { + Some(app) => { + let tag = if is_add && !st.items.contains_key(&id) { + "AirSync:Add" + } else { + "AirSync:Change" + }; + commands.push( + Element::new(tag) + .with_text("AirSync:ServerId", sid) + .with(app), + ); + let fp = match fetched.get(&id) { + Some(RawItem::Mail(m)) => crate::backend::mail_fingerprint(&m.flags), + Some(RawItem::Dav { etag, .. }) => etag.clone(), + None => fp, + }; + st.items.insert(id, fp); + sent += 1; + } + None => { + if fetched.contains_key(&id) { + // unconvertible item: remember it so we don't loop on it + tracing::warn!(user = %ctx.user(), collection = %c.id, item = %id, "item could not be converted, skipping"); + st.items.insert(id, fp); + } else if st.items.contains_key(&id) { + // vanished between listing and fetch + commands.push( + Element::new("AirSync:Delete").with_text("AirSync:ServerId", sid), + ); + st.items.remove(&id); + sent += 1; + } + } + } + } + if sent > 0 || !diff.deletes.is_empty() { + dirty = true; + } + *budget = budget.saturating_sub(sent as u32); + } + ctx.items_out += sent; + + let new_key = if dirty || st.filter != filter { + st.filter = filter; + state::save(ctx, &c.id, &uuid, counter + 1, &st).await?; + state::make_key(&uuid, counter + 1) + } else { + // nothing changed: keep the key, but persist remembered options + if c.options.is_some() || sig_changed { + state::save(ctx, &c.id, &uuid, counter, &st).await?; + } + state::make_key(&uuid, counter) + }; + db::folder_stat_update( + &ctx.app.db, + ctx.dev.id, + &c.id, + &folder.name, + kind.class(), + sent as i64, + items_in as i64, + item_count, + 1, + window as i64, + loop_hit, + ) + .await?; + + let mut el = col_status(c, ver, &new_key, 1); + if !responses.children.is_empty() { + el.push(responses); + } + if more { + el.push(Element::new("AirSync:MoreAvailable")); + } + if !commands.children.is_empty() { + el.push(commands); + } + Ok(ColResult { + el, + changed: dirty || more, + }) +} + +pub struct Diff { + pub adds: Vec<(String, String)>, + pub changes: Vec<(String, String)>, + pub deletes: Vec, +} + +pub fn diff(known: &BTreeMap, listing: &Listing, kind: Kind) -> Diff { + let mut adds = Vec::new(); + let mut changes = Vec::new(); + let mut present = std::collections::HashSet::with_capacity(listing.items.len()); + for (id, fp) in &listing.items { + present.insert(id.as_str()); + match known.get(id) { + None => adds.push((id.clone(), fp.clone())), + Some(old) if old != fp => changes.push((id.clone(), fp.clone())), + _ => {} + } + } + let deletes = known + .keys() + .filter(|k| !present.contains(k.as_str())) + .cloned() + .collect(); + if kind == Kind::Mail { + // newest first + adds.sort_by_key(|(id, _)| std::cmp::Reverse(id.parse::().unwrap_or(0))); + } + Diff { + adds, + changes, + deletes, + } +} + +pub fn convert_raw( + ctx: &Ctx, + collection: &str, + kind: Kind, + raw: &RawItem, + opts: &Options, +) -> Option { + let ver = ctx.req.ver(); + let dtz = ctx.s.timezone(); + match raw { + RawItem::Mail(m) => Some( + email::to_eas( + m, + &email::MailContext { + collection, + ver, + opts, + default_tz: dtz, + }, + ) + .app, + ), + RawItem::Dav { data, .. } => match kind { + Kind::Calendar => calendar::to_eas( + data, + &calendar::CalContext { + ver, + opts, + default_tz: dtz, + user_email: &ctx.sess.email, + }, + ), + Kind::Tasks => task::to_eas(data, opts, ver, dtz), + Kind::Contacts => { + contact::to_eas(data, opts, ver, dtz, ctx.s.carddav.max_picture_kb * 1024) + } + Kind::Mail => None, + }, + } +} + +async fn client_add(ctx: &mut Ctx, folder: &BFolder, app: &Element) -> Result<(String, String)> { + let dtz = ctx.s.timezone(); + let body = match folder.kind() { + Kind::Calendar => calendar::from_eas(app, None, dtz, &ctx.sess.email.clone(), ""), + Kind::Tasks => task::from_eas(app, None, dtz), + Kind::Contacts => contact::from_eas(app, None, dtz), + Kind::Mail => None, + } + .ok_or_else(|| anyhow::anyhow!("conversion failed"))?; + ctx.sess.dav_put(folder, None, body, None).await +} + +/// Apply a client change. Ok(Some(fp)) = applied, Ok(None) = conflict (server wins). +async fn client_change( + ctx: &mut Ctx, + folder: &BFolder, + id: &str, + app: &Element, + known_fp: Option, +) -> Result> { + match folder.kind() { + Kind::Mail => { + let uid: u32 = id.parse().map_err(|_| anyhow::anyhow!("not found"))?; + let read = app.child_u32("Email:Read").map(|v| v == 1); + let flagged = app + .child("Email:Flag") + .map(|f| f.child_u32("Email:FlagStatus").unwrap_or(0) == 2); + let fp = ctx.sess.mail_set_flags(folder, uid, read, flagged).await?; + Ok(Some(fp)) + } + k => { + let cur = ctx.sess.fetch_dav(folder, id).await?; + let server_changed = known_fp.as_deref().map(|k| k != cur.etag).unwrap_or(false); + if server_changed && ctx.s.sync.server_wins { + return Ok(None); + } + let dtz = ctx.s.timezone(); + let existing = cur.data.as_deref(); + let body = match k { + Kind::Calendar => { + calendar::from_eas(app, existing, dtz, &ctx.sess.email.clone(), "") + } + Kind::Tasks => task::from_eas(app, existing, dtz), + Kind::Contacts => contact::from_eas(app, existing, dtz), + Kind::Mail => None, + } + .ok_or_else(|| anyhow::anyhow!("conversion failed"))?; + let etag = if server_changed { + None + } else { + Some(cur.etag.as_str()) + }; + match ctx + .sess + .dav_put(folder, Some(id), body, Some(etag.unwrap_or(""))) + .await + { + Ok((_, etag)) => Ok(Some(etag)), + Err(e) if e.to_string().contains("conflict") && ctx.s.sync.server_wins => Ok(None), + Err(e) => Err(e), + } + } + } +} + +// ------------------------------------------------------------------ GetItemEstimate + +pub async fn get_item_estimate(ctx: &mut Ctx, req: Option) -> Result { + let Some(req) = req else { + return Ok(Reply::Wbxml( + Element::new("GetItemEstimate:GetItemEstimate") + .with_text("GetItemEstimate:Status", "2"), + )); + }; + let mut root = Element::new("GetItemEstimate:GetItemEstimate"); + let Some(cols) = req.child("GetItemEstimate:Collections") else { + return Ok(Reply::Wbxml(root.with_text("GetItemEstimate:Status", "2"))); + }; + for c in cols.children_named("GetItemEstimate:Collection") { + let id = c + .child_text("GetItemEstimate:CollectionId") + .unwrap_or_default(); + let key_s = c.child_text("AirSync:SyncKey").unwrap_or_default(); + let filter_req = c + .child("AirSync:Options") + .map(|o| Options::parse(o).filter_type) + .or_else(|| c.child_u32("AirSync:FilterType").map(|f| f as u8)) + .unwrap_or(0); + let resp = |status: &str, est: Option| { + let mut col = Element::new("GetItemEstimate:Collection") + .with_text("GetItemEstimate:CollectionId", id.clone()); + if let Some(e) = est { + col.push_text("GetItemEstimate:Estimate", e.to_string()); + } + Element::new("GetItemEstimate:Response") + .with_text("GetItemEstimate:Status", status) + .with(col) + }; + let Some(folder) = resolve(ctx, &id).await? else { + root.push(resp("2", None)); + continue; + }; + let key = match SyncKey::parse(&key_s) { + Some(SyncKey::Initial) => { + root.push(resp("3", None)); + continue; + } + Some(k) => k, + None => { + root.push(resp("4", None)); + continue; + } + }; + let Some(st): Option = state::load(ctx, &id, &key).await? else { + root.push(resp("4", None)); + continue; + }; + let f = effective_filter(ctx, folder.kind(), filter_req); + let listing = ctx.sess.list(&folder, filter_since(f)).await?; + let d = diff(&st.items, &listing, folder.kind()); + root.push(resp( + "1", + Some(d.adds.len() + d.changes.len() + d.deletes.len()), + )); + } + Ok(Reply::Wbxml(root)) +} + +// ------------------------------------------------------------------ MoveItems + +/// Patch the latest state of a collection in place (used after moves). +async fn patch_latest( + ctx: &Ctx, + collection: &str, + f: impl FnOnce(&mut CollectionState), +) -> Result<()> { + if let Some((u, n, mut st)) = state::load_latest::(ctx, collection).await? { + f(&mut st); + state::save(ctx, collection, &u, n, &st).await?; + } + Ok(()) +} + +pub async fn move_items(ctx: &mut Ctx, req: Option) -> Result { + let mut root = Element::new("Move:MoveItems"); + let Some(req) = req else { + return Ok(Reply::Wbxml(root)); + }; + let started = Instant::now(); + for mv in req.children_named("Move:Move") { + let src_msg = mv.child_text("Move:SrcMsgId").unwrap_or_default(); + let src_fld = mv.child_text("Move:SrcFldId").unwrap_or_default(); + let dst_fld = mv.child_text("Move:DstFldId").unwrap_or_default(); + let mut resp = Element::new("Move:Response").with_text("Move:SrcMsgId", src_msg.clone()); + if started.elapsed() > Duration::from_secs(120) { + resp.push_text("Move:Status", "7"); + root.push(resp); + continue; + } + let src = resolve(ctx, &src_fld).await?; + let dst = resolve(ctx, &dst_fld).await?; + let (Some(src), Some(dst)) = (src, dst) else { + resp.push_text( + "Move:Status", + if ctx.sess.folders().await.is_ok() { + "2" + } else { + "5" + }, + ); + root.push(resp); + continue; + }; + if src.id == dst.id { + resp.push_text("Move:Status", "4"); + root.push(resp); + continue; + } + let Some(item) = parse_server_id(ctx, &src_msg, &src.id).await? else { + resp.push_text("Move:Status", "1"); + root.push(resp); + continue; + }; + let res: Result> = async { + match (src.kind(), dst.kind()) { + (Kind::Mail, Kind::Mail) => { + let uid: u32 = item.parse()?; + let new = ctx.sess.mail_move(&src, uid, &dst).await?; + Ok(new.map(|u| (u.to_string(), String::new()))) + } + (a, b) if a == b => { + let new = ctx.sess.dav_move(&src, &item, &dst).await?; + Ok(Some((new, String::new()))) + } + _ => Ok(None), + } + } + .await; + match res { + Ok(Some((new_id, _))) => { + let new_sid = server_id(ctx, &dst_fld, &dst.id, &new_id).await?; + // the device already moved the item: reflect it in its states + let item_c = item.clone(); + patch_latest(ctx, &src_fld, |st| { + st.items.remove(&item_c); + }) + .await?; + let fp = if dst.kind() == Kind::Mail { + // fingerprint will be refreshed on next sync if different + ctx.sess + .list(&dst, None) + .await + .ok() + .and_then(|l| { + l.items + .into_iter() + .find(|(i, _)| *i == new_id) + .map(|(_, f)| f) + }) + .unwrap_or_default() + } else { + ctx.sess + .fetch_dav(&dst, &new_id) + .await + .map(|r| r.etag) + .unwrap_or_default() + }; + let nid = new_id.clone(); + patch_latest(ctx, &dst_fld, |st| { + st.items.insert(nid, fp); + }) + .await?; + resp.push_text("Move:Status", "3"); + resp.push_text("Move:DstMsgId", new_sid); + ctx.items_in += 1; + } + Ok(None) => resp.push_text("Move:Status", "5"), + Err(e) => { + tracing::warn!(user = %ctx.user(), "MoveItems failed: {e:#}"); + resp.push_text( + "Move:Status", + if e.to_string().contains("not found") { + "1" + } else { + "5" + }, + ); + } + } + root.push(resp); + } + Ok(Reply::Wbxml(root)) +} + +#[allow(dead_code)] +fn _unused(_: &convert::BodyPref) {} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..82cad61 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,68 @@ +//! ActivePush - Exchange ActiveSync gateway for IMAP/SMTP + CalDAV/CardDAV (Z-Push rewrite). + +pub mod admin; +pub mod app; +pub mod autodiscover; +pub mod backend; +pub mod config; +pub mod db; +pub mod eas; +pub mod logging; +pub mod metrics; +pub mod probe; +pub mod server; +pub mod tls; +pub mod util; +pub mod wbxml; +pub mod xmltree; + +use std::sync::Arc; + +use axum::routing::any; +use axum::Router; + +/// Public (device facing) router: ActiveSync + Autodiscover. Paths match the nginx +/// locations previously routed to Z-Push. +pub fn public_router(app: Arc) -> Router { + Router::new() + .route("/Microsoft-Server-ActiveSync", any(eas::handler)) + .route("/microsoft-server-activesync", any(eas::handler)) + .route("/AutoDiscover/AutoDiscover.xml", any(autodiscover::handler)) + .route("/Autodiscover/Autodiscover.xml", any(autodiscover::handler)) + .route("/autodiscover/autodiscover.xml", any(autodiscover::handler)) + .route("/healthz", axum::routing::get(healthz)) + .route("/", axum::routing::get(index)) + .fallback(not_found) + .with_state(app) +} + +async fn index() -> impl axum::response::IntoResponse { + ( + [("Content-Type", "text/plain; charset=utf-8")], + "ActivePush - Exchange ActiveSync endpoint.\nDevices connect to /Microsoft-Server-ActiveSync.\n", + ) +} + +async fn not_found() -> impl axum::response::IntoResponse { + ( + axum::http::StatusCode::NOT_FOUND, + [("Content-Type", "text/plain; charset=utf-8")], + "Not found.\n", + ) +} + +/// Liveness + database check for load balancers and monitoring. +async fn healthz( + axum::extract::State(app): axum::extract::State>, +) -> impl axum::response::IntoResponse { + match sqlx::query_scalar::<_, i64>("SELECT 1") + .fetch_one(&app.db) + .await + { + Ok(_) => (axum::http::StatusCode::OK, "ok\n"), + Err(_) => ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "database unavailable\n", + ), + } +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..2e14258 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,134 @@ +//! tracing setup + in-memory ring buffer exposed in the admin panel. + +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::{Context, SubscriberExt}; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +#[derive(Debug, Clone, Serialize)] +pub struct LogLine { + pub ts: i64, + pub level: String, + pub target: String, + pub message: String, +} + +pub struct LogRing { + lines: Mutex>, + cap: AtomicUsize, +} + +impl LogRing { + pub fn new(cap: usize) -> Arc { + Arc::new(LogRing { + lines: Mutex::new(VecDeque::with_capacity(cap)), + cap: AtomicUsize::new(cap.max(100)), + }) + } + pub fn set_cap(&self, cap: usize) { + self.cap.store(cap.clamp(100, 100_000), Ordering::Relaxed); + } + pub fn push(&self, l: LogLine) { + let cap = self.cap.load(Ordering::Relaxed); + let mut g = self.lines.lock().unwrap(); + g.push_back(l); + while g.len() > cap { + g.pop_front(); + } + } + pub fn tail(&self, n: usize, min_level: Option<&str>, q: Option<&str>) -> Vec { + let rank = |l: &str| match l { + "ERROR" => 5, + "WARN" => 4, + "INFO" => 3, + "DEBUG" => 2, + _ => 1, + }; + let min = min_level.map(rank).unwrap_or(0); + let q = q.map(|s| s.to_lowercase()); + let g = self.lines.lock().unwrap(); + let mut out: Vec = g + .iter() + .rev() + .filter(|l| rank(&l.level) >= min) + .filter(|l| { + q.as_ref() + .map(|q| l.message.to_lowercase().contains(q) || l.target.contains(q)) + .unwrap_or(true) + }) + .take(n) + .cloned() + .collect(); + out.reverse(); + out + } +} + +struct RingLayer { + ring: Arc, +} + +struct MsgVisitor(String); + +impl Visit for MsgVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.0, "{value:?}"); + } else { + let _ = write!(self.0, " {}={:?}", field.name(), value); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.0.push_str(value); + } else { + let _ = write!(self.0, " {}={}", field.name(), value); + } + } +} + +impl Layer for RingLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + if *meta.level() > Level::INFO && !meta.target().starts_with("active_push") { + return; + } + let mut v = MsgVisitor(String::new()); + event.record(&mut v); + self.ring.push(LogLine { + ts: chrono::Utc::now().timestamp(), + level: meta.level().to_string(), + target: meta.target().to_string(), + message: v.0, + }); + } +} + +pub fn init(ring: Arc, json: bool) { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("active_push=info,warn")); + let ring_layer = RingLayer { ring }.with_filter(EnvFilter::new( + std::env::var("ACTIVE_PUSH_PANEL_LOG").unwrap_or_else(|_| "active_push=info,warn".into()), + )); + let registry = tracing_subscriber::registry().with(ring_layer); + if json { + registry + .with(tracing_subscriber::fmt::layer().json().with_filter(filter)) + .init(); + } else { + registry + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_filter(filter), + ) + .init(); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3a78024 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,486 @@ +use std::io::Read; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; + +use active_push::app::{App, Boot}; +use active_push::{admin, db, logging, probe, public_router, server, tls}; + +#[derive(Parser)] +#[command( + name = "active-push", + version, + about = "ActivePush - Exchange ActiveSync server for IMAP/SMTP/CalDAV/CardDAV" +)] +struct Cli { + /// Data directory (SQLite database). + #[arg( + long, + env = "ACTIVE_PUSH_DATA_DIR", + default_value = "/var/lib/active-push", + global = true + )] + data_dir: PathBuf, + /// Device facing listener (put nginx/RustProxy in front of it). + #[arg( + long, + env = "ACTIVE_PUSH_EAS_LISTEN", + default_value = "127.0.0.1:8081", + global = true + )] + eas_listen: SocketAddr, + /// Administration panel listener. + #[arg( + long, + env = "ACTIVE_PUSH_ADMIN_LISTEN", + default_value = "127.0.0.1:9091", + global = true + )] + admin_listen: SocketAddr, + /// Allow the panel to listen on a non-loopback address. + #[arg(long, env = "ACTIVE_PUSH_ALLOW_REMOTE_ADMIN", global = true)] + allow_remote_admin: bool, + /// Optional TLS for the panel (PEM files). + #[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_CERT", global = true)] + admin_tls_cert: Option, + #[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_KEY", global = true)] + admin_tls_key: Option, + /// Optional TLS for the ActiveSync listener (normally TLS ends at the reverse proxy). + #[arg(long, env = "ACTIVE_PUSH_EAS_TLS_CERT", global = true)] + eas_tls_cert: Option, + #[arg(long, env = "ACTIVE_PUSH_EAS_TLS_KEY", global = true)] + eas_tls_key: Option, + /// JSON log output (for journald/ELK). + #[arg(long, env = "ACTIVE_PUSH_LOG_JSON", global = true)] + log_json: bool, + #[command(subcommand)] + cmd: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// Run the server (default). + Serve, + /// Administrator management. + Admin { + #[command(subcommand)] + cmd: AdminCmd, + }, + /// Settings import/export. + Config { + #[command(subcommand)] + cmd: ConfigCmd, + }, + /// Device management (z-push-admin equivalents). + Device { + #[command(subcommand)] + cmd: DeviceCmd, + }, + /// ActiveSync smoke test against a running server. + Probe { + #[arg(long)] + url: String, + #[arg(long)] + user: String, + /// Read the password from this environment variable (otherwise prompt). + #[arg(long)] + password_env: Option, + #[arg(long)] + insecure: bool, + /// Device id used for the test partnership. + #[arg(long, default_value = "probe0000000001")] + device_id: String, + #[arg(long, default_value = "ActivePushProbe")] + device_type: String, + /// Model reported via DeviceInformation. + #[arg(long, default_value = "ActivePush probe")] + model: String, + }, +} + +#[derive(Subcommand)] +enum AdminCmd { + /// Print a one-time setup token (valid 1 hour) for creating the first admin in the panel. + SetupToken, + /// Create the first administrator (password is prompted). + Init { + #[arg(long)] + username: String, + }, + /// Reset an administrator password and revoke all sessions. + ResetPassword { + #[arg(long)] + username: Option, + }, + /// Revoke all panel sessions. + LogoutAll, + Status, +} + +#[derive(Subcommand)] +enum ConfigCmd { + /// Print current settings as JSON (secrets redacted unless --with-secrets). + Get { + #[arg(long)] + with_secrets: bool, + }, + /// Validate and store settings from a JSON file ("-" = stdin). A running server picks + /// them up on restart; use the panel for live changes. + Apply { file: String }, + /// Validate a JSON settings file without storing it. + Validate { file: String }, +} + +#[derive(Subcommand)] +enum DeviceCmd { + List, + /// Request a remote wipe. + Wipe { + id: i64, + }, + /// Forget all sync states (device resynchronises everything). + Resync { + id: i64, + }, + Remove { + id: i64, + }, + Block { + id: i64, + }, + Unblock { + id: i64, + }, +} + +fn read_password(prompt: &str) -> Result { + let p1 = rpassword::prompt_password(prompt)?; + let p2 = rpassword::prompt_password("Repeat password: ")?; + if p1 != p2 { + bail!("passwords do not match"); + } + Ok(p1) +} + +fn read_file(f: &str) -> Result { + if f == "-" { + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s)?; + Ok(s) + } else { + std::fs::read_to_string(f).with_context(|| format!("read {f}")) + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + rt.block_on(run(cli)) +} + +async fn run(cli: Cli) -> Result<()> { + let ring = logging::LogRing::new(2000); + let cmd = cli.cmd.unwrap_or(Cmd::Serve); + if matches!(cmd, Cmd::Serve) { + logging::init(ring.clone(), cli.log_json); + } else { + // CLI commands: quiet logging to stderr + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "warn".into()), + )) + .with_writer(std::io::stderr) + .try_init(); + } + if let Cmd::Probe { + url, + user, + password_env, + insecure, + device_id, + device_type, + model, + } = &cmd + { + let pass = match password_env { + Some(v) => { + std::env::var(v).with_context(|| format!("environment variable {v} not set"))? + } + None => rpassword::prompt_password("Password: ")?, + }; + return probe::run(url, user, &pass, *insecure, device_id, device_type, model).await; + } + let boot = Boot { + data_dir: cli.data_dir.clone(), + eas_listen: cli.eas_listen, + admin_listen: cli.admin_listen, + allow_remote_admin: cli.allow_remote_admin, + admin_tls: cli.admin_tls_cert.clone().zip(cli.admin_tls_key.clone()), + eas_tls: cli.eas_tls_cert.clone().zip(cli.eas_tls_key.clone()), + }; + check_data_dir_owner(&boot.data_dir)?; + let app = App::new(boot, ring).await?; + match cmd { + Cmd::Serve => serve(app).await, + Cmd::Admin { cmd } => match cmd { + AdminCmd::SetupToken => { + let t = admin::cli_setup_token(&app).await?; + println!("{t}"); + eprintln!("One-time setup token (valid 60 minutes). Open the panel and paste it to create the administrator."); + Ok(()) + } + AdminCmd::Init { username } => { + let pw = read_password("New administrator password (min. 12 characters): ")?; + admin::cli_init(&app, &username, &pw).await?; + println!("Administrator {username} created."); + Ok(()) + } + AdminCmd::ResetPassword { username } => { + let pw = read_password("New password (min. 12 characters): ")?; + let u = admin::cli_reset_password(&app, username.as_deref(), &pw).await?; + println!("Password for {u} changed; all panel sessions were revoked."); + Ok(()) + } + AdminCmd::LogoutAll => { + let n = admin::cli_logout_all(&app).await?; + println!("{n} session(s) revoked."); + Ok(()) + } + AdminCmd::Status => { + println!("{}", admin::cli_status(&app).await?); + Ok(()) + } + }, + Cmd::Config { cmd } => match cmd { + ConfigCmd::Get { with_secrets } => { + let s = app.settings(); + let v = if with_secrets { + serde_json::to_value(&*s)? + } else { + s.redacted() + }; + println!("{}", serde_json::to_string_pretty(&v)?); + Ok(()) + } + ConfigCmd::Apply { file } => { + let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?; + let s = app + .settings() + .merge_update(v) + .map_err(|e| anyhow::anyhow!(e))?; + let rev = app.update_settings(s).await?; + db::audit( + &app.db, + "cli", + "settings_saved", + "", + "cli", + &format!("revision {rev}"), + ) + .await; + println!("Settings stored (revision {rev}). Restart the service or save in the panel to apply live."); + Ok(()) + } + ConfigCmd::Validate { file } => { + let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?; + app.settings() + .merge_update(v) + .map_err(|e| anyhow::anyhow!(e))?; + println!("Settings are valid."); + Ok(()) + } + }, + Cmd::Device { cmd } => { + match cmd { + DeviceCmd::List => { + println!( + "{:>5} {:<28} {:<16} {:<30} {:<19} STATE", + "ID", "USER", "TYPE", "DEVICE ID", "LAST SEEN" + ); + for d in db::device_list(&app.db).await? { + let state = if d.blocked == 1 { + "blocked" + } else if d.wipe_status == db::WIPE_WIPED { + "wiped" + } else if d.wipe_status >= db::WIPE_PENDING { + "wipe pending" + } else if d.approved == 0 { + "awaiting approval" + } else { + "ok" + }; + println!( + "{:>5} {:<28} {:<16} {:<30} {:<19} {state}", + d.id, + d.username, + d.device_type, + d.device_id, + active_push::util::format_ts(d.last_seen) + ); + } + } + DeviceCmd::Wipe { id } => { + db::device_request_wipe(&app.db, id, "cli", false).await?; + db::audit(&app.db, "cli", "device_wipe", &id.to_string(), "cli", "").await; + println!("Remote wipe requested for device {id}; it is executed on the next device connection."); + } + DeviceCmd::Resync { id } => { + db::device_reset_states(&app.db, id).await?; + println!("States of device {id} removed."); + } + DeviceCmd::Remove { id } => { + db::device_delete(&app.db, id).await?; + println!("Device {id} removed."); + } + DeviceCmd::Block { id } => { + db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(1), None) + .await?; + println!("Device {id} blocked."); + } + DeviceCmd::Unblock { id } => { + db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(0), None) + .await?; + println!("Device {id} unblocked."); + } + } + Ok(()) + } + Cmd::Probe { .. } => unreachable!(), + } +} + +/// Refuse to touch a data directory owned by another user (e.g. `active-push admin …` run as +/// root against the service's /var/lib/active-push would leave root-owned database files). +fn check_data_dir_owner(dir: &std::path::Path) -> Result<()> { + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + let Ok(meta) = std::fs::metadata(dir) else { + return Ok(()); + }; + let euid = std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines().find(|l| l.starts_with("Uid:")).and_then(|l| { + l.split_whitespace() + .nth(2) + .and_then(|v| v.parse::().ok()) + }) + }); + if let Some(euid) = euid { + if euid != meta.uid() && std::env::var("ACTIVE_PUSH_ALLOW_FOREIGN_DATA_DIR").is_err() { + bail!( + "{} belongs to uid {}, but this command runs as uid {euid}.\nRun it as the service user, e.g.: sudo -u active-push active-push …", + dir.display(), + meta.uid() + ); + } + } + } + Ok(()) +} + +async fn serve(app: Arc) -> Result<()> { + let boot = app.boot.clone(); + if !boot.admin_listen.ip().is_loopback() && !boot.allow_remote_admin { + bail!( + "refusing to expose the admin panel on {} - use a loopback address, an SSH tunnel/reverse proxy, or pass --allow-remote-admin", + boot.admin_listen + ); + } + let admin_tls = match &boot.admin_tls { + Some((c, k)) => Some(tls::server_config(c, k)?), + None => None, + }; + let eas_tls = match &boot.eas_tls { + Some((c, k)) => Some(tls::server_config(c, k)?), + None => None, + }; + let eas_l = tokio::net::TcpListener::bind(boot.eas_listen) + .await + .with_context(|| format!("bind ActiveSync listener {}", boot.eas_listen))?; + let adm_l = tokio::net::TcpListener::bind(boot.admin_listen) + .await + .with_context(|| format!("bind admin listener {}", boot.admin_listen))?; + let s = app.settings(); + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + eas = %boot.eas_listen, + panel = %boot.admin_listen, + imap = %format!("{}:{}", s.imap.host, s.imap.port), + caldav = s.caldav.enabled, + carddav = s.carddav.enabled, + "ActivePush started" + ); + let has_admin: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM admins") + .fetch_one(&app.db) + .await + .unwrap_or(0); + if has_admin == 0 { + tracing::warn!( + "no administrator yet - run `active-push admin setup-token` and open the panel" + ); + } + + // background housekeeping + let bg = app.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(60)); + let mut n: u64 = 0; + loop { + tokio::select! { + _ = tick.tick() => {} + _ = bg.shutdown.cancelled() => break, + } + n += 1; + let s = bg.settings(); + bg.pool + .evict_idle(Duration::from_secs(s.imap.pool_idle_secs)); + bg.auth.housekeeping(); + bg.metrics.prune(); + if n % 5 == 0 { + bg.metrics.flush_hour(&bg.db).await; + } + if n % 60 == 1 { + if let Err(e) = db::housekeeping(&bg.db, &s).await { + tracing::warn!("housekeeping failed: {e:#}"); + } + } + } + }); + + let shutdown = app.shutdown.clone(); + let eas = tokio::spawn(server::serve( + "activesync", + eas_l, + public_router(app.clone()), + eas_tls, + shutdown.clone(), + )); + let adm = tokio::spawn(server::serve( + "panel", + adm_l, + admin::router(app.clone()), + admin_tls, + shutdown.clone(), + )); + + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } + tracing::info!("shutdown requested"); + shutdown.cancel(); + let _ = tokio::join!(eas, adm); + app.metrics.flush_hour(&app.db).await; + app.db.close().await; + tracing::info!("bye"); + Ok(()) +} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..846ce5b --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,479 @@ +//! In-memory metrics, live request registry and per-device ping tracking. + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tokio::sync::{broadcast, Notify}; + +use crate::db::Db; +use crate::util::now; + +#[derive(Default)] +pub struct Counters { + pub requests: AtomicU64, + pub errors: AtomicU64, + pub auth_failures: AtomicU64, + pub bytes_in: AtomicU64, + pub bytes_out: AtomicU64, + pub items_out: AtomicU64, + pub items_in: AtomicU64, + pub mails_sent: AtomicU64, + pub autodiscover: AtomicU64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CmdStat { + pub count: u64, + pub errors: u64, + pub total_ms: u64, + pub max_ms: u64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Bucket { + pub t: i64, + pub requests: u64, + pub errors: u64, + pub auth_failures: u64, + pub items_out: u64, + pub items_in: u64, + pub bytes_in: u64, + pub bytes_out: u64, + pub total_ms: u64, + pub pings: u64, + pub mails_sent: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ActiveRequest { + pub id: u64, + pub user: String, + pub device_id: String, + pub device_type: String, + pub command: String, + pub ip: String, + pub started: i64, + pub version: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Event { + pub ts: i64, + pub user: String, + pub device_id: String, + pub device_type: String, + pub command: String, + pub ip: String, + pub http: u16, + pub status: String, + pub ms: u64, + pub bytes_in: u64, + pub bytes_out: u64, + pub items_out: u64, + pub items_in: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PingInfo { + pub user: String, + pub device_id: String, + pub device_type: String, + pub started: i64, + pub lifetime: u64, + pub folders: usize, + pub kind: &'static str, +} + +struct PingSlot { + info: PingInfo, + cancel: Arc, + gen: u64, +} + +pub struct Metrics { + pub c: Counters, + commands: Mutex>, + minutes: Mutex>, + hour_acc: Mutex<(i64, Bucket, HashMap)>, + active: Mutex>, + next_id: AtomicU64, + recent: Mutex>, + pub events: broadcast::Sender, + pings: Mutex>, + ping_gen: AtomicU64, + users_seen: Mutex>, +} + +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} + +impl Metrics { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(512); + Metrics { + c: Counters::default(), + commands: Mutex::new(HashMap::new()), + minutes: Mutex::new(VecDeque::with_capacity(1441)), + hour_acc: Mutex::new((now() / 3600 * 3600, Bucket::default(), HashMap::new())), + active: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + recent: Mutex::new(VecDeque::with_capacity(501)), + events: tx, + pings: Mutex::new(HashMap::new()), + ping_gen: AtomicU64::new(1), + users_seen: Mutex::new(HashMap::new()), + } + } + + fn with_bucket(&self, f: impl FnOnce(&mut Bucket)) { + let minute = now() / 60 * 60; + let mut m = self.minutes.lock().unwrap(); + if m.back().map(|b| b.t) != Some(minute) { + m.push_back(Bucket { + t: minute, + ..Default::default() + }); + while m.len() > 1440 { + m.pop_front(); + } + } + f(m.back_mut().unwrap()); + } + + pub fn begin(&self, r: ActiveRequest) -> u64 { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let mut r = r; + r.id = id; + if !r.user.is_empty() { + self.users_seen + .lock() + .unwrap() + .insert(r.user.clone(), now()); + } + self.active.lock().unwrap().insert(id, r); + id + } + + pub fn end(&self, id: u64, ev: Event) { + self.active.lock().unwrap().remove(&id); + self.record(ev); + } + + pub fn record(&self, ev: Event) { + let failed = ev.http >= 500 || ev.status.starts_with("error"); + self.c.requests.fetch_add(1, Ordering::Relaxed); + if failed { + self.c.errors.fetch_add(1, Ordering::Relaxed); + } + self.c.bytes_in.fetch_add(ev.bytes_in, Ordering::Relaxed); + self.c.bytes_out.fetch_add(ev.bytes_out, Ordering::Relaxed); + self.c.items_out.fetch_add(ev.items_out, Ordering::Relaxed); + self.c.items_in.fetch_add(ev.items_in, Ordering::Relaxed); + { + let mut cmds = self.commands.lock().unwrap(); + let s = cmds.entry(ev.command.clone()).or_default(); + s.count += 1; + if failed { + s.errors += 1; + } + // pings/hanging syncs would skew latency + if ev.command != "Ping" { + s.total_ms += ev.ms; + s.max_ms = s.max_ms.max(ev.ms); + } + } + let is_ping = ev.command == "Ping"; + self.with_bucket(|b| { + b.requests += 1; + if failed { + b.errors += 1; + } + b.items_out += ev.items_out; + b.items_in += ev.items_in; + b.bytes_in += ev.bytes_in; + b.bytes_out += ev.bytes_out; + if is_ping { + b.pings += 1; + } else { + b.total_ms += ev.ms; + } + }); + { + let mut h = self.hour_acc.lock().unwrap(); + h.1.requests += 1; + if failed { + h.1.errors += 1; + } + h.1.bytes_in += ev.bytes_in; + h.1.bytes_out += ev.bytes_out; + h.1.items_out += ev.items_out; + h.1.items_in += ev.items_in; + if !is_ping { + h.1.total_ms += ev.ms; + } + *h.2.entry(ev.command.clone()).or_default() += 1; + } + { + let mut r = self.recent.lock().unwrap(); + r.push_back(ev.clone()); + while r.len() > 500 { + r.pop_front(); + } + } + let _ = self.events.send(ev); + } + + pub fn auth_failure(&self) { + self.c.auth_failures.fetch_add(1, Ordering::Relaxed); + self.with_bucket(|b| b.auth_failures += 1); + self.hour_acc.lock().unwrap().1.auth_failures += 1; + } + + pub fn mail_sent(&self) { + self.c.mails_sent.fetch_add(1, Ordering::Relaxed); + self.with_bucket(|b| b.mails_sent += 1); + self.hour_acc.lock().unwrap().1.mails_sent += 1; + } + + pub fn active(&self) -> Vec { + let mut v: Vec<_> = self.active.lock().unwrap().values().cloned().collect(); + v.sort_by_key(|a| a.started); + v + } + + pub fn recent(&self, n: usize) -> Vec { + let r = self.recent.lock().unwrap(); + r.iter().rev().take(n).cloned().collect() + } + + pub fn minutes(&self, n: usize) -> Vec { + let m = self.minutes.lock().unwrap(); + let skip = m.len().saturating_sub(n); + m.iter().skip(skip).cloned().collect() + } + + pub fn commands(&self) -> HashMap { + self.commands.lock().unwrap().clone() + } + + /// Drop per-user activity entries older than a day (bounded memory). + pub fn prune(&self) { + let cutoff = now() - 86400; + self.users_seen.lock().unwrap().retain(|_, t| *t >= cutoff); + } + + pub fn users_active(&self, window_secs: i64) -> usize { + let cutoff = now() - window_secs; + self.users_seen + .lock() + .unwrap() + .values() + .filter(|t| **t >= cutoff) + .count() + } + + // ---------------------------------------------------------------- pings + + /// Register a hanging request for a device. Any previous hanging request of the same + /// device is cancelled (the device opened a new connection). + pub fn ping_start(&self, key: &str, info: PingInfo) -> (Arc, u64) { + let gen = self.ping_gen.fetch_add(1, Ordering::Relaxed); + let cancel = Arc::new(Notify::new()); + let mut p = self.pings.lock().unwrap(); + if let Some(old) = p.insert( + key.to_string(), + PingSlot { + info, + cancel: cancel.clone(), + gen, + }, + ) { + old.cancel.notify_one(); + } + (cancel, gen) + } + + pub fn ping_end(&self, key: &str, gen: u64) { + let mut p = self.pings.lock().unwrap(); + if p.get(key).map(|s| s.gen) == Some(gen) { + p.remove(key); + } + } + + /// A new request from a device interrupts its hanging Ping/Sync. + pub fn ping_interrupt(&self, key: &str) { + if let Some(s) = self.pings.lock().unwrap().remove(key) { + s.cancel.notify_one(); + } + } + + pub fn pings(&self) -> Vec { + self.pings + .lock() + .unwrap() + .values() + .map(|s| s.info.clone()) + .collect() + } + + pub fn ping_count(&self) -> usize { + self.pings.lock().unwrap().len() + } + + // ---------------------------------------------------------------- persistence + + /// Flush the current hour accumulator into SQLite (called periodically). + pub async fn flush_hour(&self, db: &Db) { + let (hour, b, cmds) = { + let mut h = self.hour_acc.lock().unwrap(); + let cur_hour = now() / 3600 * 3600; + let snapshot = (h.0, std::mem::take(&mut h.1), std::mem::take(&mut h.2)); + h.0 = cur_hour; + snapshot + }; + if b.requests == 0 && b.auth_failures == 0 && b.mails_sent == 0 { + return; + } + // merge command json + let existing: Option = + sqlx::query_scalar("SELECT commands FROM stats_hourly WHERE hour = ?") + .bind(hour) + .fetch_optional(db) + .await + .ok() + .flatten(); + let mut merged: HashMap = existing + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + for (k, v) in cmds { + *merged.entry(k).or_default() += v; + } + let _ = sqlx::query( + "INSERT INTO stats_hourly (hour, requests, errors, auth_failures, bytes_in, bytes_out, items_out, items_in, mails_sent, total_ms, commands) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(hour) DO UPDATE SET requests = requests + excluded.requests, errors = errors + excluded.errors, + auth_failures = auth_failures + excluded.auth_failures, bytes_in = bytes_in + excluded.bytes_in, + bytes_out = bytes_out + excluded.bytes_out, items_out = items_out + excluded.items_out, + items_in = items_in + excluded.items_in, mails_sent = mails_sent + excluded.mails_sent, + total_ms = total_ms + excluded.total_ms, commands = excluded.commands", + ) + .bind(hour) + .bind(b.requests as i64) + .bind(b.errors as i64) + .bind(b.auth_failures as i64) + .bind(b.bytes_in as i64) + .bind(b.bytes_out as i64) + .bind(b.items_out as i64) + .bind(b.items_in as i64) + .bind(b.mails_sent as i64) + .bind(b.total_ms as i64) + .bind(serde_json::to_string(&merged).unwrap_or_default()) + .execute(db) + .await; + } +} + +/// Simple sliding-window failure counter + lockout (per IP and per user). +pub struct AuthGuard { + fails: Mutex>>, + locked: Mutex>, + ok_cache: Mutex>, +} + +impl Default for AuthGuard { + fn default() -> Self { + AuthGuard { + fails: Mutex::new(HashMap::new()), + locked: Mutex::new(HashMap::new()), + ok_cache: Mutex::new(HashMap::new()), + } + } +} + +impl AuthGuard { + pub fn locked_until(&self, key: &str) -> Option { + let mut l = self.locked.lock().unwrap(); + match l.get(key) { + Some(t) if *t > now() => Some(*t), + Some(_) => { + l.remove(key); + None + } + None => None, + } + } + + /// Record a failure; returns true if the key just became locked. + pub fn fail(&self, key: &str, max: u32, window: u64, lockout: u64) -> bool { + let t = now(); + let mut f = self.fails.lock().unwrap(); + let q = f.entry(key.to_string()).or_default(); + q.push_back(t); + while q.front().map(|x| *x < t - window as i64).unwrap_or(false) { + q.pop_front(); + } + if max > 0 && q.len() as u32 >= max { + q.clear(); + self.locked + .lock() + .unwrap() + .insert(key.to_string(), t + lockout as i64); + return true; + } + false + } + + pub fn success(&self, key: &str) { + self.fails.lock().unwrap().remove(key); + } + + pub fn cache_ok(&self, k: [u8; 32], ttl: u64) { + if ttl > 0 { + self.ok_cache.lock().unwrap().insert(k, now() + ttl as i64); + } + } + + pub fn is_cached(&self, k: &[u8; 32]) -> bool { + self.ok_cache + .lock() + .unwrap() + .get(k) + .map(|t| *t > now()) + .unwrap_or(false) + } + + pub fn clear_cache(&self) { + self.ok_cache.lock().unwrap().clear(); + } + + pub fn lockouts(&self) -> Vec<(String, i64)> { + let t = now(); + self.locked + .lock() + .unwrap() + .iter() + .filter(|(_, v)| **v > t) + .map(|(k, v)| (k.clone(), *v)) + .collect() + } + + pub fn unlock(&self, key: &str) { + self.locked.lock().unwrap().remove(key); + self.fails.lock().unwrap().remove(key); + } + + pub fn housekeeping(&self) { + let t = now(); + self.locked.lock().unwrap().retain(|_, v| *v > t); + self.ok_cache.lock().unwrap().retain(|_, v| *v > t); + self.fails + .lock() + .unwrap() + .retain(|_, q| q.back().map(|x| *x > t - 86400).unwrap_or(false)); + } +} diff --git a/src/probe.rs b/src/probe.rs new file mode 100644 index 0000000..72f847f --- /dev/null +++ b/src/probe.rs @@ -0,0 +1,266 @@ +//! `active-push probe` - a small ActiveSync client used for smoke tests and diagnostics. + +use anyhow::{bail, Context, Result}; + +use crate::wbxml::{self, Element}; + +pub struct Probe { + http: reqwest::Client, + url: String, + user: String, + pass: String, + device: String, + pub device_type: String, + pub model: String, + pub policy_key: String, + pub version: String, +} + +impl Probe { + pub fn new(url: &str, user: &str, pass: &str, device: &str, insecure: bool) -> Result { + let http = reqwest::Client::builder() + .danger_accept_invalid_certs(insecure) + .timeout(std::time::Duration::from_secs(120)) + .build()?; + Ok(Probe { + http, + url: url.trim_end_matches('/').to_string(), + user: user.into(), + pass: pass.into(), + device: device.into(), + device_type: "ActivePushProbe".into(), + model: "ActivePush probe".into(), + policy_key: "0".into(), + version: "14.1".into(), + }) + } + + pub async fn options(&self) -> Result<(u16, String, String)> { + let r = self + .http + .request(reqwest::Method::OPTIONS, &self.url) + .basic_auth(&self.user, Some(&self.pass)) + .send() + .await?; + let h = |n: &str| { + r.headers() + .get(n) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + Ok(( + r.status().as_u16(), + h("ms-asprotocolversions"), + h("ms-asprotocolcommands"), + )) + } + + pub async fn cmd(&self, cmd: &str, body: Option) -> Result<(u16, Option)> { + self.cmd_q(cmd, body, "").await + } + + pub async fn cmd_q( + &self, + cmd: &str, + body: Option, + extra_q: &str, + ) -> Result<(u16, Option)> { + let url = format!( + "{}?Cmd={cmd}&User={}&DeviceId={}&DeviceType={}{extra_q}", + self.url, + url::form_urlencoded::byte_serialize(self.user.as_bytes()).collect::(), + self.device, + url::form_urlencoded::byte_serialize(self.device_type.as_bytes()).collect::() + ); + let mut rb = self + .http + .post(url) + .basic_auth(&self.user, Some(&self.pass)) + .header("MS-ASProtocolVersion", &self.version) + .header("X-MS-PolicyKey", &self.policy_key) + .header("User-Agent", "ActivePush-Probe/1.0") + .header("Content-Type", "application/vnd.ms-sync.wbxml"); + if let Some(b) = body { + rb = rb.body(wbxml::encode(&b)); + } + let r = rb.send().await.with_context(|| format!("{cmd} request"))?; + let st = r.status().as_u16(); + let bytes = r.bytes().await?; + if bytes.is_empty() || st != 200 { + return Ok((st, None)); + } + Ok((st, Some(wbxml::decode(&bytes).context("decode response")?))) + } + + pub async fn provision(&mut self) -> Result { + let req = Element::new("Provision:Provision") + .with( + Element::new("Settings:DeviceInformation").with( + Element::new("Settings:Set") + .with_text("Settings:Model", self.model.clone()) + .with_text("Settings:FriendlyName", self.model.clone()) + .with_text("Settings:OS", "ActivePush probe") + .with_text("Settings:UserAgent", "ActivePush-Probe/1.0"), + ), + ) + .with( + Element::new("Provision:Policies").with( + Element::new("Provision:Policy") + .with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML"), + ), + ); + let (_, r) = self.cmd("Provision", Some(req)).await?; + let r = r.context("empty provision response")?; + let pol = r + .path(&["Provision:Policies", "Provision:Policy"]) + .context("no policy")?; + if pol.child_text("Provision:Status").as_deref() == Some("2") { + return Ok("provisioning disabled on server".into()); + } + let temp = pol + .child_text("Provision:PolicyKey") + .context("no temp key")?; + self.policy_key = temp.clone(); + let ack = Element::new("Provision:Provision").with( + Element::new("Provision:Policies").with( + Element::new("Provision:Policy") + .with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML") + .with_text("Provision:PolicyKey", temp) + .with_text("Provision:Status", "1"), + ), + ); + let (_, r) = self.cmd("Provision", Some(ack)).await?; + let r = r.context("empty provision ack")?; + let key = r + .path(&["Provision:Policies", "Provision:Policy"]) + .and_then(|p| p.child_text("Provision:PolicyKey")) + .context("no final key")?; + self.policy_key = key.clone(); + Ok(key) + } + + pub async fn folder_sync(&self, key: &str) -> Result { + let (st, r) = self + .cmd( + "FolderSync", + Some( + Element::new("FolderHierarchy:FolderSync") + .with_text("FolderHierarchy:SyncKey", key), + ), + ) + .await?; + r.with_context(|| format!("FolderSync returned HTTP {st}")) + } + + pub async fn sync( + &self, + collection: &str, + key: &str, + window: u32, + commands: Option, + ) -> Result { + let mut col = Element::new("AirSync:Collection") + .with_text("AirSync:SyncKey", key) + .with_text("AirSync:CollectionId", collection); + if key != "0" { + col.push(Element::new("AirSync:DeletesAsMoves")); + col.push(Element::new("AirSync:GetChanges")); + col.push_text("AirSync:WindowSize", window.to_string()); + col.push( + Element::new("AirSync:Options").with( + Element::new("AirSyncBase:BodyPreference") + .with_text("AirSyncBase:Type", "1") + .with_text("AirSyncBase:TruncationSize", "2048"), + ), + ); + } + if let Some(c) = commands { + col.push(c); + } + let req = Element::new("AirSync:Sync").with(Element::new("AirSync:Collections").with(col)); + let (st, r) = self.cmd("Sync", Some(req)).await?; + r.with_context(|| format!("Sync returned HTTP {st} without body")) + } +} + +/// Run a read-only smoke test and print a report. +pub async fn run( + url: &str, + user: &str, + pass: &str, + insecure: bool, + device_id: &str, + device_type: &str, + model: &str, +) -> Result<()> { + let mut p = Probe::new(url, user, pass, device_id, insecure)?; + p.device_type = device_type.to_string(); + p.model = model.to_string(); + let (st, vers, _) = p.options().await?; + println!("OPTIONS HTTP {st} versions: {vers}"); + if st == 401 { + bail!("authentication failed"); + } + match p.provision().await { + Ok(k) => println!("Provision policy key {k}"), + Err(e) => println!("Provision failed: {e:#}"), + } + let fs = p.folder_sync("0").await?; + let status = fs.child_text("FolderHierarchy:Status").unwrap_or_default(); + let mut inbox = None; + let mut n = 0; + if let Some(ch) = fs.child("FolderHierarchy:Changes") { + for a in ch.children_named("FolderHierarchy:Add") { + n += 1; + let t = a.child_text("FolderHierarchy:Type").unwrap_or_default(); + let name = a + .child_text("FolderHierarchy:DisplayName") + .unwrap_or_default(); + let id = a.child_text("FolderHierarchy:ServerId").unwrap_or_default(); + println!(" folder {id:>4} type {t:>2} {name}"); + if t == "2" { + inbox = Some(id); + } + } + } + println!("FolderSync status {status}, {n} folders"); + if let Some(ib) = inbox { + let s0 = p.sync(&ib, "0", 0, None).await?; + let key = s0 + .path(&["AirSync:Collections", "AirSync:Collection"]) + .and_then(|c| c.child_text("AirSync:SyncKey")) + .context("no sync key")?; + let s1 = p.sync(&ib, &key, 5, None).await?; + let col = s1 + .path(&["AirSync:Collections", "AirSync:Collection"]) + .context("no collection")?; + let adds = col + .child("AirSync:Commands") + .map(|c| c.children.len()) + .unwrap_or(0); + println!( + "Sync Inbox status {}, {adds} item(s){}", + col.child_text("AirSync:Status").unwrap_or_default(), + if col.has("AirSync:MoreAvailable") { + ", more available" + } else { + "" + } + ); + if let Some(first) = col + .child("AirSync:Commands") + .and_then(|c| c.children.first()) + { + if let Some(app) = first.child("AirSync:ApplicationData") { + println!( + " newest: {} from {}", + app.child_text("Email:Subject").unwrap_or_default(), + app.child_text("Email:From").unwrap_or_default() + ); + } + } + } + println!("OK"); + Ok(()) +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..eafb8f1 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,97 @@ +//! HTTP(S) listeners with graceful shutdown. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ConnectInfo; +use axum::Router; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder; +use hyper_util::service::TowerToHyperService; +use tokio::net::TcpListener; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +pub async fn serve( + name: &'static str, + listener: TcpListener, + router: Router, + tls: Option>, + shutdown: CancellationToken, +) { + let acceptor = tls.map(tokio_rustls::TlsAcceptor::from); + let mut conns = JoinSet::new(); + loop { + let (stream, peer) = tokio::select! { + r = listener.accept() => match r { + Ok(x) => x, + Err(e) => { + tracing::warn!("{name}: accept failed: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }, + _ = shutdown.cancelled() => break, + }; + let _ = stream.set_nodelay(true); + let svc = router + .clone() + .layer(axum::Extension(ConnectInfo::(peer))); + let acceptor = acceptor.clone(); + let stop = shutdown.clone(); + conns.spawn(async move { + let hyper_svc = TowerToHyperService::new(svc); + let mut builder = Builder::new(TokioExecutor::new()); + builder + .http1() + .timer(hyper_util::rt::TokioTimer::new()) + .header_read_timeout(Duration::from_secs(30)) + .max_buf_size(256 * 1024); + builder.http2().max_concurrent_streams(64); + match acceptor { + Some(a) => { + let tls = match tokio::time::timeout(Duration::from_secs(15), a.accept(stream)) + .await + { + Ok(Ok(s)) => s, + _ => return, + }; + let conn = builder.serve_connection_with_upgrades(TokioIo::new(tls), hyper_svc); + tokio::pin!(conn); + tokio::select! { + _ = conn.as_mut() => {} + _ = stop.cancelled() => { + conn.as_mut().graceful_shutdown(); + let _ = tokio::time::timeout(Duration::from_secs(10), conn).await; + } + } + } + None => { + let conn = + builder.serve_connection_with_upgrades(TokioIo::new(stream), hyper_svc); + tokio::pin!(conn); + tokio::select! { + _ = conn.as_mut() => {} + _ = stop.cancelled() => { + conn.as_mut().graceful_shutdown(); + let _ = tokio::time::timeout(Duration::from_secs(10), conn).await; + } + } + } + } + }); + // reap finished connections + while let Some(r) = conns.try_join_next() { + let _ = r; + } + } + tracing::info!( + "{name}: stopping, waiting for {} connection(s)", + conns.len() + ); + let _ = tokio::time::timeout(Duration::from_secs(12), async { + while conns.join_next().await.is_some() {} + }) + .await; +} diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..6bfd3ef --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,112 @@ +//! Shared rustls client configuration (system trust store + optional insecure mode). + +use std::sync::{Arc, OnceLock}; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme}; + +fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) +} + +fn roots() -> RootCertStore { + let mut store = RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + let _ = store.add(cert); + } + if store.is_empty() { + store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + store +} + +pub fn client_config(verify: bool) -> Arc { + static VERIFIED: OnceLock> = OnceLock::new(); + static INSECURE: OnceLock> = OnceLock::new(); + if verify { + VERIFIED + .get_or_init(|| { + Arc::new( + ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .expect("tls versions") + .with_root_certificates(roots()) + .with_no_client_auth(), + ) + }) + .clone() + } else { + INSECURE + .get_or_init(|| { + Arc::new( + ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .expect("tls versions") + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth(), + ) + }) + .clone() + } +} + +/// Accepts any certificate. Only used when the administrator explicitly disables +/// verification for a backend (e.g. self-signed certificate on localhost). +#[derive(Debug)] +struct NoVerify; + +impl ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Load a PEM certificate chain + key for the optional built-in HTTPS listeners. +pub fn server_config(cert_path: &str, key_path: &str) -> anyhow::Result> { + use anyhow::Context; + let certs = rustls_pemfile::certs(&mut std::io::BufReader::new( + std::fs::File::open(cert_path).with_context(|| format!("open {cert_path}"))?, + )) + .collect::, _>>()?; + let key = rustls_pemfile::private_key(&mut std::io::BufReader::new( + std::fs::File::open(key_path).with_context(|| format!("open {key_path}"))?, + ))? + .ok_or_else(|| anyhow::anyhow!("no private key in {key_path}"))?; + let mut cfg = rustls::ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert(certs, key)?; + cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(Arc::new(cfg)) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..c43de64 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,276 @@ +use std::net::IpAddr; + +use rand::RngCore; +use sha2::{Digest, Sha256}; + +pub fn now() -> i64 { + chrono::Utc::now().timestamp() +} + +pub fn random_token(bytes: usize) -> String { + let mut b = vec![0u8; bytes]; + rand::rngs::OsRng.fill_bytes(&mut b); + hex::encode(b) +} + +pub fn sha256_hex(data: &[u8]) -> String { + hex::encode(Sha256::digest(data)) +} + +pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { + use subtle::ConstantTimeEq; + a.len() == b.len() && bool::from(a.ct_eq(b)) +} + +#[derive(Debug, Clone, Copy)] +pub struct Cidr { + addr: IpAddr, + prefix: u8, +} + +impl Cidr { + pub fn parse(s: &str) -> Option { + let s = s.trim(); + let (a, p) = match s.split_once('/') { + Some((a, p)) => (a, Some(p)), + None => (s, None), + }; + let addr: IpAddr = a.parse().ok()?; + let max = if addr.is_ipv4() { 32 } else { 128 }; + let prefix = match p { + Some(p) => p.parse().ok().filter(|x: &u8| *x <= max)?, + None => max, + }; + Some(Cidr { addr, prefix }) + } + + pub fn contains(&self, ip: &IpAddr) -> bool { + let ip = normalize(*ip); + match (self.addr, ip) { + (IpAddr::V4(n), IpAddr::V4(i)) => { + let mask = if self.prefix == 0 { + 0 + } else { + u32::MAX << (32 - self.prefix) + }; + (u32::from(n) & mask) == (u32::from(i) & mask) + } + (IpAddr::V6(n), IpAddr::V6(i)) => { + let mask = if self.prefix == 0 { + 0 + } else { + u128::MAX << (128 - self.prefix) + }; + (u128::from(n) & mask) == (u128::from(i) & mask) + } + _ => false, + } + } +} + +pub fn normalize(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + v4 => v4, + } +} + +pub fn ip_in_any(ip: &IpAddr, nets: &[String]) -> bool { + nets.iter() + .filter_map(|n| Cidr::parse(n)) + .any(|c| c.contains(ip)) +} + +pub fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +pub fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c if (c as u32) < 0x20 && c != '\n' && c != '\r' && c != '\t' => {} + _ => out.push(c), + } + } + out +} + +/// Truncate a string to at most `n` bytes on a char boundary. +pub fn truncate_bytes(s: &str, n: usize) -> &str { + if s.len() <= n { + return s; + } + let mut i = n; + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + &s[..i] +} + +/// Convert HTML to readable plain text (used when a device wants text but only HTML exists). +pub fn html_to_text(html: &str) -> String { + let mut out = String::with_capacity(html.len() / 2); + let lower = html.to_ascii_lowercase(); + let bytes = html.as_bytes(); + let mut i = 0; + let mut skip_until: Option<&str> = None; + while i < bytes.len() { + if let Some(end) = skip_until { + if lower[i..].starts_with(end) { + skip_until = None; + i += end.len(); + } else { + i += 1; + } + continue; + } + if bytes[i] == b'<' { + let close = match html[i..].find('>') { + Some(c) => i + c, + None => break, + }; + let tag = lower[i + 1..close].trim_start_matches('/'); + let name: String = tag + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + match name.as_str() { + "script" => skip_until = Some(""), + "style" => skip_until = Some(""), + "head" => skip_until = Some(""), + "br" => out.push('\n'), + "p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "h4" | "blockquote" | "table" => { + if !out.ends_with('\n') { + out.push('\n'); + } + if name == "li" && !lower[i + 1..].starts_with('/') { + out.push_str("• "); + } + } + "td" | "th" => out.push('\t'), + _ => {} + } + i = close + 1; + continue; + } + if bytes[i] == b'&' { + if let Some(semi) = html[i..].find(';').filter(|s| *s < 10) { + let ent = &html[i + 1..i + semi]; + let rep = match ent { + "nbsp" => Some(' '), + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" | "#39" => Some('\''), + e if e.starts_with("#x") => u32::from_str_radix(&e[2..], 16) + .ok() + .and_then(char::from_u32), + e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32), + _ => None, + }; + if let Some(c) = rep { + out.push(c); + i += semi + 1; + continue; + } + } + } + let ch = html[i..].chars().next().unwrap_or(' '); + if ch == '\r' || ch == '\n' || ch == '\t' { + if !out.ends_with(' ') && !out.ends_with('\n') { + out.push(' '); + } + } else { + out.push(ch); + } + i += ch.len_utf8(); + } + // collapse 3+ newlines + let mut res = String::with_capacity(out.len()); + let mut nl = 0; + for c in out.chars() { + if c == '\n' { + nl += 1; + if nl > 2 { + continue; + } + } else { + nl = 0; + } + res.push(c); + } + res.trim().to_string() +} + +pub fn text_to_html(text: &str) -> String { + let mut out = String::from("
"); + out.push_str(&html_escape(text)); + out.push_str("
"); + out +} + +/// Validate a login name received from a device. +pub fn valid_login(s: &str) -> bool { + !s.is_empty() + && s.len() <= 254 + && s.chars().all(|c| { + !c.is_control() + && !c.is_whitespace() + && !matches!(c, '/' | '\\' | '"' | '\'' | '<' | '>' | '%' | '*' | '?') + }) +} + +pub fn format_ts(ts: i64) -> String { + chrono::DateTime::from_timestamp(ts, 0) + .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cidr() { + let c = Cidr::parse("10.0.0.0/8").unwrap(); + assert!(c.contains(&"10.2.3.4".parse().unwrap())); + assert!(!c.contains(&"11.2.3.4".parse().unwrap())); + let l = Cidr::parse("127.0.0.1").unwrap(); + assert!(l.contains(&"::ffff:127.0.0.1".parse().unwrap())); + assert!(Cidr::parse("::1/128") + .unwrap() + .contains(&"::1".parse().unwrap())); + assert!(Cidr::parse("1.2.3.4/33").is_none()); + } + + #[test] + fn html2text() { + let t = html_to_text("

Hello World


a & b"); + assert_eq!(t, "Hello World\n\na & b"); + } + + #[test] + fn logins() { + assert!(valid_login("jan.kowalski@firma.pl")); + assert!(!valid_login("a b")); + assert!(!valid_login("../etc")); + } +} diff --git a/src/wbxml/codepages.rs b/src/wbxml/codepages.rs new file mode 100644 index 0000000..6978bf7 --- /dev/null +++ b/src/wbxml/codepages.rs @@ -0,0 +1,763 @@ +// Generated from Z-Push wbxmldefs.php with MS-ASWBXML standard tag names. +pub const NAMESPACES: &[&str] = &[ + "AirSync", + "Contacts", + "Email", + "AirNotify", + "Calendar", + "Move", + "GetItemEstimate", + "FolderHierarchy", + "MeetingResponse", + "Tasks", + "ResolveRecipients", + "ValidateCert", + "Contacts2", + "Ping", + "Provision", + "Search", + "GAL", + "AirSyncBase", + "Settings", + "DocumentLibrary", + "ItemOperations", + "ComposeMail", + "Email2", + "Notes", + "RightsManagement", + "Find", +]; + +pub const PAGES: &[&[(u8, &str)]] = &[ + // 0 AirSync + &[ + (0x05, "Sync"), + (0x06, "Responses"), + (0x07, "Add"), + (0x08, "Change"), + (0x09, "Delete"), + (0x0A, "Fetch"), + (0x0B, "SyncKey"), + (0x0C, "ClientId"), + (0x0D, "ServerId"), + (0x0E, "Status"), + (0x0F, "Collection"), + (0x10, "Class"), + (0x11, "Version"), + (0x12, "CollectionId"), + (0x13, "GetChanges"), + (0x14, "MoreAvailable"), + (0x15, "WindowSize"), + (0x16, "Commands"), + (0x17, "Options"), + (0x18, "FilterType"), + (0x19, "Truncation"), + (0x1A, "RTFTruncation"), + (0x1B, "Conflict"), + (0x1C, "Collections"), + (0x1D, "ApplicationData"), + (0x1E, "DeletesAsMoves"), + (0x1F, "NotifyGUID"), + (0x20, "Supported"), + (0x21, "SoftDelete"), + (0x22, "MIMESupport"), + (0x23, "MIMETruncation"), + (0x24, "Wait"), + (0x25, "Limit"), + (0x26, "Partial"), + (0x27, "ConversationMode"), + (0x28, "MaxItems"), + (0x29, "HeartbeatInterval"), + ], + // 1 Contacts + &[ + (0x05, "Anniversary"), + (0x06, "AssistantName"), + (0x07, "AssistantPhoneNumber"), + (0x08, "Birthday"), + (0x09, "Body"), + (0x0A, "BodySize"), + (0x0B, "BodyTruncated"), + (0x0C, "Business2PhoneNumber"), + (0x0D, "BusinessCity"), + (0x0E, "BusinessCountry"), + (0x0F, "BusinessPostalCode"), + (0x10, "BusinessState"), + (0x11, "BusinessStreet"), + (0x12, "BusinessFaxNumber"), + (0x13, "BusinessPhoneNumber"), + (0x14, "CarPhoneNumber"), + (0x15, "Categories"), + (0x16, "Category"), + (0x17, "Children"), + (0x18, "Child"), + (0x19, "CompanyName"), + (0x1A, "Department"), + (0x1B, "Email1Address"), + (0x1C, "Email2Address"), + (0x1D, "Email3Address"), + (0x1E, "FileAs"), + (0x1F, "FirstName"), + (0x20, "Home2PhoneNumber"), + (0x21, "HomeCity"), + (0x22, "HomeCountry"), + (0x23, "HomePostalCode"), + (0x24, "HomeState"), + (0x25, "HomeStreet"), + (0x26, "HomeFaxNumber"), + (0x27, "HomePhoneNumber"), + (0x28, "JobTitle"), + (0x29, "LastName"), + (0x2A, "MiddleName"), + (0x2B, "MobilePhoneNumber"), + (0x2C, "OfficeLocation"), + (0x2D, "OtherCity"), + (0x2E, "OtherCountry"), + (0x2F, "OtherPostalCode"), + (0x30, "OtherState"), + (0x31, "OtherStreet"), + (0x32, "PagerNumber"), + (0x33, "RadioPhoneNumber"), + (0x34, "Spouse"), + (0x35, "Suffix"), + (0x36, "Title"), + (0x37, "WebPage"), + (0x38, "YomiCompanyName"), + (0x39, "YomiFirstName"), + (0x3A, "YomiLastName"), + (0x3B, "Rtf"), + (0x3C, "Picture"), + (0x3D, "Alias"), + (0x3E, "WeightedRank"), + ], + // 2 Email + &[ + (0x05, "Attachment"), + (0x06, "Attachments"), + (0x07, "AttName"), + (0x08, "AttSize"), + (0x09, "AttOid"), + (0x0A, "AttMethod"), + (0x0B, "AttRemoved"), + (0x0C, "Body"), + (0x0D, "BodySize"), + (0x0E, "BodyTruncated"), + (0x0F, "DateReceived"), + (0x10, "DisplayName"), + (0x11, "DisplayTo"), + (0x12, "Importance"), + (0x13, "MessageClass"), + (0x14, "Subject"), + (0x15, "Read"), + (0x16, "To"), + (0x17, "Cc"), + (0x18, "From"), + (0x19, "ReplyTo"), + (0x1A, "AllDayEvent"), + (0x1B, "Categories"), + (0x1C, "Category"), + (0x1D, "DtStamp"), + (0x1E, "EndTime"), + (0x1F, "InstanceType"), + (0x20, "BusyStatus"), + (0x21, "Location"), + (0x22, "MeetingRequest"), + (0x23, "Organizer"), + (0x24, "RecurrenceId"), + (0x25, "Reminder"), + (0x26, "ResponseRequested"), + (0x27, "Recurrences"), + (0x28, "Recurrence"), + (0x29, "Type"), + (0x2A, "Until"), + (0x2B, "Occurrences"), + (0x2C, "Interval"), + (0x2D, "DayOfWeek"), + (0x2E, "DayOfMonth"), + (0x2F, "WeekOfMonth"), + (0x30, "MonthOfYear"), + (0x31, "StartTime"), + (0x32, "Sensitivity"), + (0x33, "TimeZone"), + (0x34, "GlobalObjId"), + (0x35, "ThreadTopic"), + (0x36, "MIMEData"), + (0x37, "MIMETruncated"), + (0x38, "MIMESize"), + (0x39, "InternetCPID"), + (0x3A, "Flag"), + (0x3B, "FlagStatus"), + (0x3C, "ContentClass"), + (0x3D, "FlagType"), + (0x3E, "CompleteTime"), + (0x3F, "DisallowNewTimeProposal"), + ], + // 3 AirNotify + &[ + (0x05, "Notify"), + (0x06, "Notification"), + (0x07, "Version"), + (0x08, "Lifetime"), + (0x09, "DeviceInfo"), + (0x0A, "Enable"), + (0x0B, "Folder"), + (0x0C, "ServerEntryId"), + (0x0D, "DeviceAddress"), + (0x0E, "ValidCarrierProfiles"), + (0x0F, "CarrierProfile"), + (0x10, "Status"), + (0x11, "Replies"), + (0x12, "Devices"), + (0x13, "Device"), + (0x14, "Id"), + (0x15, "Expiry"), + (0x16, "NotifyGUID"), + ], + // 4 Calendar + &[ + (0x05, "TimeZone"), + (0x06, "AllDayEvent"), + (0x07, "Attendees"), + (0x08, "Attendee"), + (0x09, "Email"), + (0x0A, "Name"), + (0x0B, "Body"), + (0x0C, "BodyTruncated"), + (0x0D, "BusyStatus"), + (0x0E, "Categories"), + (0x0F, "Category"), + (0x10, "Rtf"), + (0x11, "DtStamp"), + (0x12, "EndTime"), + (0x13, "Exception"), + (0x14, "Exceptions"), + (0x15, "Deleted"), + (0x16, "ExceptionStartTime"), + (0x17, "Location"), + (0x18, "MeetingStatus"), + (0x19, "OrganizerEmail"), + (0x1A, "OrganizerName"), + (0x1B, "Recurrence"), + (0x1C, "Type"), + (0x1D, "Until"), + (0x1E, "Occurrences"), + (0x1F, "Interval"), + (0x20, "DayOfWeek"), + (0x21, "DayOfMonth"), + (0x22, "WeekOfMonth"), + (0x23, "MonthOfYear"), + (0x24, "Reminder"), + (0x25, "Sensitivity"), + (0x26, "Subject"), + (0x27, "StartTime"), + (0x28, "UID"), + (0x29, "AttendeeStatus"), + (0x2A, "AttendeeType"), + (0x2B, "Attachment"), + (0x2C, "Attachments"), + (0x2D, "AttName"), + (0x2E, "AttSize"), + (0x2F, "AttOid"), + (0x30, "AttMethod"), + (0x31, "AttRemoved"), + (0x32, "DisplayName"), + (0x33, "DisallowNewTimeProposal"), + (0x34, "ResponseRequested"), + (0x35, "AppointmentReplyTime"), + (0x36, "ResponseType"), + (0x37, "CalendarType"), + (0x38, "IsLeapMonth"), + (0x39, "FirstDayOfWeek"), + (0x3A, "OnlineMeetingConfLink"), + (0x3B, "OnlineMeetingExternalLink"), + (0x3C, "ClientUid"), + ], + // 5 Move + &[ + (0x05, "MoveItems"), + (0x06, "Move"), + (0x07, "SrcMsgId"), + (0x08, "SrcFldId"), + (0x09, "DstFldId"), + (0x0A, "Response"), + (0x0B, "Status"), + (0x0C, "DstMsgId"), + ], + // 6 GetItemEstimate + &[ + (0x05, "GetItemEstimate"), + (0x06, "Version"), + (0x07, "Collections"), + (0x08, "Collection"), + (0x09, "Class"), + (0x0A, "CollectionId"), + (0x0B, "DateTime"), + (0x0C, "Estimate"), + (0x0D, "Response"), + (0x0E, "Status"), + ], + // 7 FolderHierarchy + &[ + (0x05, "Folders"), + (0x06, "Folder"), + (0x07, "DisplayName"), + (0x08, "ServerId"), + (0x09, "ParentId"), + (0x0A, "Type"), + (0x0B, "Response"), + (0x0C, "Status"), + (0x0D, "ContentClass"), + (0x0E, "Changes"), + (0x0F, "Add"), + (0x10, "Delete"), + (0x11, "Update"), + (0x12, "SyncKey"), + (0x13, "FolderCreate"), + (0x14, "FolderDelete"), + (0x15, "FolderUpdate"), + (0x16, "FolderSync"), + (0x17, "Count"), + (0x18, "Version"), + ], + // 8 MeetingResponse + &[ + (0x05, "CalendarId"), + (0x06, "CollectionId"), + (0x07, "MeetingResponse"), + (0x08, "RequestId"), + (0x09, "Request"), + (0x0A, "Result"), + (0x0B, "Status"), + (0x0C, "UserResponse"), + (0x0D, "Version"), + (0x0E, "InstanceId"), + (0x10, "ProposedStartTime"), + (0x11, "ProposedEndTime"), + (0x12, "SendResponse"), + ], + // 9 Tasks + &[ + (0x05, "Body"), + (0x06, "BodySize"), + (0x07, "BodyTruncated"), + (0x08, "Categories"), + (0x09, "Category"), + (0x0A, "Complete"), + (0x0B, "DateCompleted"), + (0x0C, "DueDate"), + (0x0D, "UtcDueDate"), + (0x0E, "Importance"), + (0x0F, "Recurrence"), + (0x10, "Type"), + (0x11, "Start"), + (0x12, "Until"), + (0x13, "Occurrences"), + (0x14, "Interval"), + (0x15, "DayOfMonth"), + (0x16, "DayOfWeek"), + (0x17, "WeekOfMonth"), + (0x18, "MonthOfYear"), + (0x19, "Regenerate"), + (0x1A, "DeadOccur"), + (0x1B, "ReminderSet"), + (0x1C, "ReminderTime"), + (0x1D, "Sensitivity"), + (0x1E, "StartDate"), + (0x1F, "UtcStartDate"), + (0x20, "Subject"), + (0x21, "Rtf"), + (0x22, "OrdinalDate"), + (0x23, "SubOrdinalDate"), + (0x24, "CalendarType"), + (0x25, "IsLeapMonth"), + (0x26, "FirstDayOfWeek"), + ], + // 10 ResolveRecipients + &[ + (0x05, "ResolveRecipients"), + (0x06, "Response"), + (0x07, "Status"), + (0x08, "Type"), + (0x09, "Recipient"), + (0x0A, "DisplayName"), + (0x0B, "EmailAddress"), + (0x0C, "Certificates"), + (0x0D, "Certificate"), + (0x0E, "MiniCertificate"), + (0x0F, "Options"), + (0x10, "To"), + (0x11, "CertificateRetrieval"), + (0x12, "RecipientCount"), + (0x13, "MaxCertificates"), + (0x14, "MaxAmbiguousRecipients"), + (0x15, "CertificateCount"), + (0x16, "Availability"), + (0x17, "StartTime"), + (0x18, "EndTime"), + (0x19, "MergedFreeBusy"), + (0x1A, "Picture"), + (0x1B, "MaxSize"), + (0x1C, "Data"), + (0x1D, "MaxPictures"), + ], + // 11 ValidateCert + &[ + (0x05, "ValidateCert"), + (0x06, "Certificates"), + (0x07, "Certificate"), + (0x08, "CertificateChain"), + (0x09, "CheckCRL"), + (0x0A, "Status"), + ], + // 12 Contacts2 + &[ + (0x05, "CustomerId"), + (0x06, "GovernmentId"), + (0x07, "IMAddress"), + (0x08, "IMAddress2"), + (0x09, "IMAddress3"), + (0x0A, "ManagerName"), + (0x0B, "CompanyMainPhone"), + (0x0C, "AccountName"), + (0x0D, "NickName"), + (0x0E, "MMS"), + ], + // 13 Ping + &[ + (0x05, "Ping"), + (0x06, "AutdState"), + (0x07, "Status"), + (0x08, "HeartbeatInterval"), + (0x09, "Folders"), + (0x0A, "Folder"), + (0x0B, "Id"), + (0x0C, "Class"), + (0x0D, "MaxFolders"), + (0x0E, "Version"), + ], + // 14 Provision + &[ + (0x05, "Provision"), + (0x06, "Policies"), + (0x07, "Policy"), + (0x08, "PolicyType"), + (0x09, "PolicyKey"), + (0x0A, "Data"), + (0x0B, "Status"), + (0x0C, "RemoteWipe"), + (0x0D, "EASProvisionDoc"), + (0x0E, "DevicePasswordEnabled"), + (0x0F, "AlphanumericDevicePasswordRequired"), + (0x10, "DeviceEncryptionEnabled"), + (0x11, "PasswordRecoveryEnabled"), + (0x12, "DocumentBrowseEnabled"), + (0x13, "AttachmentsEnabled"), + (0x14, "MinDevicePasswordLength"), + (0x15, "MaxInactivityTimeDeviceLock"), + (0x16, "MaxDevicePasswordFailedAttempts"), + (0x17, "MaxAttachmentSize"), + (0x18, "AllowSimpleDevicePassword"), + (0x19, "DevicePasswordExpiration"), + (0x1A, "DevicePasswordHistory"), + (0x1B, "AllowStorageCard"), + (0x1C, "AllowCamera"), + (0x1D, "RequireDeviceEncryption"), + (0x1E, "AllowUnsignedApplications"), + (0x1F, "AllowUnsignedInstallationPackages"), + (0x20, "MinDevicePasswordComplexCharacters"), + (0x21, "AllowWiFi"), + (0x22, "AllowTextMessaging"), + (0x23, "AllowPOPIMAPEmail"), + (0x24, "AllowBluetooth"), + (0x25, "AllowIrDA"), + (0x26, "RequireManualSyncWhenRoaming"), + (0x27, "AllowDesktopSync"), + (0x28, "MaxCalendarAgeFilter"), + (0x29, "AllowHTMLEmail"), + (0x2A, "MaxEmailAgeFilter"), + (0x2B, "MaxEmailBodyTruncationSize"), + (0x2C, "MaxEmailHTMLBodyTruncationSize"), + (0x2D, "RequireSignedSMIMEMessages"), + (0x2E, "RequireEncryptedSMIMEMessages"), + (0x2F, "RequireSignedSMIMEAlgorithm"), + (0x30, "RequireEncryptionSMIMEAlgorithm"), + (0x31, "AllowSMIMEEncryptionAlgorithmNegotiation"), + (0x32, "AllowSMIMESoftCerts"), + (0x33, "AllowBrowser"), + (0x34, "AllowConsumerEmail"), + (0x35, "AllowRemoteDesktop"), + (0x36, "AllowInternetSharing"), + (0x37, "UnapprovedInROMApplicationList"), + (0x38, "ApplicationName"), + (0x39, "ApprovedApplicationList"), + (0x3A, "Hash"), + (0x3B, "AccountOnlyRemoteWipe"), + ], + // 15 Search + &[ + (0x05, "Search"), + (0x07, "Store"), + (0x08, "Name"), + (0x09, "Query"), + (0x0A, "Options"), + (0x0B, "Range"), + (0x0C, "Status"), + (0x0D, "Response"), + (0x0E, "Result"), + (0x0F, "Properties"), + (0x10, "Total"), + (0x11, "EqualTo"), + (0x12, "Value"), + (0x13, "And"), + (0x14, "Or"), + (0x15, "FreeText"), + (0x17, "DeepTraversal"), + (0x18, "LongId"), + (0x19, "RebuildResults"), + (0x1A, "LessThan"), + (0x1B, "GreaterThan"), + (0x1C, "Schema"), + (0x1D, "Supported"), + (0x1E, "UserName"), + (0x1F, "Password"), + (0x20, "ConversationId"), + (0x21, "Picture"), + (0x22, "MaxSize"), + (0x23, "MaxPictures"), + ], + // 16 GAL + &[ + (0x05, "DisplayName"), + (0x06, "Phone"), + (0x07, "Office"), + (0x08, "Title"), + (0x09, "Company"), + (0x0A, "Alias"), + (0x0B, "FirstName"), + (0x0C, "LastName"), + (0x0D, "HomePhone"), + (0x0E, "MobilePhone"), + (0x0F, "EmailAddress"), + (0x10, "Picture"), + (0x11, "Status"), + (0x12, "Data"), + ], + // 17 AirSyncBase + &[ + (0x05, "BodyPreference"), + (0x06, "Type"), + (0x07, "TruncationSize"), + (0x08, "AllOrNone"), + (0x0A, "Body"), + (0x0B, "Data"), + (0x0C, "EstimatedDataSize"), + (0x0D, "Truncated"), + (0x0E, "Attachments"), + (0x0F, "Attachment"), + (0x10, "DisplayName"), + (0x11, "FileReference"), + (0x12, "Method"), + (0x13, "ContentId"), + (0x14, "ContentLocation"), + (0x15, "IsInline"), + (0x16, "NativeBodyType"), + (0x17, "ContentType"), + (0x18, "Preview"), + (0x19, "BodyPartPreference"), + (0x1A, "BodyPart"), + (0x1B, "Status"), + (0x1C, "Add"), + (0x1D, "Delete"), + (0x1E, "ClientId"), + (0x1F, "Content"), + (0x20, "Location"), + (0x21, "Annotation"), + (0x22, "Street"), + (0x23, "City"), + (0x24, "State"), + (0x25, "Country"), + (0x26, "PostalCode"), + (0x27, "Latitude"), + (0x28, "Longitude"), + (0x29, "Accuracy"), + (0x2A, "Altitude"), + (0x2B, "AltitudeAccuracy"), + (0x2C, "LocationUri"), + (0x2D, "InstanceId"), + ], + // 18 Settings + &[ + (0x05, "Settings"), + (0x06, "Status"), + (0x07, "Get"), + (0x08, "Set"), + (0x09, "Oof"), + (0x0A, "OofState"), + (0x0B, "StartTime"), + (0x0C, "EndTime"), + (0x0D, "OofMessage"), + (0x0E, "AppliesToInternal"), + (0x0F, "AppliesToExternalKnown"), + (0x10, "AppliesToExternalUnknown"), + (0x11, "Enabled"), + (0x12, "ReplyMessage"), + (0x13, "BodyType"), + (0x14, "DevicePassword"), + (0x15, "Password"), + (0x16, "DeviceInformation"), + (0x17, "Model"), + (0x18, "IMEI"), + (0x19, "FriendlyName"), + (0x1A, "OS"), + (0x1B, "OSLanguage"), + (0x1C, "PhoneNumber"), + (0x1D, "UserInformation"), + (0x1E, "EmailAddresses"), + (0x1F, "SmtpAddress"), + (0x20, "UserAgent"), + (0x21, "EnableOutboundSMS"), + (0x22, "MobileOperator"), + (0x23, "PrimarySmtpAddress"), + (0x24, "Accounts"), + (0x25, "Account"), + (0x26, "AccountId"), + (0x27, "AccountName"), + (0x28, "UserDisplayName"), + (0x29, "SendDisabled"), + (0x2B, "RightsManagementInformation"), + ], + // 19 DocumentLibrary + &[ + (0x05, "LinkId"), + (0x06, "DisplayName"), + (0x07, "IsFolder"), + (0x08, "CreationDate"), + (0x09, "LastModifiedDate"), + (0x0A, "IsHidden"), + (0x0B, "ContentLength"), + (0x0C, "ContentType"), + ], + // 20 ItemOperations + &[ + (0x05, "ItemOperations"), + (0x06, "Fetch"), + (0x07, "Store"), + (0x08, "Options"), + (0x09, "Range"), + (0x0A, "Total"), + (0x0B, "Properties"), + (0x0C, "Data"), + (0x0D, "Status"), + (0x0E, "Response"), + (0x0F, "Version"), + (0x10, "Schema"), + (0x11, "Part"), + (0x12, "EmptyFolderContents"), + (0x13, "DeleteSubFolders"), + (0x14, "UserName"), + (0x15, "Password"), + (0x16, "Move"), + (0x17, "DstFldId"), + (0x18, "ConversationId"), + (0x19, "MoveAlways"), + ], + // 21 ComposeMail + &[ + (0x05, "SendMail"), + (0x06, "SmartForward"), + (0x07, "SmartReply"), + (0x08, "SaveInSentItems"), + (0x09, "ReplaceMime"), + (0x0A, "Type"), + (0x0B, "Source"), + (0x0C, "FolderId"), + (0x0D, "ItemId"), + (0x0E, "LongId"), + (0x0F, "InstanceId"), + (0x10, "MIME"), + (0x11, "ClientId"), + (0x12, "Status"), + (0x13, "AccountId"), + (0x15, "Forwardees"), + (0x16, "Forwardee"), + (0x17, "Name"), + (0x18, "Email"), + ], + // 22 Email2 + &[ + (0x05, "UmCallerId"), + (0x06, "UmUserNotes"), + (0x07, "UmAttDuration"), + (0x08, "UmAttOrder"), + (0x09, "ConversationId"), + (0x0A, "ConversationIndex"), + (0x0B, "LastVerbExecuted"), + (0x0C, "LastVerbExecutionTime"), + (0x0D, "ReceivedAsBcc"), + (0x0E, "Sender"), + (0x0F, "CalendarType"), + (0x10, "IsLeapMonth"), + (0x11, "AccountId"), + (0x12, "FirstDayOfWeek"), + (0x13, "MeetingMessageType"), + (0x15, "IsDraft"), + (0x16, "Bcc"), + (0x17, "Send"), + ], + // 23 Notes + &[ + (0x05, "Subject"), + (0x06, "MessageClass"), + (0x07, "LastModifiedDate"), + (0x08, "Categories"), + (0x09, "Category"), + ], + // 24 RightsManagement + &[ + (0x05, "RightsManagementSupport"), + (0x06, "RightsManagementTemplates"), + (0x07, "RightsManagementTemplate"), + (0x08, "RightsManagementLicense"), + (0x09, "EditAllowed"), + (0x0A, "ReplyAllowed"), + (0x0B, "ReplyAllAllowed"), + (0x0C, "ForwardAllowed"), + (0x0D, "ModifyRecipientsAllowed"), + (0x0E, "ExtractAllowed"), + (0x0F, "PrintAllowed"), + (0x10, "ExportAllowed"), + (0x11, "ProgrammaticAccessAllowed"), + (0x12, "Owner"), + (0x13, "ContentExpiryDate"), + (0x14, "TemplateID"), + (0x15, "TemplateName"), + (0x16, "TemplateDescription"), + (0x17, "ContentOwner"), + (0x18, "RemoveRightsManagementProtection"), + ], + // 25 Find + &[ + (0x05, "Find"), + (0x06, "SearchId"), + (0x07, "ExecuteSearch"), + (0x08, "MailBoxSearchCriterion"), + (0x09, "Query"), + (0x0A, "Status"), + (0x0B, "FreeText"), + (0x0C, "Options"), + (0x0D, "Range"), + (0x0E, "DeepTraversal"), + (0x11, "Response"), + (0x12, "Result"), + (0x13, "Properties"), + (0x14, "Preview"), + (0x15, "HasAttachments"), + (0x16, "Total"), + (0x17, "DisplayCc"), + (0x18, "DisplayBcc"), + (0x19, "GalSearchCriterion"), + (0x20, "MaxPictures"), + (0x21, "MaxSize"), + (0x22, "Picture"), + ], +]; diff --git a/src/wbxml/mod.rs b/src/wbxml/mod.rs new file mode 100644 index 0000000..1627ea3 --- /dev/null +++ b/src/wbxml/mod.rs @@ -0,0 +1,556 @@ +//! WBXML (MS-ASWBXML) encoder/decoder with a small element tree. +//! +//! Elements are addressed by `"Namespace:Tag"` names, e.g. `"AirSync:SyncKey"`. + +mod codepages; + +use std::collections::HashMap; +use std::fmt::Write as _; +use std::sync::OnceLock; + +pub use codepages::NAMESPACES; + +const SWITCH_PAGE: u8 = 0x00; +const END: u8 = 0x01; +const ENTITY: u8 = 0x02; +const STR_I: u8 = 0x03; +const LITERAL: u8 = 0x04; +const STR_T: u8 = 0x83; +const OPAQUE: u8 = 0xC3; +const WITH_CONTENT: u8 = 0x40; +const WITH_ATTRIBUTES: u8 = 0x80; + +/// Hard limits protecting the decoder against hostile input. +const MAX_DEPTH: usize = 64; +const MAX_ELEMENTS: usize = 500_000; + +#[derive(Debug, thiserror::Error)] +pub enum WbxmlError { + #[error("unexpected end of WBXML stream")] + Eof, + #[error("invalid WBXML: {0}")] + Invalid(&'static str), + #[error("unknown WBXML tag {0}")] + UnknownName(String), +} + +struct Tables { + by_name: HashMap, +} + +fn tables() -> &'static Tables { + static T: OnceLock = OnceLock::new(); + T.get_or_init(|| { + let mut by_name = HashMap::new(); + for (page, tags) in codepages::PAGES.iter().enumerate() { + let ns = codepages::NAMESPACES[page]; + for (code, tag) in tags.iter() { + by_name.insert(format!("{ns}:{tag}"), (page as u8, *code)); + } + } + Tables { by_name } + }) +} + +/// Resolve a `Namespace:Tag` name into its (page, token) pair. +pub fn lookup(name: &str) -> Option<(u8, u8)> { + tables().by_name.get(name).copied() +} + +fn tag_name(page: u8, code: u8) -> (&'static str, &'static str) { + let ns = codepages::NAMESPACES + .get(page as usize) + .copied() + .unwrap_or("Unknown"); + let tag = codepages::PAGES + .get(page as usize) + .and_then(|t| t.iter().find(|(c, _)| *c == code).map(|(_, n)| *n)) + .unwrap_or("Unknown"); + (ns, tag) +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Content { + None, + Text(String), + Opaque(Vec), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Element { + pub page: u8, + pub code: u8, + pub children: Vec, + pub content: Content, +} + +impl Element { + /// Create a new element. Panics on unknown names: tag names are compile-time literals + /// in this code base and covered by tests. + pub fn new(name: &str) -> Self { + let (page, code) = lookup(name).unwrap_or_else(|| panic!("unknown WBXML tag name {name}")); + Element { + page, + code, + children: Vec::new(), + content: Content::None, + } + } + + pub fn ns(&self) -> &'static str { + tag_name(self.page, self.code).0 + } + + pub fn tag(&self) -> &'static str { + tag_name(self.page, self.code).1 + } + + pub fn name(&self) -> String { + let (ns, tag) = tag_name(self.page, self.code); + format!("{ns}:{tag}") + } + + pub fn is(&self, name: &str) -> bool { + lookup(name) == Some((self.page, self.code)) + } + + // ----- builder helpers ----- + + pub fn text(name: &str, value: impl Into) -> Self { + let mut e = Element::new(name); + e.content = Content::Text(value.into()); + e + } + + pub fn opaque(name: &str, value: Vec) -> Self { + let mut e = Element::new(name); + e.content = Content::Opaque(value); + e + } + + pub fn with(mut self, child: Element) -> Self { + self.children.push(child); + self + } + + pub fn with_text(mut self, name: &str, value: impl Into) -> Self { + self.children.push(Element::text(name, value)); + self + } + + pub fn with_opt(mut self, name: &str, value: Option>) -> Self { + if let Some(v) = value { + self.children.push(Element::text(name, v)); + } + self + } + + pub fn with_empty(mut self, name: &str) -> Self { + self.children.push(Element::new(name)); + self + } + + pub fn push(&mut self, child: Element) { + self.children.push(child); + } + + pub fn push_text(&mut self, name: &str, value: impl Into) { + self.children.push(Element::text(name, value)); + } + + pub fn push_opt(&mut self, name: &str, value: Option>) { + if let Some(v) = value { + self.children.push(Element::text(name, v)); + } + } + + // ----- query helpers ----- + + pub fn child(&self, name: &str) -> Option<&Element> { + let key = lookup(name)?; + self.children.iter().find(|c| (c.page, c.code) == key) + } + + pub fn children_named<'a>(&'a self, name: &str) -> impl Iterator + 'a { + let key = lookup(name); + self.children + .iter() + .filter(move |c| Some((c.page, c.code)) == key) + } + + /// Text of a direct child element. + pub fn child_text(&self, name: &str) -> Option { + self.child(name).map(|c| c.text_value()) + } + + pub fn child_u32(&self, name: &str) -> Option { + self.child_text(name).and_then(|t| t.trim().parse().ok()) + } + + pub fn has(&self, name: &str) -> bool { + self.child(name).is_some() + } + + /// Follow a path of direct children. + pub fn path(&self, names: &[&str]) -> Option<&Element> { + let mut cur = self; + for n in names { + cur = cur.child(n)?; + } + Some(cur) + } + + pub fn text_value(&self) -> String { + match &self.content { + Content::Text(s) => s.clone(), + Content::Opaque(b) => String::from_utf8_lossy(b).into_owned(), + Content::None => String::new(), + } + } + + pub fn bytes_value(&self) -> Vec { + match &self.content { + Content::Text(s) => s.as_bytes().to_vec(), + Content::Opaque(b) => b.clone(), + Content::None => Vec::new(), + } + } + + /// Human readable XML rendering for debug logs (data truncated). + pub fn to_xml(&self) -> String { + let mut out = String::new(); + self.write_xml(&mut out, 0); + out + } + + fn write_xml(&self, out: &mut String, depth: usize) { + let pad = " ".repeat(depth); + let (ns, tag) = tag_name(self.page, self.code); + let name = format!("{ns}:{tag}"); + match (&self.content, self.children.is_empty()) { + (Content::None, true) => { + let _ = writeln!(out, "{pad}<{name}/>"); + } + (Content::Text(t), _) => { + let shown = if tag == "Password" || tag == "Data" && t.len() > 256 { + format!("[{} chars]", t.len()) + } else if t.len() > 256 { + format!("{}…", &t[..t.floor_char_boundary_compat(256)]) + } else { + t.clone() + }; + let _ = writeln!(out, "{pad}<{name}>{}", xml_escape(&shown)); + } + (Content::Opaque(b), _) => { + let _ = writeln!(out, "{pad}<{name}>[opaque {} bytes]", b.len()); + } + (Content::None, false) => { + let _ = writeln!(out, "{pad}<{name}>"); + for c in &self.children { + c.write_xml(out, depth + 1); + } + let _ = writeln!(out, "{pad}"); + } + } + } +} + +trait FloorBoundary { + fn floor_char_boundary_compat(&self, idx: usize) -> usize; +} + +impl FloorBoundary for String { + fn floor_char_boundary_compat(&self, idx: usize) -> usize { + let mut i = idx.min(self.len()); + while i > 0 && !self.is_char_boundary(i) { + i -= 1; + } + i + } +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +// ---------------------------------------------------------------- encoder + +pub fn encode(root: &Element) -> Vec { + let mut out = Vec::with_capacity(512); + out.extend_from_slice(&[0x03, 0x01, 0x6A, 0x00]); + let mut page = 0u8; + encode_element(root, &mut out, &mut page); + out +} + +fn encode_element(e: &Element, out: &mut Vec, page: &mut u8) { + if e.page != *page { + out.push(SWITCH_PAGE); + out.push(e.page); + *page = e.page; + } + let has_content = !e.children.is_empty() || !matches!(e.content, Content::None); + out.push(if has_content { + e.code | WITH_CONTENT + } else { + e.code + }); + if !has_content { + return; + } + match &e.content { + Content::Text(t) => { + out.push(STR_I); + // NUL bytes would terminate the inline string; strip them. + out.extend(t.bytes().filter(|b| *b != 0)); + out.push(0); + } + Content::Opaque(b) => { + out.push(OPAQUE); + write_mb_u32(out, b.len() as u32); + out.extend_from_slice(b); + } + Content::None => {} + } + for c in &e.children { + encode_element(c, out, page); + } + out.push(END); +} + +fn write_mb_u32(out: &mut Vec, mut v: u32) { + let mut buf = [0u8; 5]; + let mut i = 4; + buf[i] = (v & 0x7f) as u8; + v >>= 7; + while v > 0 { + i -= 1; + buf[i] = 0x80 | (v & 0x7f) as u8; + v >>= 7; + } + out.extend_from_slice(&buf[i..]); +} + +// ---------------------------------------------------------------- decoder + +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn byte(&mut self) -> Result { + let b = *self.data.get(self.pos).ok_or(WbxmlError::Eof)?; + self.pos += 1; + Ok(b) + } + + fn peek(&self) -> Option { + self.data.get(self.pos).copied() + } + + fn mb_u32(&mut self) -> Result { + let mut v: u32 = 0; + for _ in 0..5 { + let b = self.byte()?; + v = v + .checked_shl(7) + .ok_or(WbxmlError::Invalid("mb_u_int32 overflow"))? + | (b & 0x7f) as u32; + if b & 0x80 == 0 { + return Ok(v); + } + } + Err(WbxmlError::Invalid("mb_u_int32 too long")) + } + + fn cstr(&mut self) -> Result, WbxmlError> { + let start = self.pos; + let rel = self.data[start..] + .iter() + .position(|b| *b == 0) + .ok_or(WbxmlError::Eof)?; + self.pos = start + rel + 1; + Ok(self.data[start..start + rel].to_vec()) + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], WbxmlError> { + if self.pos + n > self.data.len() { + return Err(WbxmlError::Eof); + } + let s = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(s) + } +} + +pub fn decode(data: &[u8]) -> Result { + let mut r = Reader { data, pos: 0 }; + let _version = r.byte()?; + let _public_id = r.mb_u32()?; + let _charset = r.mb_u32()?; + let strtbl_len = r.mb_u32()? as usize; + let strtbl = r.take(strtbl_len)?.to_vec(); + let mut page = 0u8; + let mut count = 0usize; + // skip leading switch pages + loop { + match r.peek() { + Some(SWITCH_PAGE) => { + r.byte()?; + page = r.byte()?; + } + Some(_) => break, + None => return Err(WbxmlError::Eof), + } + } + decode_element(&mut r, &mut page, &strtbl, 0, &mut count) +} + +fn decode_element( + r: &mut Reader, + page: &mut u8, + strtbl: &[u8], + depth: usize, + count: &mut usize, +) -> Result { + if depth > MAX_DEPTH { + return Err(WbxmlError::Invalid("nesting too deep")); + } + *count += 1; + if *count > MAX_ELEMENTS { + return Err(WbxmlError::Invalid("too many elements")); + } + let tok = r.byte()?; + if tok == LITERAL { + return Err(WbxmlError::Invalid("literal tags not supported")); + } + if tok & WITH_ATTRIBUTES != 0 { + return Err(WbxmlError::Invalid("attributes not supported")); + } + let mut el = Element { + page: *page, + code: tok & 0x3f, + children: Vec::new(), + content: Content::None, + }; + if tok & WITH_CONTENT == 0 { + return Ok(el); + } + let mut text: Option> = None; + let mut opaque: Option> = None; + loop { + let next = r.peek().ok_or(WbxmlError::Eof)?; + match next { + END => { + r.byte()?; + break; + } + SWITCH_PAGE => { + r.byte()?; + *page = r.byte()?; + } + STR_I => { + r.byte()?; + let s = r.cstr()?; + text.get_or_insert_with(Vec::new).extend_from_slice(&s); + } + STR_T => { + r.byte()?; + let off = r.mb_u32()? as usize; + let rest = strtbl + .get(off..) + .ok_or(WbxmlError::Invalid("bad string table offset"))?; + let end = rest.iter().position(|b| *b == 0).unwrap_or(rest.len()); + text.get_or_insert_with(Vec::new) + .extend_from_slice(&rest[..end]); + } + ENTITY => { + r.byte()?; + let cp = r.mb_u32()?; + if let Some(c) = char::from_u32(cp) { + let mut b = [0u8; 4]; + text.get_or_insert_with(Vec::new) + .extend_from_slice(c.encode_utf8(&mut b).as_bytes()); + } + } + OPAQUE => { + r.byte()?; + let len = r.mb_u32()? as usize; + let b = r.take(len)?; + opaque.get_or_insert_with(Vec::new).extend_from_slice(b); + } + _ => { + let child = decode_element(r, page, strtbl, depth + 1, count)?; + el.children.push(child); + } + } + } + if let Some(o) = opaque { + el.content = Content::Opaque(o); + } else if let Some(t) = text { + el.content = Content::Text(String::from_utf8_lossy(&t).into_owned()); + } + Ok(el) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let root = Element::new("AirSync:Sync").with( + Element::new("AirSync:Collections").with( + Element::new("AirSync:Collection") + .with_text("AirSync:SyncKey", "{abc}1") + .with_text("AirSync:CollectionId", "5") + .with_empty("AirSync:GetChanges") + .with( + Element::new("AirSync:Options") + .with( + Element::new("AirSyncBase:BodyPreference") + .with_text("AirSyncBase:Type", "2"), + ) + .with(Element::opaque("ComposeMail:MIME", vec![0, 1, 2, 255])), + ), + ), + ); + let bytes = encode(&root); + let back = decode(&bytes).unwrap(); + assert_eq!(root, back); + let col = back + .path(&["AirSync:Collections", "AirSync:Collection"]) + .unwrap(); + assert_eq!(col.child_text("AirSync:SyncKey").unwrap(), "{abc}1"); + assert!(col.has("AirSync:GetChanges")); + } + + #[test] + fn mb_u32() { + for v in [0u32, 1, 127, 128, 300, 16383, 16384, 1 << 28, u32::MAX] { + let mut out = vec![]; + write_mb_u32(&mut out, v); + let mut r = Reader { data: &out, pos: 0 }; + assert_eq!(r.mb_u32().unwrap(), v); + } + } + + #[test] + fn all_names_unique_per_page() { + for (p, tags) in codepages::PAGES.iter().enumerate() { + let mut seen = std::collections::HashSet::new(); + for (c, _) in tags.iter() { + assert!(seen.insert(*c), "duplicate token {c:#x} on page {p}"); + } + } + } + + #[test] + fn rejects_garbage() { + assert!(decode(&[0x03, 0x01, 0x6a, 0x00, 0x45]).is_err()); + assert!(decode(&[]).is_err()); + } +} diff --git a/src/xmltree.rs b/src/xmltree.rs new file mode 100644 index 0000000..3f35c4d --- /dev/null +++ b/src/xmltree.rs @@ -0,0 +1,166 @@ +//! Tiny namespace-aware XML DOM built on quick-xml (no DTD / entity expansion). + +use anyhow::{bail, Result}; +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; +use quick_xml::NsReader; + +#[derive(Debug, Clone, Default)] +pub struct XNode { + pub ns: String, + pub name: String, + pub text: String, + pub attrs: Vec<(String, String)>, + pub children: Vec, +} + +impl XNode { + pub fn attr(&self, name: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + pub fn child(&self, name: &str) -> Option<&XNode> { + self.children + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + } + pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.children + .iter() + .filter(move |c| c.name.eq_ignore_ascii_case(name)) + } + /// Depth-first search for the first descendant with this local name. + pub fn find(&self, name: &str) -> Option<&XNode> { + for c in &self.children { + if c.name.eq_ignore_ascii_case(name) { + return Some(c); + } + if let Some(f) = c.find(name) { + return Some(f); + } + } + None + } + pub fn find_all<'a>(&'a self, name: &str, out: &mut Vec<&'a XNode>) { + for c in &self.children { + if c.name.eq_ignore_ascii_case(name) { + out.push(c); + } + c.find_all(name, out); + } + } + pub fn text_of(&self, name: &str) -> Option { + self.find(name).map(|n| n.text.trim().to_string()) + } +} + +fn attrs_of(e: &quick_xml::events::BytesStart) -> Vec<(String, String)> { + e.attributes() + .flatten() + .take(64) + .map(|a| { + ( + String::from_utf8_lossy(a.key.local_name().as_ref()).into_owned(), + a.unescape_value() + .map(|v| v.into_owned()) + .unwrap_or_default(), + ) + }) + .collect() +} + +pub fn parse(xml: &[u8]) -> Result { + if xml.len() > 64 * 1024 * 1024 { + bail!("XML document too large"); + } + let text = String::from_utf8_lossy(xml); + // Refuse DTDs outright (XXE / billion laughs protection). + if text.contains(" = vec![XNode::default()]; + let mut depth = 0usize; + loop { + match reader.read_resolved_event()? { + (ns, Event::Start(e)) => { + depth += 1; + if depth > 128 { + bail!("XML nesting too deep"); + } + let ns = match ns { + ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(), + _ => String::new(), + }; + stack.push(XNode { + ns, + name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(), + attrs: attrs_of(&e), + ..Default::default() + }); + } + (ns, Event::Empty(e)) => { + let ns = match ns { + ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(), + _ => String::new(), + }; + let node = XNode { + ns, + name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(), + attrs: attrs_of(&e), + ..Default::default() + }; + if let Some(top) = stack.last_mut() { + top.children.push(node); + } + } + (_, Event::End(_)) => { + depth = depth.saturating_sub(1); + let node = stack.pop().unwrap_or_default(); + match stack.last_mut() { + Some(top) => top.children.push(node), + None => bail!("unbalanced XML"), + } + } + (_, Event::Text(t)) => { + if let Some(top) = stack.last_mut() { + let s = t.unescape().map(|c| c.into_owned()).unwrap_or_default(); + top.text.push_str(&s); + } + } + (_, Event::CData(t)) => { + if let Some(top) = stack.last_mut() { + top.text.push_str(&String::from_utf8_lossy(&t)); + } + } + (_, Event::Eof) => break, + (_, Event::DocType(_)) => bail!("XML DTD not allowed"), + _ => {} + } + } + let root = stack.pop().unwrap_or_default(); + root.children + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("empty XML document")) +} + +#[cfg(test)] +mod tests { + #[test] + fn parse_ns() { + let x = br#"/a/b.ics"x""#; + let n = super::parse(x).unwrap(); + assert_eq!(n.name, "multistatus"); + assert_eq!(n.ns, "DAV:"); + assert_eq!(n.text_of("getetag").unwrap(), "\"x\""); + } + + #[test] + fn rejects_dtd() { + assert!(super::parse(b"]>&a;").is_err()); + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..ad5ea2a --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,634 @@ +//! End-to-end test against a running ActivePush with the dev backends +//! (`tools/dev/test-backends.sh` + `tools/dev/test-settings.json`). +//! +//! AP_E2E_URL=http://127.0.0.1:18081 cargo test --test e2e -- --nocapture --test-threads=1 +//! +//! Skipped when AP_E2E_URL is not set. + +use std::time::Duration; + +use active_push::backend::imap::ImapConn; +use active_push::config::{ImapSettings, TlsMode}; +use active_push::probe::Probe; +use active_push::wbxml::Element; + +const USER: &str = "jan@test.local"; +const PASS: &str = "Test-Password-123"; + +fn url() -> Option { + std::env::var("AP_E2E_URL") + .ok() + .map(|u| format!("{}/Microsoft-Server-ActiveSync", u.trim_end_matches('/'))) +} + +async fn imap() -> ImapConn { + let cfg = ImapSettings { + host: "127.0.0.1".into(), + port: 11143, + security: TlsMode::None, + ..Default::default() + }; + let mut c = ImapConn::connect(&cfg).await.unwrap(); + assert!(c.login(USER, PASS).await.unwrap()); + c +} + +struct Folders { + list: Vec<(String, String, String, String)>, // id, parent, name, type +} + +impl Folders { + fn by_type(&self, t: &str) -> String { + self.list + .iter() + .find(|f| f.3 == t) + .map(|f| f.0.clone()) + .unwrap_or_else(|| panic!("no folder of type {t}")) + } + fn by_name(&self, n: &str) -> (String, String) { + self.list + .iter() + .find(|f| f.2 == n) + .map(|f| (f.0.clone(), f.1.clone())) + .unwrap_or_else(|| panic!("no folder {n}")) + } +} + +fn col(r: &Element) -> &Element { + r.path(&["AirSync:Collections", "AirSync:Collection"]) + .expect("collection in sync response") +} + +async fn full_sync(p: &Probe, id: &str) -> (String, Vec) { + let r = p.sync(id, "0", 0, None).await.unwrap(); + let mut key = col(&r).child_text("AirSync:SyncKey").unwrap(); + let mut items = Vec::new(); + for _ in 0..50 { + let r = p.sync(id, &key, 100, None).await.unwrap(); + let c = col(&r); + assert_eq!( + c.child_text("AirSync:Status").as_deref(), + Some("1"), + "sync status: {}", + r.to_xml() + ); + key = c.child_text("AirSync:SyncKey").unwrap(); + if let Some(cmds) = c.child("AirSync:Commands") { + items.extend(cmds.children.iter().cloned()); + } + if !c.has("AirSync:MoreAvailable") { + break; + } + } + (key, items) +} + +async fn changes(p: &Probe, id: &str, key: &str, cmds: Option) -> (String, Element) { + let r = p.sync(id, key, 100, cmds).await.unwrap(); + let c = col(&r).clone(); + (c.child_text("AirSync:SyncKey").unwrap(), c) +} + +#[tokio::test] +async fn e2e_full_flow() { + let Some(url) = url() else { + eprintln!("AP_E2E_URL not set - skipping end-to-end test"); + return; + }; + let dev = format!("e2e{}", std::process::id()); + // seed fresh messages with an attachment + { + let mut im = imap().await; + for f in ["Zmieniony", "Nowy folder &AXw-"] { + let _ = im.delete(f).await; + } + for i in 1..=3 { + let m = format!("From: Anna Nowak \r\nTo: Jan \r\nSubject: Testowa wiadomość e2e {i}\r\nMessage-ID: \r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=b1\r\n\r\n--b1\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości numer {i}.\r\n--b1\r\nContent-Type: application/pdf; name=\"raport.pdf\"\r\nContent-Disposition: attachment; filename=\"raport.pdf\"\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0xLjQKJcOkw7zDtsOfCg==\r\n--b1--\r\n"); + im.append("INBOX", "", m.as_bytes()).await.unwrap(); + } + } + let mut p = Probe::new(&url, USER, PASS, &dev, true).unwrap(); + + // ---- auth failure gives 401 + let bad = Probe::new(&url, USER, "wrong-password", &dev, true).unwrap(); + assert_eq!(bad.options().await.unwrap().0, 401); + + // ---- provisioning is enforced + let (st, _) = p + .cmd( + "FolderSync", + Some( + Element::new("FolderHierarchy:FolderSync") + .with_text("FolderHierarchy:SyncKey", "0"), + ), + ) + .await + .unwrap(); + assert!( + st == 449 || st == 200, + "unprovisioned FolderSync answered {st}" + ); + p.provision().await.unwrap(); + + // ---- hierarchy + let fs = p.folder_sync("0").await.unwrap(); + let fkey = fs.child_text("FolderHierarchy:SyncKey").unwrap(); + let mut list = Vec::new(); + for a in fs + .child("FolderHierarchy:Changes") + .unwrap() + .children_named("FolderHierarchy:Add") + { + list.push(( + a.child_text("FolderHierarchy:ServerId").unwrap(), + a.child_text("FolderHierarchy:ParentId").unwrap(), + a.child_text("FolderHierarchy:DisplayName").unwrap(), + a.child_text("FolderHierarchy:Type").unwrap(), + )); + } + let f = Folders { list }; + let inbox = f.by_type("2"); + let trash = f.by_type("4"); + let sent = f.by_type("5"); + let cal = f.by_type("8"); + let tasks = f.by_type("7"); + let contacts = f.by_type("9"); + let (projekty, _) = f.by_name("Projekty"); + let (_, alfa_parent) = f.by_name("Alfa"); + assert_eq!(alfa_parent, projekty, "subfolder parent"); + // unchanged hierarchy keeps key + let fs2 = p.folder_sync(&fkey).await.unwrap(); + assert_eq!( + fs2.path(&["FolderHierarchy:Changes", "FolderHierarchy:Count"]) + .unwrap() + .text_value(), + "0" + ); + + // ---- Z-Push leftovers: a collection id unknown here must be deleted on the device + let zr = p + .sync( + "a6271970", + "{11111111-2222-3333-4444-555555555555}7", + 10, + None, + ) + .await + .unwrap(); + assert_eq!(col(&zr).child_text("AirSync:Status").as_deref(), Some("12")); + let fs3 = p.folder_sync(&fkey).await.unwrap(); + let ch3 = fs3.child("FolderHierarchy:Changes").expect("changes"); + assert!( + ch3.children_named("FolderHierarchy:Delete") + .any(|d| d.child_text("FolderHierarchy:ServerId").as_deref() == Some("a6271970")), + "stale collection deleted: {}", + fs3.to_xml() + ); + let fkey = fs3.child_text("FolderHierarchy:SyncKey").unwrap(); + + // ---- mail + let (mut ikey, mails) = full_sync(&p, &inbox).await; + assert!(mails.len() >= 3, "expected mails, got {}", mails.len()); + let mails: Vec = mails + .into_iter() + .filter(|m| { + m.child("AirSync:ApplicationData") + .and_then(|a| a.child_text("Email:Subject")) + .map(|s| s.starts_with("Testowa wiadomo")) + .unwrap_or(false) + }) + .collect(); + assert!(mails.len() >= 3, "seeded test mails present"); + let first = &mails[0]; + let app = first.child("AirSync:ApplicationData").unwrap(); + assert!(app + .child_text("Email:Subject") + .unwrap() + .starts_with("Testowa wiadomo")); + let fref = app + .path(&["AirSyncBase:Attachments", "AirSyncBase:Attachment"]) + .and_then(|a| a.child_text("AirSyncBase:FileReference")) + .expect("attachment"); + // attachment via ItemOperations + let io = Element::new("ItemOperations:ItemOperations").with( + Element::new("ItemOperations:Fetch") + .with_text("ItemOperations:Store", "Mailbox") + .with_text("AirSyncBase:FileReference", fref.clone()), + ); + let (_, r) = p.cmd("ItemOperations", Some(io)).await.unwrap(); + let r = r.unwrap(); + let data = r + .path(&[ + "ItemOperations:Response", + "ItemOperations:Fetch", + "ItemOperations:Properties", + ]) + .and_then(|x| x.child_text("ItemOperations:Data")) + .expect("attachment data"); + assert_eq!(data, "JVBERi0xLjQKJcOkw7zDtsOfCg=="); + + // client marks read + flag + let sid = first.child_text("AirSync:ServerId").unwrap(); + let ch = Element::new("AirSync:Commands").with( + Element::new("AirSync:Change") + .with_text("AirSync:ServerId", sid.clone()) + .with( + Element::new("AirSync:ApplicationData") + .with_text("Email:Read", "1") + .with(Element::new("Email:Flag").with_text("Email:FlagStatus", "2")), + ), + ); + let (k, c) = changes(&p, &inbox, &ikey, Some(ch)).await; + ikey = k; + assert!( + c.child("AirSync:Commands").is_none(), + "own change must not be echoed: {}", + c.to_xml() + ); + let uid: u32 = sid.split(':').nth(1).unwrap().parse().unwrap(); + let mut im = imap().await; + im.select("INBOX").await.unwrap(); + let fl = im.uid_fetch_flags(&uid.to_string()).await.unwrap(); + assert!( + fl[0].flags.iter().any(|x| x == "\\Seen") && fl[0].flags.iter().any(|x| x == "\\Flagged") + ); + + // server side change -> device gets Change + im.uid_store(uid, "-", "\\Flagged").await.unwrap(); + let (k, c) = changes(&p, &inbox, &ikey, None).await; + ikey = k; + let cmds = c.child("AirSync:Commands").expect("server change"); + assert!(cmds.children.iter().any(|x| x.is("AirSync:Change"))); + + // new mail arrives -> Add + im.append( + "INBOX", + "", + b"From: x@y.pl\r\nTo: jan@test.local\r\nSubject: Nowa\r\n\r\nhej\r\n", + ) + .await + .unwrap(); + let (k, c) = changes(&p, &inbox, &ikey, None).await; + ikey = k; + assert!(c + .child("AirSync:Commands") + .unwrap() + .children + .iter() + .any(|x| x.is("AirSync:Add"))); + + // delete from device -> Trash + let ch = Element::new("AirSync:Commands") + .with(Element::new("AirSync:Delete").with_text("AirSync:ServerId", sid.clone())); + let (k, _) = changes(&p, &inbox, &ikey, Some(ch)).await; + ikey = k; + im.select("Trash").await.unwrap(); + assert!( + !im.uid_search("ALL").await.unwrap().is_empty(), + "message moved to Trash" + ); + + // GetItemEstimate (nothing pending) + let gie = Element::new("GetItemEstimate:GetItemEstimate").with( + Element::new("GetItemEstimate:Collections").with( + Element::new("GetItemEstimate:Collection") + .with_text("AirSync:SyncKey", ikey.clone()) + .with_text("GetItemEstimate:CollectionId", inbox.clone()), + ), + ); + let (_, r) = p.cmd("GetItemEstimate", Some(gie)).await.unwrap(); + let r = r.unwrap(); + let est_dbg = r.to_xml(); + let est = r + .path(&["GetItemEstimate:Response", "GetItemEstimate:Collection"]) + .unwrap() + .child_text("GetItemEstimate:Estimate") + .unwrap(); + if est != "0" { + let (_, c) = changes(&p, &inbox, &ikey, None).await; + panic!("estimate {est}: {est_dbg}\npending: {}", c.to_xml()); + } + + // MoveItems inbox -> Projekty + let (_, c) = changes(&p, &inbox, &ikey, None).await; + let _ = c; + let mv_sid = mails[1].child_text("AirSync:ServerId").unwrap(); + let mv = Element::new("Move:MoveItems").with( + Element::new("Move:Move") + .with_text("Move:SrcMsgId", mv_sid) + .with_text("Move:SrcFldId", inbox.clone()) + .with_text("Move:DstFldId", projekty.clone()), + ); + let (_, r) = p.cmd("MoveItems", Some(mv)).await.unwrap(); + let r = r.unwrap(); + let resp = r.child("Move:Response").unwrap(); + assert_eq!( + resp.child_text("Move:Status").as_deref(), + Some("3"), + "{}", + r.to_xml() + ); + assert!(resp + .child_text("Move:DstMsgId") + .unwrap() + .starts_with(&format!("{projekty}:"))); + + // ---- SendMail + Sent copy + let mime = format!("From: Jan <{USER}>\r\nTo: anna@test.local\r\nBcc: tajny@test.local\r\nSubject: E2E wysyłka\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCześć Anno!\r\n"); + let sm = Element::new("ComposeMail:SendMail") + .with_text("ComposeMail:ClientId", "c1") + .with_empty("ComposeMail:SaveInSentItems") + .with(Element::opaque("ComposeMail:MIME", mime.into_bytes())); + let (st, r) = p.cmd("SendMail", Some(sm)).await.unwrap(); + assert_eq!(st, 200); + assert!( + r.is_none(), + "SendMail success returns empty body: {:?}", + r.map(|x| x.to_xml()) + ); + im.select("Sent").await.unwrap(); + assert!( + !im.uid_search("SUBJECT \"E2E\"").await.unwrap().is_empty(), + "copy in Sent" + ); + + // SmartReply quoting original + let reply_src = mails[2].child_text("AirSync:ServerId").unwrap(); + let mime = format!("From: {USER}\r\nTo: anna@test.local\r\nSubject: Re: test\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nOdpowiedź\r\n"); + let sr = Element::new("ComposeMail:SmartReply") + .with_text("ComposeMail:ClientId", "c2") + .with( + Element::new("ComposeMail:Source") + .with_text("ComposeMail:FolderId", inbox.clone()) + .with_text("ComposeMail:ItemId", reply_src), + ) + .with(Element::opaque("ComposeMail:MIME", mime.into_bytes())); + let (st, _) = p.cmd("SmartReply", Some(sr)).await.unwrap(); + assert_eq!(st, 200); + tokio::time::sleep(Duration::from_millis(300)).await; + let sink = std::fs::read_dir("data/test-backends/sink") + .map(|d| d.count()) + .unwrap_or(0); + assert!(sink >= 2, "SMTP sink got messages"); + + // ---- calendar + let (ckey, evs) = full_sync(&p, &cal).await; + let ev = evs + .iter() + .filter_map(|e| e.child("AirSync:ApplicationData")) + .find(|a| a.child_text("Calendar:Subject").as_deref() == Some("Spotkanie zespołu")) + .expect("seeded event"); + assert_eq!( + ev.child_text("Calendar:Subject").unwrap(), + "Spotkanie zespołu" + ); + assert_eq!( + ev.path(&["Calendar:Recurrence", "Calendar:Type"]) + .unwrap() + .text_value(), + "1" + ); + assert_eq!(ev.child_text("Calendar:Reminder").unwrap(), "15"); + // device adds an event + let add = Element::new("AirSync:Commands").with( + Element::new("AirSync:Add") + .with_text("AirSync:ClientId", "ev-new") + .with( + Element::new("AirSync:ApplicationData") + .with_text( + "Calendar:TimeZone", + active_push::backend::tz::blob_for(&chrono_tz::Europe::Warsaw, 2026), + ) + .with_text("Calendar:AllDayEvent", "0") + .with_text("Calendar:StartTime", "20261120T090000Z") + .with_text("Calendar:EndTime", "20261120T100000Z") + .with_text("Calendar:Subject", "Z telefonu") + .with_text("Calendar:Reminder", "30") + .with_text("Calendar:BusyStatus", "2"), + ), + ); + let (ckey2, c) = changes(&p, &cal, &ckey, Some(add)).await; + let resp = c.path(&["AirSync:Responses", "AirSync:Add"]).unwrap(); + assert_eq!(resp.child_text("AirSync:Status").as_deref(), Some("1")); + let new_sid = resp.child_text("AirSync:ServerId").unwrap(); + // idempotent retry with the same key returns the same id + let add_again = Element::new("AirSync:Commands").with( + Element::new("AirSync:Add") + .with_text("AirSync:ClientId", "ev-new") + .with(Element::new("AirSync:ApplicationData").with_text("Calendar:Subject", "dup")), + ); + let (_, c2) = changes(&p, &cal, &ckey2, Some(add_again)).await; + assert_eq!( + c2.path(&["AirSync:Responses", "AirSync:Add"]) + .unwrap() + .child_text("AirSync:ServerId") + .unwrap(), + new_sid + ); + // stored in Radicale + let body = reqwest::Client::new() + .request( + reqwest::Method::from_bytes(b"PROPFIND").unwrap(), + "http://127.0.0.1:15232/jan@test.local/kalendarz/", + ) + .basic_auth(USER, Some(PASS)) + .header("Depth", "1") + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + body.matches(".ics").count() >= 3, + "new event stored on CalDAV" + ); + + // ---- tasks + contacts + let (_, ts) = full_sync(&p, &tasks).await; + assert!(ts.iter().any(|t| t + .child("AirSync:ApplicationData") + .and_then(|a| a.child_text("Tasks:Subject")) + .as_deref() + == Some("Kupić mleko"))); + let (kkey, cs) = full_sync(&p, &contacts).await; + assert_eq!(cs.len(), 1); + let c0 = cs[0].child("AirSync:ApplicationData").unwrap(); + assert_eq!( + c0.child_text("Contacts:MobilePhoneNumber").unwrap(), + "+48 600 100 200" + ); + let csid = cs[0].child_text("AirSync:ServerId").unwrap(); + let mut changed = c0.clone(); + changed.children.retain(|x| !x.is("Contacts:JobTitle")); + changed.push_text("Contacts:JobTitle", "Kierownik"); + let ch = Element::new("AirSync:Commands").with( + Element::new("AirSync:Change") + .with_text("AirSync:ServerId", csid) + .with(changed), + ); + let (_, c) = changes(&p, &contacts, &kkey, Some(ch)).await; + assert!( + c.child("AirSync:Responses").is_none(), + "contact change ok: {}", + c.to_xml() + ); + + // ---- search + let gal = Element::new("Search:Search").with( + Element::new("Search:Store") + .with_text("Search:Name", "GAL") + .with_text("Search:Query", "Nowak") + .with(Element::new("Search:Options").with_text("Search:Range", "0-9")), + ); + let (_, r) = p.cmd("Search", Some(gal)).await.unwrap(); + let r = r.unwrap(); + assert_eq!( + r.path(&["Search:Response", "Search:Store"]) + .unwrap() + .child_text("Search:Total") + .as_deref(), + Some("1"), + "{}", + r.to_xml() + ); + let ms = Element::new("Search:Search").with( + Element::new("Search:Store") + .with_text("Search:Name", "Mailbox") + .with( + Element::new("Search:Query").with( + Element::new("Search:And") + .with_text("AirSync:Class", "Email") + .with_text("AirSync:CollectionId", inbox.clone()) + .with_text("Search:FreeText", "wiadomości numer"), + ), + ) + .with(Element::new("Search:Options").with_text("Search:Range", "0-9")), + ); + let (_, r) = p.cmd("Search", Some(ms)).await.unwrap(); + let r = r.unwrap(); + let total: u32 = r + .path(&["Search:Response", "Search:Store"]) + .unwrap() + .child_text("Search:Total") + .unwrap() + .parse() + .unwrap(); + assert!(total >= 1, "mailbox search: {}", r.to_xml()); + + // ---- folder create / rename / delete + let fc = Element::new("FolderHierarchy:FolderCreate") + .with_text("FolderHierarchy:SyncKey", fkey.clone()) + .with_text("FolderHierarchy:ParentId", "0") + .with_text("FolderHierarchy:DisplayName", "Nowy folder ż") + .with_text("FolderHierarchy:Type", "12"); + let (_, r) = p.cmd("FolderCreate", Some(fc)).await.unwrap(); + let r = r.unwrap(); + assert_eq!( + r.child_text("FolderHierarchy:Status").as_deref(), + Some("1"), + "{}", + r.to_xml() + ); + let nkey = r.child_text("FolderHierarchy:SyncKey").unwrap(); + let nid = r.child_text("FolderHierarchy:ServerId").unwrap(); + let fu = Element::new("FolderHierarchy:FolderUpdate") + .with_text("FolderHierarchy:SyncKey", nkey) + .with_text("FolderHierarchy:ServerId", nid.clone()) + .with_text("FolderHierarchy:ParentId", "0") + .with_text("FolderHierarchy:DisplayName", "Zmieniony"); + let (_, r) = p.cmd("FolderUpdate", Some(fu)).await.unwrap(); + let r = r.unwrap(); + assert_eq!( + r.child_text("FolderHierarchy:Status").as_deref(), + Some("1"), + "{}", + r.to_xml() + ); + let fd = Element::new("FolderHierarchy:FolderDelete") + .with_text( + "FolderHierarchy:SyncKey", + r.child_text("FolderHierarchy:SyncKey").unwrap(), + ) + .with_text("FolderHierarchy:ServerId", nid); + let (_, r) = p.cmd("FolderDelete", Some(fd)).await.unwrap(); + assert_eq!( + r.unwrap().child_text("FolderHierarchy:Status").as_deref(), + Some("1") + ); + + // ---- settings / user information + let s = Element::new("Settings:Settings") + .with(Element::new("Settings:UserInformation").with_empty("Settings:Get")); + let (_, r) = p.cmd("Settings", Some(s)).await.unwrap(); + assert!(r.unwrap().to_xml().contains(USER)); + + // ---- ping: new mail during heartbeat + let (fresh_key, _) = changes(&p, &inbox, &ikey, None).await; + let _ = fresh_key; + let ping = Element::new("Ping:Ping") + .with_text("Ping:HeartbeatInterval", "60") + .with( + Element::new("Ping:Folders").with( + Element::new("Ping:Folder") + .with_text("Ping:Id", inbox.clone()) + .with_text("Ping:Class", "Email"), + ), + ); + let started = std::time::Instant::now(); + let pinger = tokio::spawn({ + let p2 = Probe::new(&url, USER, PASS, &dev, true).unwrap(); + let key = p.policy_key.clone(); + async move { + let mut p2 = p2; + p2.policy_key = key; + p2.cmd("Ping", Some(ping)).await + } + }); + tokio::time::sleep(Duration::from_secs(3)).await; + im.append( + "INBOX", + "", + b"From: x@y.pl\r\nTo: jan@test.local\r\nSubject: Push!\r\n\r\npush\r\n", + ) + .await + .unwrap(); + let (_, r) = pinger.await.unwrap().unwrap(); + let r = r.unwrap(); + assert_eq!( + r.child_text("Ping:Status").as_deref(), + Some("2"), + "{}", + r.to_xml() + ); + assert!( + started.elapsed() < Duration::from_secs(20), + "push latency {:?}", + started.elapsed() + ); + eprintln!("ping reported change after {:?}", started.elapsed()); + + // ---- sent folder sync works too + let (_, sent_items) = full_sync(&p, &sent).await; + assert!(!sent_items.is_empty()); + let (_, trash_items) = full_sync(&p, &trash).await; + assert!(!trash_items.is_empty()); + + // ---- autodiscover + let base = std::env::var("AP_E2E_URL").unwrap(); + let ad = reqwest::Client::new() + .post(format!("{base}/autodiscover/autodiscover.xml")) + .basic_auth(USER, Some(PASS)) + .body(r#"jan@test.localhttp://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006"#) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + ad.contains("MobileSync") && ad.contains("Microsoft-Server-ActiveSync"), + "{ad}" + ); + eprintln!("e2e OK"); +} diff --git a/tools/dev/screenshots.js b/tools/dev/screenshots.js new file mode 100644 index 0000000..b943bc2 --- /dev/null +++ b/tools/dev/screenshots.js @@ -0,0 +1,73 @@ +// Regenerate docs/images/*.png from a running ActivePush panel (Playwright). +// node tools/dev/screenshots.js http://127.0.0.1:9091 admin 'password' [outdir] +// With SETUP_TOKEN= in the environment the first-run screen is captured and the +// administrator is created with the given credentials. +const { chromium } = require('playwright'); +const path = require('path'); + +(async () => { + const [base, user, pass, out = path.join(__dirname, '../../docs/images')] = process.argv.slice(2); + if (!base || !user || !pass) { + console.error('usage: node screenshots.js URL USER PASSWORD [OUTDIR]'); + process.exit(2); + } + const browser = await chromium.launch(); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'en-US', deviceScaleFactor: 1 }); + const page = await ctx.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + const shot = async (name, full = false) => { + await page.screenshot({ path: path.join(out, `${name}.png`), fullPage: full }); + console.log('saved', name); + }; + + await page.goto(base + '/'); + await page.waitForSelector('#auth:not([hidden])'); + if (process.env.SETUP_TOKEN) { + await page.fill('#setup-token', process.env.SETUP_TOKEN); + await page.fill('#username', user); + await shot('setup'); + await page.fill('#password', pass); + await page.fill('#password2', pass); + } else { + await page.fill('#username', user); + await page.fill('#password', pass); + } + await page.click('#auth-submit'); + await page.waitForSelector('#shell:not([hidden])'); + + const pages = [ + ['overview', true, 5000], ['live', false, 6000], ['devices', false, 2500], ['users', false, 2500], + ['backends', false, 1500], ['activesync', false, 1500], ['policies', false, 1500], ['security', false, 1500], + ['logs', false, 4000], + ]; + for (const [id, full, wait] of pages) { + await page.goto(`${base}/#${id}`); + await page.waitForTimeout(wait); + await shot(id === 'users' ? 'mailboxes' : id, full); + } + await page.goto(`${base}/#devices`); + await page.waitForTimeout(2000); + const chips = await page.$$('.chip[data-pid]'); + if (chips.length) { + await chips[0].click(); + await page.waitForTimeout(1200); + await shot('device'); + } + await page.goto(`${base}/#overview`); + await page.waitForTimeout(500); + await page.click('#theme'); + await page.waitForTimeout(4000); + await shot('overview-light'); + await page.click('#theme'); + + const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: 'en-US', deviceScaleFactor: 2, storageState: await ctx.storageState() }); + const mp = await mobile.newPage(); + await mp.goto(`${base}/#overview`); + await mp.waitForTimeout(4000); + await mp.screenshot({ path: path.join(out, 'mobile.png') }); + console.log('saved mobile'); + + if (errors.length) console.error('page errors:', errors); + await browser.close(); +})(); diff --git a/tools/dev/test-backends.sh b/tools/dev/test-backends.sh new file mode 100755 index 0000000..37ed441 --- /dev/null +++ b/tools/dev/test-backends.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Local throw-away backends for development and end-to-end tests: +# Dovecot IMAP 127.0.0.1:11143 (plain, test only) +# Radicale 127.0.0.1:15232 (CalDAV + CardDAV) +# SMTP sink 127.0.0.1:12525 (prints/stores every message, no auth) +# Test account: jan@test.local / Test-Password-123 +# +# NEVER use this configuration in production. Ctrl+C stops everything. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +DIR="${AP_TEST_DIR:-$ROOT/data/test-backends}" +RUN_USER="${AP_TEST_USER:-$(id -un)}" +if [[ "$RUN_USER" == "root" ]]; then + printf 'Run as a normal user or set AP_TEST_USER=.\n' >&2 + exit 2 +fi +for bin in dovecot radicale python3; do + command -v "$bin" >/dev/null 2>&1 || { printf 'Missing %s (apt install dovecot-imapd; pip install radicale aiosmtpd)\n' "$bin" >&2; exit 127; } +done +python3 -c 'import aiosmtpd' 2>/dev/null || { printf 'Missing python aiosmtpd (pip install aiosmtpd)\n' >&2; exit 127; } +UIDN="$(id -u "$RUN_USER")"; GIDN="$(id -g "$RUN_USER")" +mkdir -p "$DIR"/{mail,run,radicale,sink} +chown -R "$RUN_USER" "$DIR" 2>/dev/null || true + +cat > "$DIR/users" < "$DIR/dovecot.conf" < "$DIR/radicale/users" +cat > "$DIR/radicale/config" < "$DIR/sink.py" <<'EOF' +import asyncio, sys, time, os +from aiosmtpd.controller import Controller +out = sys.argv[1] +class H: + async def handle_DATA(self, server, session, envelope): + name = os.path.join(out, f"{time.time():.6f}.eml") + with open(name, "wb") as f: + f.write(f"X-Envelope-From: {envelope.mail_from}\r\nX-Envelope-To: {', '.join(envelope.rcpt_tos)}\r\n".encode()) + f.write(envelope.content) + print(f"[smtp-sink] {envelope.mail_from} -> {envelope.rcpt_tos} ({len(envelope.content)} bytes) saved {name}", flush=True) + return "250 OK" +c = Controller(H(), hostname="127.0.0.1", port=12525) +c.start() +print("[smtp-sink] listening on 127.0.0.1:12525", flush=True) +try: + asyncio.new_event_loop().run_forever() +except KeyboardInterrupt: + pass +EOF + +pids=() +cleanup() { for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done; dovecot -c "$DIR/dovecot.conf" stop 2>/dev/null || true; } +trap cleanup EXIT INT TERM + +dovecot -c "$DIR/dovecot.conf" +if [[ "$(id -un)" == "$RUN_USER" ]]; then + radicale -C "$DIR/radicale/config" >"$DIR/radicale.log" 2>&1 & pids+=($!) + python3 "$DIR/sink.py" "$DIR/sink" & pids+=($!) +else + su -s /bin/bash "$RUN_USER" -c "radicale -C '$DIR/radicale/config' >'$DIR/radicale.log' 2>&1" & pids+=($!) + su -s /bin/bash "$RUN_USER" -c "python3 '$DIR/sink.py' '$DIR/sink'" & pids+=($!) +fi +sleep 1 +cat < $DIR/sink + accounts jan, anna, biuro, kasia @test.local, password Test-Password-123 +Apply matching settings with: ./dev.sh config apply tools/dev/test-settings.json +Press Ctrl+C to stop. +EOF +wait diff --git a/tools/dev/test-settings.json b/tools/dev/test-settings.json new file mode 100644 index 0000000..ed5a076 --- /dev/null +++ b/tools/dev/test-settings.json @@ -0,0 +1,10 @@ +{ + "general": { "server_name": "ActivePush DEV", "default_timezone": "Europe/Warsaw", "login_format": "as_given", "default_domain": "test.local", "email_template": "{login}" }, + "imap": { "host": "127.0.0.1", "port": 11143, "security": "none", "verify_tls": false }, + "smtp": { "method": "smtp", "host": "127.0.0.1", "port": 12525, "security": "none", "auth": "none", "verify_tls": false }, + "caldav": { "enabled": true, "base_url": "http://127.0.0.1:15232", "path": "/%u/", "verify_tls": false }, + "carddav": { "enabled": true, "base_url": "http://127.0.0.1:15232", "path": "/%u/", "verify_tls": false, "gal_min_length": 2 }, + "ping": { "check_interval_secs": 5, "lifetime_min": 30, "lifetime_max": 3540, "max_folders": 300 }, + "sync": { "heartbeat_min": 30 }, + "security": { "auth_fail_max": 20 } +} diff --git a/tools/zpush-import.py b/tools/zpush-import.py new file mode 100755 index 0000000..6e01272 --- /dev/null +++ b/tools/zpush-import.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Convert an existing Z-Push installation's configuration into ActivePush settings JSON. + +Usage: + python3 zpush-import.py /usr/share/z-push # source layout (config.php, backend/*/config.php) + python3 zpush-import.py /etc/z-push # Debian package layout (*.conf.php) + python3 zpush-import.py /var/www/z-push > zpush.json + active-push config apply zpush.json + +Only settings are converted. Device sync states are not portable: devices notice the new +server (unknown sync key / policy key) and resynchronise automatically. +""" +import configparser +import json +import pathlib +import re +import sys + +DEFINE = re.compile(r"""^\s*define\s*\(\s*['"]([A-Z0-9_]+)['"]\s*,\s*(.+?)\)\s*;""", re.S) + + +def php_value(raw): + raw = raw.strip() + if raw.startswith(("'", '"')) and raw.endswith(raw[0]): + return raw[1:-1].replace("\\'", "'").replace('\\"', '"') + low = raw.lower() + if low in ("true", "false"): + return low == "true" + if re.fullmatch(r"-?\d+", raw): + return int(raw) + if low.startswith("array(") or raw.startswith("["): + inner = raw[raw.index("(") + 1: raw.rindex(")")] if low.startswith("array(") else raw[1:-1] + out = {} + for k, v in re.findall(r"""['"]([^'"]+)['"]\s*=>\s*('[^']*'|"[^"]*"|[^,\)]+)""", inner): + out[k] = php_value(v) + return out + if low.startswith("serialize("): + return None + return {"__const__": raw} + + +def read_defines(paths): + defs = {} + for p in paths: + text = p.read_text(errors="replace") + # strip block comments and line comments + text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) + lines = [l for l in text.splitlines() if not l.strip().startswith(("//", "#"))] + joined = "\n".join(lines) + for m in re.finditer(r"define\s*\(.*?\)\s*;", joined, re.S): + d = DEFINE.match(m.group(0)) + if d: + defs[d.group(1)] = php_value(d.group(2)) + return defs + + +def find_configs(root): + root = pathlib.Path(root) + files = [] + for pat in ["config.php", "*.conf.php", "backend/*/config.php", "autodiscover/config.php", "src/config.php", "src/backend/*/config.php", "src/autodiscover/config.php"]: + files += [f for f in root.glob(pat) if f.is_file()] + policies = [f for f in [root / "policies.ini", root / "src/policies.ini"] if f.is_file()] + return sorted(set(files)), policies + + +FILTER = {"SYNC_FILTERTYPE_ALL": 0, "SYNC_FILTERTYPE_1DAY": 1, "SYNC_FILTERTYPE_3DAYS": 2, "SYNC_FILTERTYPE_1WEEK": 3, + "SYNC_FILTERTYPE_2WEEKS": 4, "SYNC_FILTERTYPE_1MONTH": 5, "SYNC_FILTERTYPE_3MONTHS": 6, "SYNC_FILTERTYPE_6MONTHS": 7} + +POLICY_KEYS = { + "devpwenabled": bool, "alphanumpwreq": bool, "devencenabled": bool, "attenabled": bool, "mindevpwlenngth": int, + "maxinacttimedevlock": int, "maxdevpwfailedattempts": int, "maxattsize": int, "allowsimpledevpw": bool, + "devpwexpiration": int, "devpwhistory": int, "allowstoragecard": bool, "allowcam": bool, "reqdevenc": bool, + "allowunsignedapps": bool, "mindevcomplexchars": int, "allowwifi": bool, "allowtextmessaging": bool, + "allowpopimapemail": bool, "allowbluetooth": int, "allowirda": bool, "reqmansyncroam": bool, "maxcalagefilter": int, + "allowhtmlemail": bool, "maxemailagefilter": int, "maxemailbodytruncsize": int, "maxemailhtmlbodytruncsize": int, + "reqsignedsmimemessages": bool, "reqencsmimemessages": bool, "reqsignedsmimealgorithm": int, + "reqencsmimealgorithm": int, "allowsmimeencalgneg": int, "allowsmimesoftcerts": bool, "allowbrowser": bool, + "allowconsumeremail": bool, "allowinternetsharing": bool, "allowremotedesktop": bool, "allowdesktopsync": bool, + "allowunsignedinstallationpackages": bool, "pwrecoveryenabled": bool, +} + + +def parse_policies(path): + cp = configparser.ConfigParser(interpolation=None, strict=False) + cp.read_string(path.read_text(errors="replace")) + out = [] + for sec in cp.sections(): + p = {"name": sec} + for k, typ in POLICY_KEYS.items(): + if k in cp[sec]: + v = cp[sec][k].strip().strip("'\"") + if v == "": + continue + try: + p[k] = (int(v) != 0) if typ is bool else int(v) + except ValueError: + pass + out.append(p) + return out + + +def main(): + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + sys.exit(2) + files, pol_files = find_configs(sys.argv[1]) + if not files: + sys.exit(f"no Z-Push configuration files found in {sys.argv[1]}") + d = read_defines(files) + notes = [f"read {f}" for f in files] + s = {"general": {}, "imap": {}, "smtp": {}, "caldav": {}, "carddav": {}, "sync": {}, "ping": {}, "provisioning": {}, "autodiscover": {}} + + if isinstance(d.get("TIMEZONE"), str) and d["TIMEZONE"]: + s["general"]["default_timezone"] = d["TIMEZONE"] + if "USE_FULLEMAIL_FOR_LOGIN" in d: + s["general"]["login_format"] = "as_given" if d["USE_FULLEMAIL_FOR_LOGIN"] else "local_part" + + # IMAP + if "IMAP_SERVER" in d: + s["imap"]["host"] = d["IMAP_SERVER"] + if "IMAP_PORT" in d: + s["imap"]["port"] = int(d["IMAP_PORT"]) + opts = str(d.get("IMAP_OPTIONS", "")).lower() + if opts: + if "/ssl" in opts: + s["imap"]["security"] = "tls" + elif "/tls" in opts: + s["imap"]["security"] = "start_tls" + elif "/notls" in opts: + s["imap"]["security"] = "none" + s["imap"]["verify_tls"] = "novalidate-cert" not in opts + for zk, ak in [("IMAP_FOLDER_PREFIX", "folder_prefix"), ("IMAP_FOLDER_INBOX", "folder_inbox"), ("IMAP_FOLDER_SENT", "folder_sent"), + ("IMAP_FOLDER_DRAFT", "folder_drafts"), ("IMAP_FOLDER_TRASH", "folder_trash"), ("IMAP_FOLDER_SPAM", "folder_spam"), + ("IMAP_FOLDER_ARCHIVE", "folder_archive")]: + if isinstance(d.get(zk), str): + s["imap"][ak] = d[zk] + if "IMAP_AUTOSEEN_ON_DELETE" in d: + s["imap"]["autoseen_on_delete"] = bool(d["IMAP_AUTOSEEN_ON_DELETE"]) + if "IMAP_INLINE_FORWARD" in d: + s["imap"]["inline_forward"] = bool(d["IMAP_INLINE_FORWARD"]) + if isinstance(d.get("IMAP_EXCLUDED_FOLDERS"), str) and d["IMAP_EXCLUDED_FOLDERS"]: + s["imap"]["excluded_folders"] = [x for x in d["IMAP_EXCLUDED_FOLDERS"].split("|") if x] + df = d.get("IMAP_DEFAULTFROM") + if isinstance(df, str): + if df.startswith("@"): + s["general"]["email_template"] = "{local}" + df + elif df == "domain": + s["general"]["email_template"] = "{local}@{domain}" + elif df in ("", "username"): + s["general"]["email_template"] = "{login}" + elif df in ("sql", "ldap"): + notes.append("IMAP_DEFAULTFROM sql/ldap is not supported; using {login} - adjust email_template") + + # SMTP + method = str(d.get("IMAP_SMTP_METHOD", "")).lower() + if method in ("mail", "sendmail"): + s["smtp"]["method"] = "sendmail" + notes.append("SMTP method 'mail'/'sendmail' -> sendmail (consider SMTP submission on port 587 instead)") + elif method == "smtp": + s["smtp"]["method"] = "smtp" + params = d.get("IMAP_SMTP_PARAMS") if isinstance(d.get("IMAP_SMTP_PARAMS"), dict) else {} + host = str(params.get("host", "localhost")) + if host.startswith("ssl://") or host.startswith("tls://"): + s["smtp"]["security"] = "tls" + host = host.split("://", 1)[1] + s["smtp"]["host"] = host + s["smtp"]["port"] = int(params.get("port", 25)) + if s["smtp"]["port"] == 587: + s["smtp"]["security"] = "start_tls" + if params.get("auth"): + if params.get("username") == "imap_username": + s["smtp"]["auth"] = "user_credentials" + else: + s["smtp"]["auth"] = "static" + s["smtp"]["username"] = str(params.get("username", "")) + s["smtp"]["password"] = str(params.get("password", "")) + else: + s["smtp"]["auth"] = "none" + if params.get("verify_peer") is False: + s["smtp"]["verify_tls"] = False + + # CalDAV / CardDAV + def dav(prefix, key): + server = d.get(f"{prefix}_SERVER") + if not isinstance(server, str) or server in ("", "caldavserver.domain.com"): + return + proto = d.get(f"{prefix}_PROTOCOL", "https") + port = d.get(f"{prefix}_PORT", 443 if proto == "https" else 80) + default_port = (proto == "https" and str(port) == "443") or (proto == "http" and str(port) == "80") + s[key]["enabled"] = True + s[key]["base_url"] = f"{proto}://{server}" + ("" if default_port else f":{port}") + if isinstance(d.get(f"{prefix}_PATH"), str): + s[key]["path"] = d[f"{prefix}_PATH"] + if f"{prefix}_PATH_INCLUDE" in d and isinstance(d[f"{prefix}_PATH_INCLUDE"], str): + s[key]["include_regex"] = d[f"{prefix}_PATH_INCLUDE"].strip("/") + if f"{prefix}_PATH_EXCLUDE" in d and isinstance(d[f"{prefix}_PATH_EXCLUDE"], str): + s[key]["exclude_regex"] = d[f"{prefix}_PATH_EXCLUDE"].strip("/") + ssl = d.get("DAV_SSL_VERIFY") + if isinstance(ssl, dict) and "NONE" in ssl.get("__const__", ""): + s[key]["verify_tls"] = False + dav("CALDAV", "caldav") + dav("CARDDAV", "carddav") + if isinstance(d.get("CARDDAV_GAL_PATH"), str): + s["carddav"]["gal_path"] = d["CARDDAV_GAL_PATH"] + if isinstance(d.get("CARDDAV_GAL_MIN_LENGTH"), int): + s["carddav"]["gal_min_length"] = d["CARDDAV_GAL_MIN_LENGTH"] + if isinstance(d.get("CARDDAV_DEFAULT_PATH"), str): + s["carddav"]["default_collection"] = d["CARDDAV_DEFAULT_PATH"].rstrip("/").rsplit("/", 1)[-1] + + # sync / ping / provisioning + if isinstance(d.get("SYNC_MAX_ITEMS"), int): + s["sync"]["max_items"] = d["SYNC_MAX_ITEMS"] + ft = d.get("SYNC_FILTERTIME_MAX") + if isinstance(ft, dict) and ft.get("__const__") in FILTER: + s["sync"]["filter_time_max"] = FILTER[ft["__const__"]] + sc = d.get("SYNC_CONFLICT_DEFAULT") + if isinstance(sc, dict): + s["sync"]["server_wins"] = "OVERWRITE_PIM" in sc["__const__"] + for zk, sec, ak in [("PING_INTERVAL", "ping", "check_interval_secs"), ("PING_LOWER_BOUND_LIFETIME", "ping", "lifetime_min"), + ("PING_HIGHER_BOUND_LIFETIME", "ping", "lifetime_max"), ("RETRY_AFTER_DELAY", "sync", "retry_after_secs"), + ("SEARCH_WAIT", "sync", "search_wait_secs"), ("SEARCH_MAXRESULTS", "sync", "search_max_results")]: + if isinstance(d.get(zk), int) and not isinstance(d.get(zk), bool): + s[sec][ak] = d[zk] + if isinstance(d.get("SYNC_CONTACTS_MAXPICTURESIZE"), int): + s["carddav"]["max_picture_kb"] = max(1, d["SYNC_CONTACTS_MAXPICTURESIZE"] // 1024) + if "PROVISIONING" in d: + s["provisioning"]["enabled"] = bool(d["PROVISIONING"]) + if "LOOSE_PROVISIONING" in d: + s["provisioning"]["loose"] = bool(d["LOOSE_PROVISIONING"]) + if pol_files: + pols = parse_policies(pol_files[0]) + if pols: + s["provisioning"]["policies"] = pols + s["provisioning"]["default_policy"] = "default" if any(p["name"] == "default" for p in pols) else pols[0]["name"] + notes.append(f"imported {len(pols)} polic{'y' if len(pols) == 1 else 'ies'} from {pol_files[0]}") + if isinstance(d.get("ZPUSH_HOST"), str) and d["ZPUSH_HOST"]: + s["autodiscover"]["server_url"] = f"https://{d['ZPUSH_HOST']}/Microsoft-Server-ActiveSync" + + s = {k: v for k, v in s.items() if v} + for n in notes: + print(f"# {n}", file=sys.stderr) + print(json.dumps(s, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..43f3ff4 --- /dev/null +++ b/web/app.js @@ -0,0 +1,931 @@ +'use strict'; +(() => { + // ------------------------------------------------------------------ basics + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); + const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); + const store = { + get(k, d) { try { return localStorage.getItem(k) ?? d; } catch { return d; } }, + set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }, + }; + let LANG = store.get('ap.lang', 'en'); + const T = (pl, en) => (LANG === 'pl' ? pl : en); + + const S = { session: null, csrf: '', settings: null, timezones: [], timers: [], sse: null, page: 'overview', devices: [] }; + + function clearTimers() { + S.timers.forEach((t) => clearInterval(t)); + S.timers = []; + if (S.sse) { S.sse.close(); S.sse = null; } + } + function every(ms, fn) { fn(); S.timers.push(setInterval(fn, ms)); } + + // ------------------------------------------------------------------ formatting + const fmtNum = (n) => (n == null ? '-' : Number(n).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-US')); + function fmtBytes(b) { + b = Number(b || 0); + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } + return `${b.toFixed(b < 10 && i ? 1 : 0)} ${u[i]}`; + } + function fmtDur(s) { + s = Math.max(0, Math.floor(s)); + const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); + if (d) return `${d}d ${h}h`; + if (h) return `${h}h ${m}m`; + if (m) return `${m}m ${s % 60}s`; + return `${s}s`; + } + const fmtTs = (t) => (t ? new Date(t * 1000).toLocaleString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-'); + const fmtTime = (t) => (t ? new Date(t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB') : '-'); + function ago(t) { + if (!t) return T('nigdy', 'never'); + const s = Math.floor(Date.now() / 1000) - t; + if (s < 60) return T(`${s}s temu`, `${s}s ago`); + return T(`${fmtDur(s)} temu`, `${fmtDur(s)} ago`); + } + + // ------------------------------------------------------------------ api + async function api(method, path, body) { + const opt = { method, credentials: 'same-origin', headers: {} }; + if (method !== 'GET') opt.headers['X-CSRF-Token'] = S.csrf; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + const r = await fetch(path, opt); + if (r.status === 401 && !path.startsWith('/api/login') && !path.startsWith('/api/setup')) { + showAuth(); + throw new Error(T('Sesja wygasła', 'Session expired')); + } + const ct = r.headers.get('content-type') || ''; + const data = ct.includes('json') ? await r.json() : await r.text(); + if (!r.ok) throw new Error((data && data.error) || `HTTP ${r.status}`); + return data; + } + + let toastT; + function toast(msg, error) { + const t = $('#toast'); + t.textContent = msg; + t.classList.toggle('error', !!error); + t.hidden = false; + clearTimeout(toastT); + toastT = setTimeout(() => { t.hidden = true; }, error ? 6000 : 3000); + } + + function applyWidths(root) { + $$('[data-w]', root).forEach((el) => { el.style.width = `${Math.max(0, Math.min(100, Number(el.dataset.w)))}%`; }); + } + + // ------------------------------------------------------------------ charts (SVG) + const NS = 'http://www.w3.org/2000/svg'; + function niceMax(v) { + if (v <= 0) return 1; + const p = 10 ** Math.floor(Math.log10(v)); + for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p; + return 10 * p; + } + + /** Line/area chart. series: [{name, cls:'k1', values:[...] }], labels: [string] */ + function lineChart(el, { labels, series, height = 170, area = true, fmt = fmtNum, bars = false, min = 1 }) { + const W = Math.max(el.clientWidth || 600, 280), H = height, L = 46, R = 22, Tp = 8, B = 20; + const n = labels.length; + const max = niceMax(Math.max(min, ...series.flatMap((s) => s.values))); + const slot = (W - L - R) / Math.max(1, n); + const x = bars ? (i) => L + slot * (i + 0.5) : (i) => L + (n <= 1 ? 0 : (i * (W - L - R)) / (n - 1)); + const y = (v) => Tp + (H - Tp - B) * (1 - v / max); + let svg = ``; + for (let g = 0; g <= 4; g++) { + const v = (max * g) / 4, yy = y(v); + svg += `${esc(fmt(Math.round(v * 100) / 100))}`; + } + const step = Math.max(1, Math.ceil(n / 8)); + for (let i = 0; i < n; i += step) { + const anchor = !bars && n > 1 && i === 0 ? 'start' : (x(i) > W - R - 30 ? 'end' : 'middle'); + svg += `${esc(labels[i])}`; + } + if (bars) { + const bw = Math.min(26, Math.max(1, (slot * 0.7) / series.length)); + series.forEach((s, si) => { + s.values.forEach((v, i) => { + const xx = x(i) - (bw * series.length) / 2 + si * bw; + svg += ``; + }); + }); + } else { + series.forEach((s) => { + const pts = s.values.map((v, i) => `${x(i)},${y(v)}`).join(' '); + if (area && n > 1) svg += ``; + svg += ``; + }); + } + svg += ``; + el.innerHTML = `
${svg}
+
${series.map((s) => `${esc(s.name)}`).join('')}
`; + const wrap = $('.chart-wrap', el), tip = $('.chart-tip', el), cur = $('#cur', el), svgEl = $('svg', el); + wrap.addEventListener('mousemove', (e) => { + const rect = svgEl.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * W; + const i = Math.max(0, Math.min(n - 1, bars ? Math.floor((px - L) / slot) : Math.round(((px - L) / (W - L - R)) * (n - 1)))); + cur.setAttribute('x1', x(i)); cur.setAttribute('x2', x(i)); cur.setAttribute('visibility', 'visible'); + tip.innerHTML = `${esc(labels[i])}
${series.map((s) => `${esc(s.name)}: ${esc(fmt(s.values[i]))}`).join('
')}`; + tip.hidden = false; + const left = Math.min(e.clientX - rect.left + 12, rect.width - tip.offsetWidth - 4); + tip.style.left = `${Math.max(0, left)}px`; + tip.style.top = '6px'; + }); + wrap.addEventListener('mouseleave', () => { tip.hidden = true; cur.setAttribute('visibility', 'hidden'); }); + } + + function spark(values, cls = 'k1') { + const n = values.length; + if (n < 2) return ''; + const max = Math.max(1, ...values); + const pts = values.map((v, i) => `${(i * 100) / (n - 1)},${30 - (v / max) * 28}`).join(' '); + return ``; + } + + function donut(el, items, center) { + const total = items.reduce((a, b) => a + b.value, 0) || 1; + const r = 48, C = 2 * Math.PI * r; + let off = 0, arcs = ''; + items.forEach((it, i) => { + const len = (it.value / total) * C; + arcs += `${esc(it.label)}: ${it.value}`; + off += len; + }); + el.innerHTML = `
${arcs}${esc(center ?? total)} +
${items.map((it, i) => `
${esc(it.label)} - ${fmtNum(it.value)}
`).join('') || `
${T('Brak danych', 'No data')}
`}
`; + } + + function hbars(items, cls = 'k1') { + const max = Math.max(1, ...items.map((i) => i.value)); + return items.map((i) => `
${esc(i.label)}
${fmtNum(i.value)}
`).join('') + || `
${T('Brak danych', 'No data')}
`; + } + + // ------------------------------------------------------------------ modal + function modal(html, cls = '') { + const root = $('#modal-root'); + root.innerHTML = `
`; + const bd = $('.backdrop', root); + bd.addEventListener('mousedown', (e) => { if (e.target === bd) closeModal(); }); + $$('[data-close]', root).forEach((b) => b.addEventListener('click', closeModal)); + document.addEventListener('keydown', escClose); + applyWidths(root); + return $('.dialog', root); + } + function escClose(e) { if (e.key === 'Escape') closeModal(); } + function closeModal() { $('#modal-root').innerHTML = ''; document.removeEventListener('keydown', escClose); } + + function confirmBox(title, text, danger, typed) { + return new Promise((resolve) => { + const d = modal(`

${esc(title)}

+

${esc(text)}

+ ${typed ? `` : ''} +
`, 'small'); + $('#cf-ok', d).addEventListener('click', () => { + if (typed && $('#cf-typed', d).value.trim() !== typed) { toast(T('Wpisany tekst się nie zgadza', 'Text does not match'), true); return; } + closeModal(); resolve(true); + }); + $$('[data-close]', d).forEach((b) => b.addEventListener('click', () => resolve(false))); + }); + } + + // ------------------------------------------------------------------ navigation + const NAV = [ + ['overview', T('Przegląd', 'Overview')], + ['live', T('Na żywo', 'Live')], + ['devices', T('Urządzenia', 'Devices')], + ['users', T('Skrzynki', 'Mailboxes')], + ['backends', T('Backendy', 'Backends')], + ['activesync', 'ActiveSync'], + ['policies', T('Polityki', 'Policies')], + ['security', T('Bezpieczeństwo', 'Security')], + ['logs', T('Logi i audyt', 'Logs & audit')], + ['about', T('O programie', 'About')], + ]; + const navLabel = (id) => (NAV.find((n) => n[0] === id) || NAV[0])[1]; + + function renderNav() { + $('#nav').innerHTML = NAV.map((n, i) => ``).join(''); + $$('#nav .nav').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.page; $('#nav').classList.remove('open'); })); + } + + function go() { + const id = (location.hash || '#overview').slice(1).split('/')[0]; + S.page = PAGES[id] ? id : 'overview'; + clearTimers(); + closeModal(); + renderNav(); + $('#page-label').textContent = navLabel(S.page); + const v = $('#view'); + v.innerHTML = ''; + PAGES[S.page](v).catch((e) => { v.innerHTML = `
${esc(e.message)}
`; }); + } + + function head(eyebrow, title, desc, actions = '') { + return `
${esc(eyebrow)}

${esc(title)}

${desc ? `

${esc(desc)}

` : ''}
${actions}
`; + } + + // ------------------------------------------------------------------ pages + const PAGES = {}; + + PAGES.overview = async (v) => { + v.innerHTML = head(T('PANEL STEROWANIA', 'CONTROL ROOM'), T('Przegląd', 'Overview'), T('Ruch ActiveSync, aktywne połączenia push, urządzenia i stan backendów.', 'ActiveSync traffic, push connections, devices and backend state.'), ``) + + `
+
+
${T('ŻĄDANIA / MIN', 'REQUESTS / MIN')}

${T('Ruch', 'Traffic')}

+
${T('ELEMENTY / MIN', 'ITEMS / MIN')}

${T('Synchronizacja danych', 'Data synchronisation')}

+
${T('TRANSFER / MIN', 'TRANSFER / MIN')}

${T('Dane', 'Data')}

+
${T('OSTATNIE 7 DNI', 'LAST 7 DAYS')}

${T('Żądania na godzinę', 'Requests per hour')}

+
+
+
${T('OD STARTU', 'SINCE START')}

${T('Komendy', 'Commands')}

+
${T('URZĄDZENIA', 'DEVICES')}

${T('Typy urządzeń', 'Device types')}

+
RUNTIME

${T('Serwer', 'Server')}

+
`; + const rangeSel = $('#range', v); + rangeSel.value = store.get('ap.range', '60'); + rangeSel.addEventListener('change', () => { store.set('ap.range', rangeSel.value); refresh(); }); + async function refresh() { + const [o, ts] = await Promise.all([api('GET', '/api/overview'), api('GET', `/api/timeseries?minutes=${rangeSel.value}`)]); + const lab = ts.map((b) => new Date(b.t * 1000).toLocaleTimeString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { hour: '2-digit', minute: '2-digit' })); + const last = ts.slice(-15); + const rpm = last.length ? Math.round(last.reduce((a, b) => a + b.requests, 0) / last.length * 10) / 10 : 0; + const errRate = o.requests ? ((o.errors / o.requests) * 100).toFixed(2) : '0.00'; + const tile = (label, value, sub, cls = '', sp = '') => `
${esc(label)}
${value}
${sub}
${sp}
`; + $('#tiles').innerHTML = [ + tile(T('Żądania / min', 'Requests / min'), esc(rpm), T(`łącznie ${fmtNum(o.requests)}`, `total ${fmtNum(o.requests)}`), 'accent', spark(ts.map((b) => b.requests))), + tile(T('Połączenia push', 'Push connections'), fmtNum(o.pings), T(`${o.active_requests} aktywnych żądań`, `${o.active_requests} active requests`), 'good', spark(ts.map((b) => b.pings), 'k2')), + tile(T('Urządzenia', 'Devices'), `${fmtNum(o.devices_24h)}/ ${fmtNum(o.devices_total)}`, T(`aktywne 24h / wszystkie · ${fmtNum(o.partnerships_total)} partnerstw`, `active 24h / total · ${fmtNum(o.partnerships_total)} partnerships`)), + tile(T('Skrzynki', 'Mailboxes'), `${fmtNum(o.users_active_15m)}/ ${fmtNum(o.users_total)}`, T('aktywne 15 min / wszystkie', 'active 15 min / total')), + tile(T('Wysłane maile', 'Mails sent'), fmtNum(o.mails_sent), T(`elementy ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`, `items ↓${fmtNum(o.items_out)} ↑${fmtNum(o.items_in)}`), '', spark(ts.map((b) => b.mails_sent), 'k3')), + tile(T('Błędy', 'Errors'), fmtNum(o.errors), `${errRate}% ${T('żądań', 'of requests')}`, o.errors ? 'bad' : '', spark(ts.map((b) => b.errors), 'k5')), + tile(T('Nieudane logowania', 'Failed logins'), fmtNum(o.auth_failures), T(`${o.lockouts} blokad aktywnych`, `${o.lockouts} active lockouts`), o.lockouts ? 'bad' : '', spark(ts.map((b) => b.auth_failures), 'k5')), + tile(T('Transfer', 'Transfer'), fmtBytes(o.bytes_out), `↑ ${fmtBytes(o.bytes_in)}`, '', spark(ts.map((b) => b.bytes_out), 'k4')), + ].join(''); + if (o.devices_pending || o.wipes_pending) { + $('#tiles').insertAdjacentHTML('afterbegin', `
${o.devices_pending ? `${o.devices_pending} ${T('urządzeń czeka na zatwierdzenie', 'devices await approval')}` : ''}${o.wipes_pending ? `${o.wipes_pending} ${T('zdalnych wymazań w toku', 'remote wipes pending')}` : ''}
`); + } + lineChart($('#ch-req'), { labels: lab, series: [ + { name: T('żądania', 'requests'), cls: 'k1', values: ts.map((b) => b.requests) }, + { name: 'ping', cls: 'k2', values: ts.map((b) => b.pings) }, + { name: T('błędy', 'errors'), cls: 'k5', values: ts.map((b) => b.errors) }, + ] }); + lineChart($('#ch-items'), { labels: lab, bars: true, series: [ + { name: T('do urządzeń', 'to devices'), cls: 'k3', values: ts.map((b) => b.items_out) }, + { name: T('z urządzeń', 'from devices'), cls: 'k4', values: ts.map((b) => b.items_in) }, + ] }); + lineChart($('#ch-bytes'), { labels: lab, fmt: fmtBytes, min: 1024, series: [ + { name: T('wysłane', 'sent'), cls: 'k4', values: ts.map((b) => b.bytes_out) }, + { name: T('odebrane', 'received'), cls: 'k6', values: ts.map((b) => b.bytes_in) }, + ] }); + $('#cmds').innerHTML = hbars(o.commands.slice(0, 12).map(([k, s]) => ({ label: k, value: s.count }))); + applyWidths($('#cmds')); + donut($('#types'), o.device_types.map(([t, n]) => ({ label: t, value: n })), o.devices_total); + $('#runtime').innerHTML = [ + [T('Wersja', 'Version'), o.version], + ['Uptime', fmtDur(o.uptime)], + [T('Pamięć RSS', 'Memory RSS'), fmtBytes(o.rss)], + [T('Pula IMAP', 'IMAP pool'), o.imap_pool], + [T('Baza danych', 'Database'), `${fmtBytes(o.db_bytes)} + WAL ${fmtBytes(o.wal_bytes)}`], + ['IMAP', o.backends.imap], + ['SMTP', o.backends.smtp], + ['CalDAV', o.backends.caldav || T('wyłączony', 'disabled')], + ['CardDAV', o.backends.carddav || T('wyłączony', 'disabled')], + ['Provisioning', o.provisioning ? T('włączony', 'enabled') : T('wyłączony', 'disabled')], + ['Autodiscover', fmtNum(o.autodiscover)], + ].map(([k, val]) => `
${esc(k)}
${esc(val)}
`).join(''); + $('#push-count').textContent = `PUSH ${o.pings}`; + } + async function hourly() { + const h = await api('GET', '/api/stats/hourly?hours=168'); + const lab = h.map((b) => { const d = new Date(b.t * 1000); return `${d.toLocaleDateString(LANG === 'pl' ? 'pl-PL' : 'en-GB', { weekday: 'short' })} ${String(d.getHours()).padStart(2, '0')}:00`; }); + lineChart($('#ch-hourly'), { labels: lab, bars: true, series: [ + { name: T('żądania', 'requests'), cls: 'k1', values: h.map((b) => b.requests) }, + { name: T('błędy', 'errors'), cls: 'k5', values: h.map((b) => b.errors) }, + ] }); + } + every(5000, () => refresh().catch(() => {})); + every(300000, () => hourly().catch(() => {})); + }; + + PAGES.live = async (v) => { + v.innerHTML = head(T('NA ŻYWO', 'LIVE'), T('Ruch na żywo', 'Live traffic'), T('Trwające żądania, połączenia push (Ping / hanging Sync) i strumień zakończonych żądań.', 'In-flight requests, push connections (Ping / hanging Sync) and a stream of finished requests.'), ``) + + `
+
PUSH

${T('Aktywne połączenia push', 'Active push connections')}

0
+
IN-FLIGHT

${T('Trwające żądania', 'Requests in progress')}

0
+
+
STREAM

${T('Zakończone żądania', 'Finished requests')}

+
+
`; + let paused = false; + $('#pause', v).addEventListener('click', (e) => { paused = !paused; e.target.textContent = paused ? T('Wznów', 'Resume') : T('Pauza', 'Pause'); }); + const filter = $('#feed-filter', v); + const feed = $('#feed', v); + const row = (e, fresh) => `
+ ${esc(fmtTime(e.ts))}${esc(e.user)} + ${esc(e.command)} ${esc(e.device_type)} ${esc(e.http)} ${esc(e.status)}${e.items_out || e.items_in ? ` ↓${e.items_out} ↑${e.items_in}` : ''} + ${e.command === 'Ping' ? fmtDur(e.ms / 1000) : `${e.ms} ms`}${fmtBytes(e.bytes_out)}
`; + const applyFilter = () => { + const q = filter.value.trim().toLowerCase(); + $$('.feed-row', feed).forEach((r) => { r.hidden = q && !r.dataset.s.includes(q); }); + }; + filter.addEventListener('input', applyFilter); + const data = await api('GET', '/api/live'); + feed.innerHTML = data.recent.map((e) => row(e, false)).join('') || `
${T('Brak żądań', 'No requests yet')}
`; + S.sse = new EventSource('/api/events'); + S.sse.addEventListener('request', (m) => { + if (paused) return; + const e = JSON.parse(m.data); + $('.empty', feed)?.remove(); + feed.insertAdjacentHTML('afterbegin', row(e, true)); + while (feed.children.length > 400) feed.lastElementChild.remove(); + applyFilter(); + }); + every(2000, async () => { + const d = await api('GET', '/api/live').catch(() => null); + if (!d) return; + $('#ping-n').textContent = d.pings.length; + $('#act-n').textContent = d.active.length; + $('#pings').innerHTML = d.pings.length ? `${d.pings.sort((a, b) => a.started - b.started).map((p) => { + const el = d.now - p.started, pct = Math.min(100, (el / Math.max(1, p.lifetime)) * 100); + return ``; + }).join('')}
${T('Użytkownik', 'User')}${T('Urządzenie', 'Device')}${T('Typ', 'Kind')}${T('Foldery', 'Folders')}${T('Heartbeat', 'Heartbeat')}
${esc(p.user)}${esc(p.device_type)}${esc(p.device_id)}${esc(p.kind)}${p.folders}${fmtDur(el)} / ${fmtDur(p.lifetime)}
` : `
${T('Brak połączeń push', 'No push connections')}
`; + $('#active').innerHTML = d.active.length ? `${d.active.map((a) => ``).join('')}
${T('Komenda', 'Command')}${T('Użytkownik', 'User')}${T('Urządzenie', 'Device')}IP${T('Czas', 'Time')}
${esc(a.command)}${esc(a.user)}${esc(a.device_type)}AS ${esc(a.version)}${esc(a.ip)}${fmtDur(d.now - a.started)}
` : `
${T('Brak', 'None')}
`; + applyWidths(v); + }); + }; + + function deviceState(d) { + const p = []; + if (d.pinging) p.push(`PUSH`); + if (d.blocked) p.push(`${T('ZABLOKOWANE', 'BLOCKED')}`); + if (!d.approved) p.push(`${T('DO ZATWIERDZENIA', 'PENDING')}`); + if (d.wipe_status === 2 || d.wipe_status === 4) p.push(`WIPE ${d.wipe_status === 4 ? T('WYSŁANY', 'SENT') : T('CZEKA', 'PENDING')}`); + if (d.wipe_status === 8) p.push(`${T('WYMAZANE', 'WIPED')}`); + if (!p.length) p.push(`OK`); + return p.join(' '); + } + + PAGES.devices = async (v) => { + v.innerHTML = head(T('PARTNERSTWA', 'PARTNERSHIPS'), T('Urządzenia', 'Devices'), T('Jeden wiersz to jedno fizyczne urządzenie; skrzynki skonfigurowane na nim są widoczne jako osobne partnerstwa. Kliknij skrzynkę, aby zobaczyć foldery, historię i akcje (wymazanie, resynchronizacja, blokada).', 'One row per physical device; each mailbox configured on it is a separate partnership. Click a mailbox for folders, history and actions (wipe, resync, block).')) + + `
+ +
`; + const q = $('#dev-q', v), f = $('#dev-f', v); + q.value = store.get('ap.devq', ''); + const draw = () => { + store.set('ap.devq', q.value); + const s = q.value.trim().toLowerCase(), now = Date.now() / 1000; + const list = S.devices.filter((d) => { + const hay = `${d.username} ${d.device_id} ${d.device_type} ${d.model} ${d.friendly_name} ${d.os} ${d.user_agent} ${d.last_ip}`.toLowerCase(); + if (s && !hay.includes(s)) return false; + if (f.value === 'push') return d.pinging; + if (f.value === 'pending') return !d.approved; + if (f.value === 'blocked') return d.blocked; + if (f.value === 'stale') return now - d.last_seen > 30 * 86400; + return true; + }); + // one row per physical device (DeviceId); every mailbox on it is a partnership chip + const groups = new Map(); + list.forEach((d) => { + if (!groups.has(d.device_id)) groups.set(d.device_id, []); + groups.get(d.device_id).push(d); + }); + const rows = Array.from(groups.values()).map((g) => g.sort((a, b2) => b2.last_seen - a.last_seen)) + .sort((a, b2) => b2[0].last_seen - a[0].last_seen); + const physical = new Set(S.devices.map((d) => d.device_id)).size; + $('#dev-n').textContent = T(`${rows.length} / ${physical} urządzeń · ${list.length} partnerstw`, `${rows.length} / ${physical} devices · ${list.length} partnerships`); + $('#dev-table').innerHTML = rows.length ? `${rows.map((g) => { + const d = g[0]; + const merged = { ...d, pinging: g.some((x) => x.pinging), blocked: g.some((x) => x.blocked), approved: g.every((x) => x.approved), wipe_status: Math.max(...g.map((x) => x.wipe_status)) }; + return ` + + + + `; + }).join('')}
${T('Urządzenie', 'Device')}${T('Skrzynki', 'Mailboxes')}OSAS${T('Ostatnio', 'Last seen')}IP${T('Żądania', 'Requests')}${T('Stan', 'State')}
${esc(d.friendly_name || d.model || d.device_type)}${esc(d.device_type)} · ${esc(d.device_id)}${g.map((x) => ``).join('')}${esc(d.os || '-')}${esc(d.as_version || '-')}${esc(ago(d.last_seen))}${esc(d.last_command)}${esc(d.last_ip)}${fmtNum(g.reduce((a, x) => a + x.requests, 0))}${deviceState(merged)}
` : `
${T('Brak urządzeń', 'No devices')}
`; + $$('.chip[data-pid]', v).forEach((c) => c.addEventListener('click', (e) => { e.stopPropagation(); deviceDialog(Number(c.dataset.pid), load); })); + $$('tr.clickable', v).forEach((tr) => tr.addEventListener('click', () => deviceDialog(Number(tr.dataset.id), load))); + }; + async function load() { + S.devices = await api('GET', '/api/devices'); + const nc = $('#nav-dev-count'); + if (nc) nc.textContent = new Set(S.devices.map((d) => d.device_id)).size; + draw(); + } + q.addEventListener('input', draw); + f.addEventListener('change', draw); + every(10000, () => load().catch(() => {})); + }; + + async function deviceDialog(id, reload) { + const d = await api('GET', `/api/devices/${id}`); + const kv = [ + [T('Użytkownik', 'User'), d.username], ['Device ID', d.device_id], [T('Typ', 'Type'), d.device_type], [T('Model', 'Model'), d.model], + [T('Nazwa', 'Friendly name'), d.friendly_name], ['OS', `${d.os} ${d.os_language}`], ['IMEI', d.imei], [T('Telefon', 'Phone'), d.phone_number], + [T('Operator', 'Operator'), d.mobile_operator], ['User-Agent', d.user_agent], ['ActiveSync', d.as_version], + [T('Polityka', 'Policy'), `${d.policy_name || '-'} (key ${d.policy_key || 0})`], [T('Pierwszy raz', 'First seen'), fmtTs(d.first_seen)], + [T('Ostatnio', 'Last seen'), `${fmtTs(d.last_seen)} · ${d.last_command} · ${d.last_ip}`], [T('Żądania', 'Requests'), fmtNum(d.requests)], + [T('Ping', 'Ping'), d.ping_lifetime ? `${d.ping_lifetime}s` : '-'], + ['Wipe', d.wipe_requested_at ? `${T('zlecony', 'requested')} ${fmtTs(d.wipe_requested_at)} ${T('przez', 'by')} ${d.wipe_requested_by || ''}${d.wipe_completed_at ? ` · ${T('potwierdzony', 'acknowledged')} ${fmtTs(d.wipe_completed_at)}` : ''}` : '-'], + ].filter(([, val]) => val && String(val).trim()); + const folders = d.folders.length ? `${d.folders.map((f) => ``).join('')}
${T('Folder', 'Folder')}${T('Klasa', 'Class')}${T('Elementy', 'Items')}↓↑${T('Ostatnia sync.', 'Last sync')}${T('Okno', 'Window')}${T('Pętle', 'Loops')}
${esc(f.folder_name)}id ${esc(f.collection)}${esc(f.folder_class)}${fmtNum(f.item_count)}${fmtNum(f.items_out)}${fmtNum(f.items_in)}${esc(ago(f.last_sync))}${f.window_size}${f.loop_hits ? `${f.loop_hits}` : 0}
` : `
${T('Brak zsynchronizowanych folderów', 'No synchronised folders')}
`; + const recent = d.recent.length ? `
${d.recent.map((e) => `
${esc(fmtTime(e.ts))}${esc(e.ip)}${esc(e.command)} ${e.http} ${esc(e.status)}${e.ms} ms${fmtBytes(e.bytes_out)}
`).join('')}
` : `
-
`; + const btn = (act, label, cls = 'outline') => ``; + const dlg = modal(`
${T('URZĄDZENIE', 'DEVICE')} #${d.id}

${esc(d.friendly_name || d.model || d.device_type)} - ${esc(d.username)}

${deviceState(d)}
+
+ ${d.approved ? '' : btn('approve', T('Zatwierdź', 'Approve'), 'primary')} + ${btn('resync', T('Resynchronizuj wszystko', 'Full resync'))} + ${btn('reprovision', T('Wymuś provisioning', 'Force re-provision'))} + ${d.blocked ? btn('unblock', T('Odblokuj', 'Unblock')) : btn('block', T('Zablokuj', 'Block'), 'danger')} + ${d.wipe_status === 2 || d.wipe_status === 4 ? btn('cancel-wipe', T('Anuluj wymazanie', 'Cancel wipe')) : btn('wipe', T('Zdalnie wymaż urządzenie', 'Remote wipe device'), 'danger')} + ${d.has_recovery_password ? btn('recovery-password', T('Hasło odzyskiwania', 'Recovery password'), 'quiet') : ''} + ${btn('remove', T('Usuń partnerstwo', 'Remove partnership'), 'danger')} +
+

${T('Szczegóły', 'Details')}

${kv.map(([k, val]) => `
${esc(k)}
${esc(val)}
`).join('')}
+

${T('Notatki', 'Notes')}

+

${T('Ostatnie żądania', 'Recent requests')}

${recent}
+

${T('Foldery', 'Folders')}

${folders}
`); + $$('[data-act]', dlg).forEach((b) => b.addEventListener('click', async () => { + const act = b.dataset.act; + if (act === 'wipe' && !(await confirmBox(T('Zdalne wymazanie', 'Remote wipe'), T('Urządzenie przy następnym połączeniu wykona reset do ustawień fabrycznych i usunie WSZYSTKIE dane. Tej operacji nie można cofnąć po jej wykonaniu przez urządzenie.', 'On its next connection the device performs a factory reset and erases ALL data. This cannot be undone once the device executes it.'), true, d.device_id))) return; + if (act === 'remove' && !(await confirmBox(T('Usunąć partnerstwo?', 'Remove partnership?'), T('Stany synchronizacji zostaną usunięte; urządzenie zsynchronizuje się od nowa przy następnym połączeniu.', 'Sync states are removed; the device will resync from scratch on its next connection.'), true))) return; + if (act === 'block' && !(await confirmBox(T('Zablokować urządzenie?', 'Block device?'), T('Urządzenie otrzyma HTTP 403 przy każdej próbie synchronizacji.', 'The device receives HTTP 403 on every sync attempt.'), true))) return; + try { + if (act === 'recovery-password') { + const r = await api('POST', `/api/devices/${id}/recovery-password`); + toast(`${T('Hasło odzyskiwania', 'Recovery password')}: ${r.password}`); + return; + } + const body = act === 'notes' ? { notes: $('#dev-notes', dlg).value } : undefined; + await api('POST', `/api/devices/${id}/${act}`, body); + toast(T('Zapisano', 'Done')); + if (act === 'remove') { closeModal(); } else if (act !== 'notes') { deviceDialog(id, reload); } + reload && reload(); + } catch (e) { toast(e.message, true); } + })); + } + + PAGES.users = async (v) => { + v.innerHTML = head(T('KONTA', 'ACCOUNTS'), T('Skrzynki', 'Mailboxes'), T('Skrzynki (loginy) synchronizowane przez ActiveSync. Jedna osoba może mieć kilka skrzynek na tym samym telefonie - kolumna "Na tym samym urządzeniu" je łączy. Blokada działa natychmiast na wszystkich urządzeniach.', 'Mailboxes (logins) syncing through ActiveSync. One person may have several mailboxes on the same phone - the "Same device as" column links them. Blocking applies immediately on every device.')) + + `
+
${T('OCHRONA', 'PROTECTION')}

${T('Aktywne blokady logowania', 'Active login lockouts')}

`; + let users = []; + const draw = () => { + const s = $('#u-q', v).value.trim().toLowerCase(); + const list = users.filter((u) => !s || u.username.includes(s)); + $('#u-table').innerHTML = list.length ? `${list.map((u) => ` + + + + `).join('')}
${T('Skrzynka', 'Mailbox')}${T('Urządzenia', 'Devices')}${T('Na tym samym urządzeniu', 'Same device as')}PUSH${T('Żądania', 'Requests')}${T('Ostatnio', 'Last seen')}${T('Polityka', 'Policy')}${T('Stan', 'State')}
${esc(u.username)}${esc((u.device_names || '-').split(',').join(', '))}${u.devices} ${T('partnerstw', 'partnership(s)')}${u.same_device ? u.same_device.split(',').map((x) => `${esc(x)}`).join('') : '-'}${u.pings}${fmtNum(u.requests)}${esc(ago(u.last_seen))}${esc(u.policy)}${u.blocked ? `${T('ZABLOKOWANA', 'BLOCKED')}` : 'OK'}
` : `
${T('Brak skrzynek', 'No mailboxes')}
`; + $$('[data-u]', v).forEach((b) => b.addEventListener('click', async () => { + const name = b.dataset.u, block = b.dataset.b === '1'; + if (block && !(await confirmBox(T('Zablokować skrzynkę?', 'Block mailbox?'), name, true))) return; + const s2 = await loadSettings(); + const set = new Set(s2.security.blocked_users.map((x) => x.toLowerCase())); + if (block) set.add(name.toLowerCase()); else set.delete(name.toLowerCase()); + s2.security.blocked_users = Array.from(set); + await saveSettings(s2); + load(); + })); + $$('[data-dev]', v).forEach((b) => b.addEventListener('click', () => { store.set('ap.devq', b.dataset.dev); location.hash = 'devices'; })); + }; + async function load() { + users = await api('GET', '/api/users'); + draw(); + const locks = await api('GET', '/api/lockouts'); + $('#locks').innerHTML = locks.length ? `${locks.map((l) => ``).join('')}
${T('Klucz', 'Key')}${T('Do', 'Until')}
${esc(l.key)}${esc(fmtTs(l.until))}
` : `
${T('Brak blokad', 'No lockouts')}
`; + $$('[data-unlock]', v).forEach((b) => b.addEventListener('click', async () => { await api('POST', '/api/lockouts/unlock', { key: b.dataset.unlock }); load(); })); + } + $('#u-q', v).addEventListener('input', draw); + every(15000, () => load().catch(() => {})); + }; + + // ------------------------------------------------------------------ settings forms + async function loadSettings() { + const r = await api('GET', '/api/settings'); + S.settings = r.settings; + S.timezones = r.timezones; + return JSON.parse(JSON.stringify(r.settings)); + } + async function saveSettings(s) { + const r = await api('PUT', '/api/settings', s); + toast(T(`Zapisano i zastosowano (rewizja ${r.revision})`, `Saved and applied (revision ${r.revision})`)); + return loadSettings(); + } + const getP = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o); + function setP(o, p, val) { + const ks = p.split('.'); + let c = o; + ks.slice(0, -1).forEach((k) => { c = c[k]; }); + c[ks[ks.length - 1]] = val; + } + + /** field: [path, label, type, opts] type: text|number|bool|select|list|secret|tz|textarea */ + function fieldHtml(s, f) { + const [path, label, type, opt = {}] = f; + const val = getP(s, path); + const id = `f-${path.replace(/\./g, '-')}`; + const help = opt.help ? `
${esc(opt.help)}
` : ''; + const wide = opt.wide || type === 'list' || type === 'textarea' ? ' wide' : ''; + if (type === 'bool') return `
${help}
`; + let input; + if (type === 'select') input = ``; + else if (type === 'tz') input = `${S.timezones.map((t) => ``; + else if (type === 'list') input = ``; + else if (type === 'secret') input = ``; + else if (type === 'number') input = ``; + else input = ``; + return `
${input}${help}
`; + } + + function collect(root, s) { + $$('[data-path]', root).forEach((el) => { + const p = el.dataset.path, t = el.dataset.type; + let val; + if (t === 'bool') val = el.checked; + else if (t === 'number') val = el.value === '' ? 0 : Number(el.value); + else if (t === 'list') val = el.value.split('\n').map((x) => x.trim()).filter(Boolean); + else if (t === 'secret') val = el.value === '' ? (getP(S.settings, p) === '__SET__' ? '__SET__' : '') : el.value; + else val = el.value.trim(); + setP(s, p, val); + }); + return s; + } + + function section(s, title, desc, fields, extra = '') { + return `

${esc(title)}

${desc ? `

${esc(desc)}

` : ''}
${fields.map((f) => fieldHtml(s, f)).join('')}
${extra}
`; + } + + async function settingsPage(v, eyebrow, title, desc, build, after) { + const s = await loadSettings(); + v.innerHTML = head(eyebrow, title, desc, ``) + `
${build(s)}
`; + const form = $('#form', v); + form.addEventListener('input', () => { $('#dirty').hidden = false; }); + form.addEventListener('change', () => { $('#dirty').hidden = false; }); + $('#reset', v).addEventListener('click', () => go()); + $('#save', v).addEventListener('click', async (e) => { + e.target.disabled = true; + try { + const cur = JSON.parse(JSON.stringify(S.settings)); + collect(form, cur); + if (after?.collect) after.collect(form, cur); + await saveSettings(cur); + go(); + } catch (err) { toast(err.message, true); } finally { e.target.disabled = false; } + }); + after?.bind?.(form, s); + } + + function testBlock(kind) { + return `
+
+
`; + } + function bindTests(form) { + $$('[data-test]', form).forEach((b) => b.addEventListener('click', async () => { + const k = b.dataset.test; + const out = $(`[data-result="${k}"]`, form); + out.hidden = false; out.className = 'result'; out.textContent = T('Testowanie…', 'Testing…'); + b.disabled = true; + try { + const cur = collect(form, JSON.parse(JSON.stringify(S.settings))); + const r = await api('POST', `/api/test/${k}`, { login: $(`[data-test-user="${k}"]`, form).value, password: $(`[data-test-pass="${k}"]`, form).value, settings: cur }); + out.classList.add(r.ok ? 'ok' : 'bad'); + out.textContent = r.message; + } catch (e) { out.classList.add('bad'); out.textContent = e.message; } finally { b.disabled = false; } + })); + } + + const TLS = () => [['none', T('brak (tylko localhost)', 'none (localhost only)')], ['start_tls', 'STARTTLS'], ['tls', T('TLS (implicit)', 'TLS (implicit)')]]; + + PAGES.backends = (v) => settingsPage(v, T('ŹRÓDŁA DANYCH', 'DATA SOURCES'), T('Backendy', 'Backends'), T('Poczta przez IMAP/SMTP, kalendarze i zadania przez CalDAV, kontakty przez CardDAV - odpowiednik backendu "combined" z Z-Push.', 'Mail via IMAP/SMTP, calendars and tasks via CalDAV, contacts via CardDAV - the Z-Push "combined" backend.'), (s) => + section(s, 'IMAP', T('Serwer poczty przychodzącej (np. Dovecot). Uwierzytelnianie urządzeń odbywa się loginem IMAP.', 'Incoming mail server (e.g. Dovecot). Devices authenticate with their IMAP login.'), [ + ['imap.host', T('Host', 'Host'), 'text'], ['imap.port', T('Port', 'Port'), 'number', { min: 1, max: 65535 }], + ['imap.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], ['imap.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], + ['imap.folder_prefix', T('Prefiks folderów', 'Folder prefix'), 'text', { ph: 'INBOX.' }], + ['imap.use_special_use', T('Rozpoznawaj foldery po SPECIAL-USE', 'Detect folders via SPECIAL-USE'), 'bool'], + ['imap.folder_inbox', 'Inbox', 'text'], ['imap.folder_sent', T('Wysłane', 'Sent'), 'text'], ['imap.folder_drafts', T('Szkice', 'Drafts'), 'text'], + ['imap.folder_trash', T('Kosz', 'Trash'), 'text'], ['imap.folder_spam', 'Spam', 'text'], ['imap.folder_archive', T('Archiwum', 'Archive'), 'text'], + ['imap.excluded_folders', T('Ukryte foldery', 'Hidden folders'), 'list', { ph: 'Shared/*\nPublic' }], + ['imap.autoseen_on_delete', T('Oznacz jako przeczytane przed usunięciem', 'Mark read before delete'), 'bool'], + ['imap.inline_forward', T('Przekazywanie inline (zamiast załącznika .eml)', 'Inline forward (instead of .eml)'), 'bool'], + ['imap.timeout_secs', T('Timeout (s)', 'Timeout (s)'), 'number', { min: 5 }], ['imap.pool_idle_secs', T('Czas życia połączeń w puli (s)', 'Pool idle time (s)'), 'number'], + ['imap.pool_max_per_user', T('Maks. połączeń w puli / użytkownika', 'Max pooled per user'), 'number', { min: 0, max: 20 }], + ], testBlock('imap')) + + section(s, 'SMTP', T('Wysyłka poczty z urządzeń (SendMail / SmartReply / SmartForward).', 'Outgoing mail from devices (SendMail / SmartReply / SmartForward).'), [ + ['smtp.method', T('Metoda', 'Method'), 'select', { options: [['smtp', 'SMTP'], ['sendmail', 'sendmail']] }], + ['smtp.host', 'Host', 'text'], ['smtp.port', 'Port', 'number'], ['smtp.security', T('Szyfrowanie', 'Encryption'), 'select', { options: TLS() }], + ['smtp.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], + ['smtp.auth', T('Uwierzytelnianie', 'Authentication'), 'select', { options: [['user_credentials', T('dane użytkownika urządzenia', 'device user credentials')], ['static', T('stałe konto relay', 'static relay account')], ['none', T('brak', 'none')]] }], + ['smtp.username', T('Login relay', 'Relay username'), 'text'], ['smtp.password', T('Hasło relay', 'Relay password'), 'secret'], + ['smtp.sendmail_path', T('Ścieżka sendmail', 'sendmail path'), 'text'], ['smtp.timeout_secs', 'Timeout (s)', 'number'], + ['smtp.max_message_mb', T('Maks. rozmiar wiadomości (MB)', 'Max message size (MB)'), 'number'], + ['smtp.save_in_sent', T('Zapisuj kopię w Wysłanych', 'Save copy to Sent'), 'bool'], + ['smtp.enforce_from', T('Wymuszaj adres nadawcy = adres konta', 'Force sender = account address'), 'bool', { help: T('Zapobiega podszywaniu się pod inne adresy.', 'Prevents sender spoofing.') }], + ], testBlock('smtp')) + + section(s, 'CalDAV', T('Kalendarze i zadania (VEVENT/VTODO). Placeholders: %u login, %l część lokalna, %d domena.', 'Calendars and tasks (VEVENT/VTODO). Placeholders: %u login, %l local part, %d domain.'), [ + ['caldav.enabled', T('Włączony', 'Enabled'), 'bool'], ['caldav.base_url', 'URL', 'text', { ph: 'https://dav.example.com' }], + ['caldav.path', T('Ścieżka kalendarzy', 'Calendar home path'), 'text', { ph: '/dav/calendars/%u/' }], ['caldav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], + ['caldav.default_collection', T('Domyślny kalendarz', 'Default calendar'), 'text'], ['caldav.include_regex', T('Tylko kolekcje pasujące do regex', 'Include regex'), 'text'], + ['caldav.exclude_regex', T('Pomiń kolekcje pasujące do regex', 'Exclude regex'), 'text'], ['caldav.timeout_secs', 'Timeout (s)', 'number'], + ], testBlock('caldav')) + + section(s, 'CardDAV', T('Kontakty oraz globalna książka adresowa (GAL) do wyszukiwania i rozwiązywania odbiorców.', 'Contacts plus a global address list (GAL) for search and recipient resolution.'), [ + ['carddav.enabled', T('Włączony', 'Enabled'), 'bool'], ['carddav.base_url', 'URL', 'text'], + ['carddav.path', T('Ścieżka książek adresowych', 'Address book home path'), 'text', { ph: '/dav/addressbooks/%u/' }], ['carddav.verify_tls', T('Weryfikuj certyfikat', 'Verify certificate'), 'bool'], + ['carddav.default_collection', T('Domyślna książka', 'Default book'), 'text'], ['carddav.gal_path', T('Ścieżka GAL', 'GAL path'), 'text', { ph: '/dav/addressbooks/%d/gal/' }], + ['carddav.gal_min_length', T('Min. długość zapytania GAL', 'GAL min query length'), 'number'], ['carddav.max_picture_kb', T('Maks. zdjęcie kontaktu (KB)', 'Max contact picture (KB)'), 'number'], + ['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'], + ], testBlock('carddav')), { bind: bindTests }); + + PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja, push i Autodiscover.', 'Login, protocol, synchronisation, push and Autodiscover.'), (s) => + section(s, T('Ogólne', 'General'), '', [ + ['general.server_name', T('Nazwa serwera', 'Server name'), 'text'], ['general.default_timezone', T('Domyślna strefa czasowa', 'Default time zone'), 'tz'], + ['general.login_format', T('Format loginu', 'Login format'), 'select', { options: [['as_given', T('jak podany przez urządzenie', 'as given by device')], ['local_part', T('tylko część lokalna (bez @domena)', 'local part only')], ['full_email', T('pełny e-mail (dopisz domenę)', 'full e-mail (append domain)')]], help: T('Odpowiednik USE_FULLEMAIL_FOR_LOGIN. Prefiks DOMENA\\ jest zawsze usuwany.', 'Equivalent of USE_FULLEMAIL_FOR_LOGIN. DOMAIN\\ prefixes are always stripped.') }], + ['general.default_domain', T('Domyślna domena', 'Default domain'), 'text'], + ['general.email_template', T('Szablon adresu e-mail', 'E-mail address template'), 'text', { help: '{login} {local} {domain}' }], + ['general.max_protocol', T('Najwyższa wersja protokołu', 'Highest protocol version'), 'select', { options: ['14.1', '14.0', '12.1', '12.0', '2.5'].map((x) => [x, x]) }], + ['general.allowed_domains', T('Dozwolone domeny (puste = wszystkie)', 'Allowed domains (empty = all)'), 'list'], + ]) + + section(s, T('Synchronizacja', 'Synchronisation'), '', [ + ['sync.max_items', T('Maks. elementów w paczce (SYNC_MAX_ITEMS)', 'Max items per batch'), 'number', { min: 1, max: 2000 }], + ['sync.filter_time_max', T('Maks. zakres czasu', 'Max time window'), 'select', { num: true, options: [[0, T('bez limitu', 'unlimited')], [1, T('1 dzień', '1 day')], [2, T('3 dni', '3 days')], [3, T('1 tydzień', '1 week')], [4, T('2 tygodnie', '2 weeks')], [5, T('1 miesiąc', '1 month')], [6, T('3 miesiące', '3 months')], [7, T('6 miesięcy', '6 months')]] }], + ['sync.server_wins', T('Konflikty: wygrywa serwer', 'Conflicts: server wins'), 'bool'], ['sync.loop_detection', T('Wykrywanie pętli synchronizacji', 'Sync loop detection'), 'bool'], + ['sync.max_attachment_mb', T('Maks. załącznik (MB)', 'Max attachment (MB)'), 'number'], ['sync.max_request_mb', T('Maks. żądanie (MB)', 'Max request (MB)'), 'number'], + ['sync.heartbeat_min', T('Hanging Sync min (s)', 'Hanging Sync min (s)'), 'number'], ['sync.heartbeat_max', T('Hanging Sync max (s)', 'Hanging Sync max (s)'), 'number'], + ['sync.search_wait_secs', T('Limit czasu wyszukiwania (s)', 'Search time limit (s)'), 'number'], ['sync.search_max_results', T('Maks. wyników wyszukiwania', 'Max search results'), 'number'], + ['sync.retry_after_secs', 'Retry-After (s)', 'number'], + ]) + + section(s, 'Push (Ping)', T('Urządzenia trzymają otwarte połączenie; serwer sprawdza zmiany w backendzie co zadany interwał.', 'Devices keep a connection open; the server checks the backend for changes every interval.'), [ + ['ping.check_interval_secs', T('Interwał sprawdzania (s)', 'Check interval (s)'), 'number', { min: 5 }], + ['ping.lifetime_min', T('Min. heartbeat (s)', 'Min heartbeat (s)'), 'number'], ['ping.lifetime_max', T('Maks. heartbeat (s)', 'Max heartbeat (s)'), 'number'], + ['ping.max_folders', T('Maks. folderów', 'Max folders'), 'number'], + ]) + + section(s, 'Autodiscover', T('Automatyczna konfiguracja kont na telefonach (/autodiscover/autodiscover.xml).', 'Automatic account configuration (/autodiscover/autodiscover.xml).'), [ + ['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'], + ['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Puste = wyznaczony z nagłówka Host.', 'Empty = derived from the Host header.') }], + ]) + + section(s, T('Logowanie i retencja', 'Logging & retention'), '', [ + ['logging.log_wbxml', T('Loguj zdekodowany WBXML (debug, zawiera treść poczty!)', 'Log decoded WBXML (debug, contains mail data!)'), 'bool'], + ['logging.debug_users', T('Użytkownicy z logowaniem debug', 'Debug users'), 'list'], + ['logging.ring_size', T('Linii logu w panelu', 'Log lines in panel'), 'number'], ['logging.stats_retention_days', T('Retencja statystyk (dni)', 'Stats retention (days)'), 'number'], + ['logging.audit_retention_days', T('Retencja audytu (dni)', 'Audit retention (days)'), 'number'], + ['panel.device_inactive_days', T('Usuwaj urządzenia nieaktywne (dni, 0 = nigdy)', 'Remove inactive devices (days, 0 = never)'), 'number'], + ])); + + const POLICY_FIELDS = () => [ + ['devpwenabled', T('Wymagaj hasła urządzenia', 'Require device password'), 'bool'], ['alphanumpwreq', T('Hasło alfanumeryczne', 'Alphanumeric password'), 'bool'], + ['allowsimpledevpw', T('Pozwól na proste hasła', 'Allow simple passwords'), 'bool'], ['mindevpwlenngth', T('Min. długość hasła', 'Min password length'), 'number'], + ['mindevcomplexchars', T('Min. znaków złożonych', 'Min complex characters'), 'number'], ['maxinacttimedevlock', T('Blokada po bezczynności (s)', 'Lock after inactivity (s)'), 'number'], + ['maxdevpwfailedattempts', T('Wymaż po N błędnych hasłach', 'Wipe after N failed passwords'), 'number'], ['devpwexpiration', T('Wygaśnięcie hasła (dni)', 'Password expiration (days)'), 'number'], + ['devpwhistory', T('Historia haseł', 'Password history'), 'number'], ['pwrecoveryenabled', T('Odzyskiwanie hasła', 'Password recovery'), 'bool'], + ['devencenabled', T('Szyfrowanie urządzenia', 'Device encryption'), 'bool'], ['reqdevenc', T('Wymagaj szyfrowania', 'Require encryption'), 'bool'], + ['attenabled', T('Załączniki dozwolone', 'Attachments allowed'), 'bool'], ['maxattsize', T('Maks. załącznik (bajty, -1 = bez limitu)', 'Max attachment (bytes, -1 unlimited)'), 'number'], + ['allowhtmlemail', T('Poczta HTML', 'HTML email'), 'bool'], ['maxemailagefilter', T('Maks. wiek poczty (0-5)', 'Max email age filter'), 'number'], + ['maxcalagefilter', T('Maks. wiek kalendarza (0,4-7)', 'Max calendar age filter'), 'number'], ['maxemailbodytruncsize', T('Obcięcie treści (bajty, -1)', 'Body truncation (bytes, -1)'), 'number'], + ['maxemailhtmlbodytruncsize', T('Obcięcie HTML (bajty, -1)', 'HTML truncation (bytes, -1)'), 'number'], ['allowstoragecard', T('Karta pamięci', 'Storage card'), 'bool'], + ['allowcam', T('Aparat', 'Camera'), 'bool'], ['allowwifi', 'Wi-Fi', 'bool'], ['allowbluetooth', 'Bluetooth (0/1/2)', 'number'], ['allowirda', 'IrDA', 'bool'], + ['allowtextmessaging', 'SMS', 'bool'], ['allowpopimapemail', 'POP/IMAP', 'bool'], ['allowbrowser', T('Przeglądarka', 'Browser'), 'bool'], + ['allowconsumeremail', T('Prywatna poczta', 'Consumer email'), 'bool'], ['allowinternetsharing', T('Udostępnianie internetu', 'Internet sharing'), 'bool'], + ['allowremotedesktop', T('Pulpit zdalny', 'Remote desktop'), 'bool'], ['allowdesktopsync', T('Synchronizacja z PC', 'Desktop sync'), 'bool'], + ['allowunsignedapps', T('Niepodpisane aplikacje', 'Unsigned apps'), 'bool'], ['allowunsignedinstallationpackages', T('Niepodpisane pakiety', 'Unsigned packages'), 'bool'], + ['reqmansyncroam', T('Ręczna synchronizacja w roamingu', 'Manual sync when roaming'), 'bool'], ['reqsignedsmimemessages', T('Wymagaj podpisu S/MIME', 'Require signed S/MIME'), 'bool'], + ['reqencsmimemessages', T('Wymagaj szyfrowania S/MIME', 'Require encrypted S/MIME'), 'bool'], ['allowsmimesoftcerts', T('Certyfikaty programowe S/MIME', 'S/MIME soft certs'), 'bool'], + ]; + + PAGES.policies = async (v) => { + let sel = 0; + await settingsPage(v, 'PROVISIONING', T('Polityki urządzeń', 'Device policies'), T('Polityki bezpieczeństwa przekazywane urządzeniom (odpowiednik policies.ini). Zmiana polityki wymusza ponowny provisioning urządzeń.', 'Security policies pushed to devices (policies.ini equivalent). Changing a policy forces devices to re-provision.'), (s) => { + const pols = s.provisioning.policies; + return section(s, 'Provisioning', '', [ + ['provisioning.enabled', T('Wymuszaj provisioning', 'Enforce provisioning'), 'bool'], + ['provisioning.loose', T('Luźny provisioning (urządzenia bez obsługi polityk)', 'Loose provisioning (devices without policy support)'), 'bool'], + ['provisioning.default_policy', T('Domyślna polityka', 'Default policy'), 'select', { options: pols.map((p) => [p.name, p.name]) }], + ]) + `

${T('Polityka', 'Policy')}

+

${T('Przypisania użytkowników', 'User assignments')}

${T('Jedna linia: login=nazwa_polityki', 'One per line: login=policy_name')}

`; + }, { + bind(form, s) { + const pf = $('#pol-form', form); + const drawPol = () => { + const p = s.provisioning.policies[sel]; + pf.innerHTML = `
${fieldHtml({ p }, ['p.name', T('Nazwa', 'Name'), 'text'])}${POLICY_FIELDS().map((f) => fieldHtml({ p }, [`p.${f[0]}`, f[1], f[2]])).join('')}
`; + $$('[data-path]', pf).forEach((el) => { el.dataset.pol = el.dataset.path.slice(2); el.removeAttribute('data-path'); }); + }; + const keep = () => { + const p = s.provisioning.policies[sel]; + $$('[data-pol]', pf).forEach((el) => { + const k = el.dataset.pol; + p[k] = el.type === 'checkbox' ? el.checked : el.type === 'number' ? Number(el.value) : el.value.trim(); + }); + }; + $('#pol-sel', form).addEventListener('change', (e) => { keep(); sel = Number(e.target.value); drawPol(); }); + $('#pol-add', form).addEventListener('click', () => { + keep(); + const c = JSON.parse(JSON.stringify(s.provisioning.policies[sel])); + c.name = `${c.name}-copy`; + s.provisioning.policies.push(c); + sel = s.provisioning.policies.length - 1; + $('#pol-sel', form).insertAdjacentHTML('beforeend', ``); + $('#pol-sel', form).value = String(sel); + drawPol(); + $('#dirty').hidden = false; + }); + $('#pol-del', form).addEventListener('click', () => { + if (s.provisioning.policies.length < 2) { toast(T('Musi zostać przynajmniej jedna polityka', 'At least one policy is required'), true); return; } + s.provisioning.policies.splice(sel, 1); + sel = 0; + $('#pol-sel', form).innerHTML = s.provisioning.policies.map((p, i) => ``).join(''); + drawPol(); + $('#dirty').hidden = false; + }); + drawPol(); + this.keep = keep; + this.s = s; + }, + collect(form, cur) { + this.keep(); + cur.provisioning.policies = this.s.provisioning.policies; + cur.provisioning.user_policies = $('#pol-users', form).value.split('\n').map((l) => l.trim()).filter((l) => l.includes('=')).map((l) => { const [u, p] = l.split('='); return { username: u.trim(), policy: p.trim() }; }); + }, + }); + }; + + PAGES.security = async (v) => { + await settingsPage(v, 'HARDENING', T('Bezpieczeństwo', 'Security'), T('Ochrona przed zgadywaniem haseł, kontrola dostępu sieciowego, urządzeń i panelu.', 'Brute-force protection, network/device access control and panel security.'), (s) => + section(s, T('Ochrona logowania', 'Login protection'), T('Po przekroczeniu limitu konto (i IP przy 3× limicie) jest blokowane. Linia logu jest zgodna z fail2ban.', 'Exceeding the limit locks the account (and the IP at 3× the limit). The log line is fail2ban friendly.'), [ + ['security.auth_fail_max', T('Maks. błędnych logowań', 'Max failed logins'), 'number'], ['security.auth_fail_window_secs', T('Okno (s)', 'Window (s)'), 'number'], + ['security.lockout_secs', T('Czas blokady (s)', 'Lockout (s)'), 'number'], ['security.auth_cache_secs', T('Cache poprawnego logowania (s)', 'Successful login cache (s)'), 'number', { help: T('Przechowywany jest tylko HMAC, nigdy hasło.', 'Only an HMAC is kept, never the password.') }], + ['security.log_auth_failures', T('Loguj nieudane logowania', 'Log failed logins'), 'bool'], + ]) + + section(s, T('Sieć', 'Network'), '', [ + ['security.trusted_proxies', T('Zaufane proxy (CIDR)', 'Trusted proxies (CIDR)'), 'list', { help: T('Tylko od nich akceptowany jest nagłówek z prawdziwym IP klienta.', 'Only these may set the real client IP header.') }], + ['security.real_ip_header', T('Nagłówek z IP klienta', 'Client IP header'), 'text'], + ['security.eas_allowed_networks', T('Sieci z dostępem do ActiveSync (puste = wszystkie)', 'Networks allowed to use ActiveSync (empty = all)'), 'list'], + ['panel.allowed_networks', T('Sieci z dostępem do panelu (puste = wszystkie)', 'Networks allowed to use the panel (empty = all)'), 'list'], + ]) + + section(s, T('Użytkownicy i urządzenia', 'Users & devices'), '', [ + ['security.require_device_approval', T('Nowe urządzenia wymagają zatwierdzenia', 'New devices require approval'), 'bool'], + ['security.allowed_users', T('Tylko ci użytkownicy (puste = wszyscy)', 'Only these users (empty = all)'), 'list'], + ['security.blocked_users', T('Zablokowani użytkownicy', 'Blocked users'), 'list'], + ['security.blocked_device_types', T('Zablokowane typy urządzeń / User-Agent', 'Blocked device types / User-Agent'), 'list', { ph: 'WindowsMail\nOutlook' }], + ]) + + section(s, T('Panel', 'Panel'), '', [ + ['panel.session_ttl_minutes', T('Wygasanie sesji (min)', 'Session lifetime (min)'), 'number'], + ['panel.prometheus_token', T('Token Prometheus (/metrics)', 'Prometheus token (/metrics)'), 'secret', { help: T('Puste = endpoint wyłączony. Użycie: Authorization: Bearer ', 'Empty = endpoint disabled. Use: Authorization: Bearer ') }], + ]) + + `

${T('Konto administratora', 'Administrator account')}

+
+
+
+
`, + { + bind(form) { + $('#pw-save', form).addEventListener('click', async () => { + const n = $('#pw-new', form).value; + if (n !== $('#pw-new2', form).value) { toast(T('Hasła się różnią', 'Passwords differ'), true); return; } + try { await api('POST', '/api/password', { current: $('#pw-cur', form).value, new: n }); toast(T('Hasło zmienione', 'Password changed')); $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => { i.value = ''; }); } catch (e) { toast(e.message, true); } + }); + $('#revoke', form).addEventListener('click', async () => { await api('POST', '/api/sessions/revoke-others'); toast(T('Inne sesje wylogowane', 'Other sessions revoked')); }); + // password fields are not settings + $$('#pw-cur,#pw-new,#pw-new2', form).forEach((i) => i.addEventListener('input', (e) => e.stopPropagation())); + }, + }); + }; + + PAGES.logs = async (v) => { + v.innerHTML = head(T('DIAGNOSTYKA', 'DIAGNOSTICS'), T('Logi i audyt', 'Logs & audit'), T('Ostatnie wpisy logu serwera oraz dziennik działań administracyjnych.', 'Recent server log lines and the administrative audit trail.')) + + `
+
+ `; + $$('.tab', v).forEach((t) => t.addEventListener('click', () => { + $$('.tab', v).forEach((x) => x.classList.toggle('active', x === t)); + $('#tab-log').hidden = t.dataset.tab !== 'log'; + $('#tab-audit').hidden = t.dataset.tab !== 'audit'; + })); + async function logs() { + if (!$('#lf').checked) return; + const q = new URLSearchParams({ n: '800' }); + if ($('#lv').value) q.set('level', $('#lv').value); + if ($('#lq').value.trim()) q.set('q', $('#lq').value.trim()); + const l = await api('GET', `/api/logs?${q}`); + const box = $('#logbox'); + const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30; + box.innerHTML = l.map((x) => `
${esc(fmtTime(x.ts))}${esc(x.level)}${esc(x.message)}
`).join('') || `
-
`; + if (atBottom) box.scrollTop = box.scrollHeight; + } + async function audit() { + const a = await api('GET', '/api/audit'); + $('#auditbox').innerHTML = a.length ? `${a.map((e) => ``).join('')}
${T('Czas', 'Time')}${T('Kto', 'Actor')}${T('Akcja', 'Action')}${T('Cel', 'Target')}IP${T('Szczegóły', 'Details')}
${esc(fmtTs(e.ts))}${esc(e.actor)}${esc(e.action)}${esc(e.target)}${esc(e.ip)}${esc(e.details)}
` : `
-
`; + } + $('#lv').addEventListener('change', logs); + $('#lq').addEventListener('input', () => { clearTimeout(PAGES._lq); PAGES._lq = setTimeout(logs, 300); }); + every(3000, () => logs().catch(() => {})); + every(30000, () => audit().catch(() => {})); + }; + + PAGES.about = async (v) => { + const o = await api('GET', '/api/overview'); + v.innerHTML = head('ABOUT', 'ActivePush', T('Serwer Exchange ActiveSync dla IMAP/SMTP + CalDAV/CardDAV - przepisany z Z-Push na Rust.', 'Exchange ActiveSync server for IMAP/SMTP + CalDAV/CardDAV - Z-Push rewritten in Rust.')) + + `

${T('Wersja', 'Version')} ${esc(o.version)}

+
ActiveSync
2.5, 12.0, 12.1, 14.0, 14.1
+
${T('Komendy', 'Commands')}
Sync, FolderSync, FolderCreate/Update/Delete, Ping, Provision, SendMail, SmartReply, SmartForward, GetAttachment, ItemOperations, GetItemEstimate, MoveItems, MeetingResponse, Search (GAL, Mailbox), ResolveRecipients, Settings, ValidateCert
+
Endpoints
/Microsoft-Server-ActiveSync
/autodiscover/autodiscover.xml (+ AutoDiscover/AutoDiscover.xml, Autodiscover/Autodiscover.xml)
+
CLI
active-push admin | config | device | probe
+

nginx

${T('Zastępuje lokalizacje fastcgi Z-Push:', 'Replaces the Z-Push fastcgi locations:')}

location = /Microsoft-Server-ActiveSync {
+    proxy_pass http://127.0.0.1:8081;
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-For $remote_addr;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_read_timeout 3660s;
+    proxy_buffering off;
+    client_max_body_size 64m;
+}
+location ~* ^/autodiscover/autodiscover\\.xml$ {
+    proxy_pass http://127.0.0.1:8081;
+    proxy_set_header Host $host;
+    proxy_set_header X-Forwarded-For $remote_addr;
+}
`; + }; + + // ------------------------------------------------------------------ auth / boot + function setAuthTexts(setup) { + $('#auth-eyebrow').textContent = setup ? T('PIERWSZE URUCHOMIENIE', 'FIRST RUN') : T('ADMINISTRACJA', 'ADMINISTRATION'); + $('#auth-title').textContent = setup ? T('Utwórz administratora', 'Create administrator') : T('Zaloguj się', 'Sign in'); + $('#setup-help').textContent = T('Wygeneruj jednorazowy token na serwerze i wklej go poniżej:', 'Generate a one-time token on the server and paste it below:'); + $('#lbl-token').textContent = T('Token instalacyjny', 'Setup token'); + $('#lbl-user').textContent = T('Login', 'Username'); + $('#lbl-pass').textContent = setup ? T('Hasło (min. 12 znaków)', 'Password (min. 12 characters)') : T('Hasło', 'Password'); + $('#lbl-pass2').textContent = T('Powtórz hasło', 'Repeat password'); + $('#auth-submit').textContent = setup ? T('Utwórz i zaloguj', 'Create & sign in') : T('Zaloguj', 'Sign in'); + $('#setup-fields').hidden = !setup; + $('#confirm-wrap').hidden = !setup; + $('#password').autocomplete = setup ? 'new-password' : 'current-password'; + } + + function showAuth(setup) { + clearTimers(); + $('#loading').hidden = true; + $('#shell').hidden = true; + $('#auth').hidden = false; + S.setup = !!setup; + setAuthTexts(S.setup); + $('#username').focus(); + } + + async function boot() { + let sess; + try { sess = await api('GET', '/api/session'); } catch (e) { $('#loading').textContent = e.message; return; } + if (!sess.authenticated) { showAuth(sess.setup_required); return; } + S.session = sess; + S.csrf = sess.csrf; + $('#loading').hidden = true; + $('#auth').hidden = true; + $('#shell').hidden = false; + $('#whoami').textContent = sess.username; + $('#logout').textContent = T('Zablokuj', 'Lock'); + $('#theme').title = T('Motyw jasny/ciemny', 'Light/dark theme'); + document.title = `${sess.server_name || 'ActivePush'} / Control room`; + go(); + } + + $('#auth-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const msg = $('#auth-msg'); + msg.textContent = ''; + const body = { username: $('#username').value.trim(), password: $('#password').value, token: $('#setup-token').value.trim() }; + if (S.setup && body.password !== $('#password2').value) { msg.textContent = T('Hasła się różnią', 'Passwords differ'); return; } + $('#auth-submit').disabled = true; + try { + await api('POST', S.setup ? '/api/setup' : '/api/login', body); + $('#password').value = ''; + $('#password2').value = ''; + await boot(); + } catch (err) { msg.textContent = err.message; } finally { $('#auth-submit').disabled = false; } + }); + + $('#logout').addEventListener('click', async () => { try { await api('POST', '/api/logout'); } catch { /* ignore */ } showAuth(false); }); + $('#menu-toggle').addEventListener('click', () => $('#nav').classList.toggle('open')); + const theme = store.get('ap.theme', 'dark'); + document.documentElement.dataset.theme = theme; + $('#theme').addEventListener('click', () => { + const t = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = t; + store.set('ap.theme', t); + if (S.session) go(); + }); + const langSel = $('#lang'); + langSel.value = LANG; + document.documentElement.lang = LANG; + langSel.addEventListener('change', () => { LANG = langSel.value; store.set('ap.lang', LANG); location.reload(); }); + window.addEventListener('hashchange', () => { if (S.session) go(); }); + let resizeT; + window.addEventListener('resize', () => { clearTimeout(resizeT); resizeT = setTimeout(() => { if (S.session && S.page === 'overview') go(); }, 400); }); + boot(); +})(); diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..64f6d61 --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1 @@ +a/p diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b961aea --- /dev/null +++ b/web/index.html @@ -0,0 +1,74 @@ + + + + + + + ActivePush / Control room + + + + + +
ACTIVEPUSH
+ + + + + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..34ded3b --- /dev/null +++ b/web/style.css @@ -0,0 +1,363 @@ +/* ActivePush control room - visual language shared with RustProxy */ +:root { + color-scheme: dark; + --bg: #131615; + --panel: #1b1f1d; + --panel2: #202622; + --line: #323a34; + --row-line: #2b332c; + --muted: #a0aaa0; + --subtle: #879082; + --text: #edf0e9; + --accent: #f69b74; + --accent-hover: #ffb090; + --accent-ink: #221810; + --green: #bdd293; + --red: #ffaaa2; + --blue: #8ebcff; + --violet: #d7adff; + --sand: #c9b99c; + --sidebar-bg: #101310; + --brand2: #cfe0ac; + --nav-active: #242b23; + --nav-hover: #20251f; + --field-bg: #131813; + --field-line: #47523f; + --outline: #717b67; + --grid: #333c33; + --code-bg: #101810; + --notice-bg: #29251c; + --notice-line: #554833; + --pill-line: #6d5a45; + --pill-live: #576847; + --danger-line: #79514e; + --shadow: #0009; + --backdrop: rgba(0, 0, 0, .68); + --r-surface: 6px; + --r-control: 4px; + --mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Consolas, monospace; + --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +html[data-theme=light] { + color-scheme: light; + --bg: #f4f6f2; + --panel: #fff; + --panel2: #edf1eb; + --line: #cbd3c7; + --row-line: #e0e5dd; + --muted: #5e685f; + --subtle: #687369; + --text: #1d241f; + --accent: #b85b38; + --accent-hover: #a84f30; + --accent-ink: #fff; + --green: #5f783f; + --red: #b64a43; + --blue: #4f77a9; + --violet: #7d4fa3; + --sand: #685b45; + --sidebar-bg: #eef2ec; + --brand2: #6b8249; + --nav-active: #e1e8dc; + --nav-hover: #e8ede6; + --field-bg: #fff; + --field-line: #b7c1b5; + --outline: #98a497; + --grid: #d4dbd1; + --code-bg: #edf3e9; + --notice-bg: #fff9ef; + --notice-line: #d4c2a5; + --pill-line: #cbb28f; + --pill-live: #a8ba8d; + --danger-line: #d7a29d; + --shadow: rgba(31, 39, 33, .22); + --backdrop: rgba(31, 39, 33, .45); +} + +* { box-sizing: border-box; scrollbar-color: #4f5d50 transparent; scrollbar-width: thin; } +[hidden] { display: none !important; } +html, body { margin: 0; } +body { + background: var(--bg); + color: var(--text); + font: 12px/1.5 var(--sans); + min-height: 100vh; +} +a { color: inherit; text-decoration: none; } +code, .mono { font-family: var(--mono); } +h1 { margin: 0; font-size: clamp(26px, 3vw, 40px); font-weight: 520; letter-spacing: -1.5px; line-height: 1.1; } +h2 { margin: 0; font-size: 15px; letter-spacing: -.3px; } +h3 { margin: 0 0 8px; font-size: 13px; } + +/* ---------------------------------------------------------------- controls */ +button { font: inherit; cursor: pointer; border: 0; } +button:disabled { opacity: .5; cursor: wait; } +.primary, .outline, .quiet, .danger { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + min-height: 27px; padding: 5px 11px; font-size: 11px; border-radius: var(--r-control); white-space: nowrap; +} +.primary { background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent); font-weight: 650; } +.primary:hover { background: var(--accent-hover); } +.outline { background: transparent; color: var(--text); border: 1px solid var(--outline); } +.outline:hover { border-color: var(--accent); } +.quiet { background: transparent; color: var(--muted); border: 1px solid var(--line); } +.quiet:hover { color: var(--text); border-color: var(--outline); } +.danger { background: transparent; color: var(--red); border: 1px solid var(--danger-line); } +.danger:hover { background: color-mix(in srgb, var(--red) 12%, transparent); } +a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid var(--accent); outline-offset: 2px; +} + +input, select, textarea { + font: inherit; font-size: 12px; width: 100%; min-width: 0; + background: var(--field-bg); color: var(--text); + border: 1px solid var(--field-line); border-radius: var(--r-control); + padding: 6px 8px; min-height: 29px; +} +textarea { min-height: 70px; resize: vertical; font-family: var(--mono); } +input[type=checkbox] { width: 15px; height: 15px; min-height: 0; accent-color: var(--accent); } +label { display: block; color: var(--muted); font-size: 11px; margin: 0 0 3px; } + +.small-label { font: 9px var(--mono); letter-spacing: 1.2px; color: var(--muted); text-transform: uppercase; } +.fine { font-size: 11px; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; } +.pill { + display: inline-block; padding: 2px 7px; border: 1px solid var(--pill-line); color: var(--accent); + font: 9px var(--mono); letter-spacing: .8px; border-radius: 3px; white-space: nowrap; text-transform: uppercase; +} +.pill.live, .pill.ok { border-color: var(--pill-live); color: var(--green); } +.pill.bad { border-color: var(--danger-line); color: var(--red); } +.pill.info { border-color: color-mix(in srgb, var(--blue) 45%, transparent); color: var(--blue); } +.pill.muted { border-color: var(--line); color: var(--muted); } +.signal { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; } +.signal.pulse { animation: pulse 1.6s infinite; } +@keyframes pulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 60%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } } + +.notice { + display: flex; gap: 12px; align-items: flex-start; color: var(--sand); border: 1px solid var(--notice-line); + background: var(--notice-bg); border-radius: var(--r-surface); padding: 8px 11px; margin-bottom: 10px; font-size: 11px; line-height: 1.6; +} +.notice b { font-family: var(--mono); color: var(--accent); } + +/* ---------------------------------------------------------------- shell */ +.shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: 100vh; } +.sidebar { + background: var(--sidebar-bg); border-right: 1px solid var(--line); padding: 18px 12px; + display: flex; flex-direction: column; gap: 14px; position: sticky; top: 0; height: 100vh; overflow-y: auto; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 14px; letter-spacing: -.2px; } +.brand-mark { + display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; white-space: nowrap; width: 34px; height: 34px; border: 1px solid var(--line); + border-radius: 8px; font: 700 12px var(--mono); color: var(--accent); background: var(--panel); +} +.brand-mark span { color: var(--brand2); } +.edition { font: 9px var(--mono); letter-spacing: 1.6px; color: var(--subtle); } +.main-navigation { display: flex; flex-direction: column; gap: 2px; } +.nav { + display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent; + color: var(--muted); padding: 7px 9px; border-radius: var(--r-control); font-size: 12px; +} +.nav span { font: 10px var(--mono); color: var(--subtle); width: 18px; } +.nav b { font-weight: 500; } +.nav:hover { background: var(--nav-hover); color: var(--text); } +.nav.active { background: var(--nav-active); color: var(--brand2); } +.nav.active span { color: var(--accent); } +.nav .count { margin-left: auto; font: 10px var(--mono); color: var(--accent); } +.sidebar-bottom { margin-top: auto; border-top: 1px solid var(--line); padding-top: 12px; } +.sidebar-bottom p { margin: 6px 0; color: var(--subtle); font: 10px/1.6 var(--mono); } +.local-tag { display: flex; align-items: center; gap: 6px; font: 9px var(--mono); letter-spacing: 1.2px; color: var(--green); } +.mobile-toggle { display: none; } + +main { min-width: 0; padding: 0 24px 40px; } +.topbar { + display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 0; + border-bottom: 1px solid var(--line); margin-bottom: 18px; position: sticky; top: 0; background: var(--bg); z-index: 5; +} +.breadcrumb { font: 10px var(--mono); letter-spacing: 1.4px; color: var(--muted); } +.breadcrumb span { color: var(--subtle); margin: 0 4px; } +.breadcrumb b { color: var(--text); font-weight: 500; } +.top-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.top-actions select { width: auto; min-height: 27px; padding: 4px 8px; } +#theme { min-width: 30px; font-size: 13px; } + +.page-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; } +.page-head .eyebrow { margin-bottom: 6px; } +.page-head p { margin: 6px 0 0; max-width: 720px; } + +/* ---------------------------------------------------------------- cards / grid */ +.grid { display: grid; gap: 10px; } +.grid.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +#view { display: flex; flex-direction: column; gap: 12px; } +#view > .page-head, #view > .tabs { margin-bottom: 0; } +.stack { display: flex; flex-direction: column; gap: 12px; } +.stack > * + * { margin-top: 0; } +.grid.stats > .notice { grid-column: 1 / -1; margin: 0; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-surface); padding: 12px 14px; min-width: 0; } +.stack > * + * { margin-top: 10px; } +.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; } +.card-heading .small-label { display: block; margin-bottom: 2px; } +.stat { position: relative; overflow: hidden; } +.stat .value { font: 600 24px/1.15 var(--mono); letter-spacing: -1px; margin-top: 6px; } +.stat .value small { font-size: 12px; color: var(--muted); letter-spacing: 0; margin-left: 4px; } +.stat .sub { color: var(--muted); font-size: 10.5px; margin-top: 2px; } +.stat svg.spark { position: absolute; right: 0; bottom: 0; width: 55%; height: 34px; opacity: .55; pointer-events: none; } +.stat.accent .value { color: var(--accent); } +.stat.good .value { color: var(--green); } +.stat.bad .value { color: var(--red); } + +/* ---------------------------------------------------------------- tables */ +.table-scroll { overflow: auto; } +table { width: 100%; border-collapse: collapse; font-size: 11px; } +th { + font: 9.5px var(--mono); letter-spacing: .6px; color: var(--muted); font-weight: 400; text-transform: uppercase; + border-bottom: 1px solid var(--line); padding: 7px 8px; text-align: left; white-space: nowrap; position: sticky; top: 0; background: var(--panel); +} +td { border-bottom: 1px solid var(--row-line); padding: 7px 8px; font-family: var(--mono); max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +tr.clickable { cursor: pointer; } +tr.clickable:hover td { background: var(--nav-hover); } +td.num, th.num { text-align: right; } +td .sub { display: block; color: var(--muted); font-size: 10px; } +.empty { color: var(--muted); padding: 18px 4px; text-align: center; } + +/* ---------------------------------------------------------------- forms */ +.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px 14px; } +.field.wide { grid-column: 1 / -1; } +.field .help { font-size: 10.5px; color: var(--subtle); margin-top: 3px; line-height: 1.45; } +.check { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; margin-top: 18px; cursor: pointer; } +.check input { flex: 0 0 auto; } +.form-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; margin-top: 12px; flex-wrap: wrap; } +.section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin: 2px 0 10px; } +.dirty-flag { color: var(--accent); font: 10px var(--mono); } +.test-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); } +.result { margin-top: 8px; font: 11px/1.5 var(--mono); padding: 7px 9px; border-radius: var(--r-control); background: var(--code-bg); border: 1px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; } +.result.ok { color: var(--green); } +.result.bad { color: var(--red); } + +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 12px; flex-wrap: wrap; } +.tab { background: transparent; color: var(--muted); padding: 7px 11px; border-bottom: 2px solid transparent; font-size: 12px; } +.tab.active { color: var(--text); border-bottom-color: var(--accent); } + +.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; } +.toolbar input, .toolbar select { width: auto; min-width: 180px; } + +/* ---------------------------------------------------------------- charts */ +.chart { width: 100%; display: block; } +.chart text { fill: var(--muted); font: 9.5px var(--mono); } +.chart .grid-line { stroke: var(--grid); stroke-dasharray: 2 5; } +.chart-wrap { position: relative; } +.chart-tip { + position: absolute; pointer-events: none; background: var(--panel2); border: 1px solid var(--line); border-radius: var(--r-control); + padding: 5px 8px; font: 10.5px/1.5 var(--mono); box-shadow: 0 8px 20px var(--shadow); white-space: nowrap; z-index: 3; +} +.legend { display: flex; gap: 14px; flex-wrap: wrap; font: 10px var(--mono); color: var(--muted); margin-top: 6px; } +.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; } +.hbar { display: grid; grid-template-columns: 120px 1fr 54px; gap: 8px; align-items: center; font: 11px var(--mono); margin: 5px 0; } +.hbar .track { height: 8px; background: var(--panel2); border-radius: 3px; overflow: hidden; } +.hbar .fill { height: 100%; background: var(--accent); border-radius: 3px; } +.hbar .n { text-align: right; color: var(--muted); } +.donut-layout { display: grid; grid-template-columns: 130px 1fr; gap: 14px; align-items: center; } + +/* ---------------------------------------------------------------- live feed */ +.feed { font: 11px/1.55 var(--mono); max-height: 460px; overflow: auto; } +.feed-row { display: grid; grid-template-columns: 64px minmax(90px, 3fr) minmax(90px, 2fr) 72px 64px; gap: 10px; padding: 3px 4px; border-bottom: 1px solid var(--row-line); } +.feed-row.fresh { animation: flash 1.2s ease-out; } +@keyframes flash { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } to { background: transparent; } } +.feed-row .t { color: var(--subtle); } +.feed-row .cmd { color: var(--accent); } +.feed-row .err { color: var(--red); } +.countdown { height: 3px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 3px; } +.countdown div { height: 100%; background: var(--green); } + +/* ---------------------------------------------------------------- dialog / toast */ +.backdrop { position: fixed; inset: 0; background: var(--backdrop); z-index: 40; display: grid; place-items: center; padding: 16px; } +.dialog { + background: var(--panel); border: 1px solid var(--line); border-radius: 10px; width: min(980px, 100%); + max-height: calc(100vh - 32px); overflow: auto; box-shadow: 0 24px 60px var(--shadow); padding: 16px 18px; +} +.dialog.small { width: min(460px, 100%); } +.dialog-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; } +.kv { display: grid; grid-template-columns: 150px 1fr; gap: 5px 12px; font-size: 11.5px; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; font-family: var(--mono); overflow-wrap: anywhere; } +.actions-row { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; } +.toast { + position: fixed; right: 18px; bottom: 18px; z-index: 60; background: #d0dfb4; color: #1c2818; border: 1px solid #ebf2de; + padding: 9px 13px; border-radius: 6px; box-shadow: 0 10px 28px var(--shadow); max-width: 420px; font-size: 12px; +} +.toast.error { background: #ffd2bd; color: #42241b; border-color: #ff9f7a; } + +/* ---------------------------------------------------------------- auth */ +.auth-page { display: grid; place-items: center; min-height: 100vh; padding: 16px; } +.auth-card { width: min(420px, 100%); } +.auth-card .brand { margin-bottom: 22px; } +.auth-card h1 { margin-bottom: 18px; } +.auth-card form > * + * { margin-top: 10px; } +.auth-card .primary { width: 100%; min-height: 34px; margin-top: 14px; } +.command { display: block; font: 11px var(--mono); background: var(--code-bg); border: 1px solid var(--line); padding: 7px 9px; border-radius: 4px; margin: 6px 0; overflow-wrap: anywhere; } + +/* ---------------------------------------------------------------- loading */ +.app-loading { display: grid; place-items: center; min-height: 100vh; color: var(--muted); font: 11px var(--mono); letter-spacing: 1.5px; } +.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 12px; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---------------------------------------------------------------- responsive */ +@media (max-width: 1100px) { + .grid.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .grid.three { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 880px) { + .shell { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; padding: 10px 14px; flex-direction: row; flex-wrap: wrap; align-items: center; } + .sidebar .edition, .sidebar-bottom { display: none; } + .mobile-toggle { display: inline-flex; margin-left: auto; } + .main-navigation { display: none; width: 100%; } + .main-navigation.open { display: flex; } + main { padding: 0 14px 30px; } + .grid.two, .grid.three { grid-template-columns: 1fr; } + .grid.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stat .value { font-size: 20px; } + .test-row { grid-template-columns: 1fr; } + .kv { grid-template-columns: 1fr; } + .feed-row { grid-template-columns: 56px 1fr 60px; } + .feed-row .u, .feed-row .ms { display: none; } + .donut-layout { grid-template-columns: 1fr; } +} + +/* chart colour classes (CSP: no inline styles) */ +.k1 { stroke: var(--accent); fill: var(--accent); } +.k2 { stroke: var(--green); fill: var(--green); } +.k3 { stroke: var(--blue); fill: var(--blue); } +.k4 { stroke: var(--violet); fill: var(--violet); } +.k5 { stroke: var(--red); fill: var(--red); } +.k6 { stroke: var(--sand); fill: var(--sand); } +.k7 { stroke: var(--muted); fill: var(--muted); } +.ln { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; } +.ar { stroke: none; fill-opacity: .14; } +.br { stroke: none; } +.dn { fill: none; stroke-width: 14; } +.cursor { stroke: var(--outline); stroke-dasharray: 3 3; } +.sw.k1, .fill.k1 { background: var(--accent); } +.sw.k2, .fill.k2 { background: var(--green); } +.sw.k3, .fill.k3 { background: var(--blue); } +.sw.k4, .fill.k4 { background: var(--violet); } +.sw.k5, .fill.k5 { background: var(--red); } +.sw.k6, .fill.k6 { background: var(--sand); } +.sw.k7, .fill.k7 { background: var(--muted); } +.donut-center { fill: var(--text); font: 600 15px var(--mono); } +.mt { margin-top: 14px; } + +.card-heading input { max-width: 340px; } + +/* select dropdown colours (native popups ignore transparent backgrounds) */ +select option, select optgroup { background: var(--panel); color: var(--text); } +select.quiet { background: var(--panel); color: var(--text); } +.feed-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chip { + display: inline-flex; align-items: center; gap: 5px; margin: 1px 4px 1px 0; padding: 1px 7px; border: 1px solid var(--line); + border-radius: 10px; background: var(--panel2); color: var(--text); font: 10.5px var(--mono); cursor: pointer; +} +.chip:hover { border-color: var(--accent); } +.chip .signal { margin: 0; width: 6px; height: 6px; } +td.wrap { white-space: normal; max-width: 420px; }