diff --git a/.forgejo/workflows/email.yml b/.forgejo/workflows/email.yml index ba2c7f6..df0922f 100644 --- a/.forgejo/workflows/email.yml +++ b/.forgejo/workflows/email.yml @@ -416,13 +416,12 @@ jobs: FLOOR=190 out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)" - # C1e/C1f: the IMAP transport and the platform keystore are both - # feature-gated (native only). They must still COMPILE when the - # features are on, or the direct backend's read path and credential - # persistence silently rot. A check, not a test: the socket and the - # secret service are not exercised, only type-checked. - - name: The IMAP and keystore features must compile - run: cargo check --locked -p nigig-core --features imap,keystore + # C1e: the IMAP transport is feature-gated (native only). It must + # still COMPILE when the feature is on, or the direct backend's read + # path silently rots. A check, not a test: the socket is not + # exercised, only type-checked. + - name: The IMAP feature must compile + run: cargo check --locked -p nigig-core --features imap # A coverage number that is only printed drifts down. This enforces a # whole-domain floor plus per-file floors on the files that have diff --git a/Cargo.lock b/Cargo.lock index 411ebe0..76ca3ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,17 +141,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "apple-native-keyring-store" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" -dependencies = [ - "keyring-core", - "log 0.4.33", - "security-framework", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -165,18 +154,6 @@ dependencies = [ "libloading 0.8.9 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener 5.4.2", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-channel" version = "1.9.0" @@ -200,20 +177,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - [[package]] name = "async-imap" version = "0.9.7" @@ -306,17 +269,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-signal" version = "0.2.14" @@ -888,7 +840,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -982,33 +933,6 @@ dependencies = [ "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1369,12 +1293,6 @@ dependencies = [ "foldhash 0.2.0 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "hashlink" version = "0.9.1" @@ -1410,12 +1328,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hexf-parse" version = "0.2.1" @@ -1427,24 +1339,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f715f263b6e73aa2322b59057134b4daf56a765cbebc2f6b35e640ffded3361b" -[[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 = "hostname" version = "0.4.2" @@ -1819,16 +1713,6 @@ dependencies = [ "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", ] -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", - "hashbrown 0.17.1", -] - [[package]] name = "inout" version = "0.1.4" @@ -1912,27 +1796,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "keyring" -version = "4.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", -] - -[[package]] -name = "keyring-core" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" -dependencies = [ - "log 0.4.33", -] - [[package]] name = "lettre" version = "0.11.23" @@ -2722,15 +2585,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -2805,7 +2659,7 @@ dependencies = [ "cfg_aliases 0.2.1", "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "hexf-parse", - "indexmap 2.13.0", + "indexmap", "makepad-error-log", "makepad-half", "num-traits 0.2.20", @@ -2978,7 +2832,6 @@ dependencies = [ "getrandom 0.2.17", "image", "js-sys", - "keyring", "lettre", "makepad-widgets", "matrix_client", @@ -3393,75 +3246,12 @@ dependencies = [ "memchr 2.8.3", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits 0.2.19", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits 0.2.19", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits 0.2.19", -] - [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits 0.2.19", -] - -[[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 0.2.19", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits 0.2.19", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3642,16 +3432,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "owned_ttf_parser" version = "0.19.0" @@ -3874,15 +3654,6 @@ dependencies = [ "time", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -4582,25 +4353,6 @@ name = "sdfer" version = "0.2.1" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" -[[package]] -name = "secret-service" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf", - "num", - "once_cell 1.21.4", - "serde", - "sha2", - "zbus", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4679,17 +4431,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5058,36 +4799,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - [[package]] name = "tower" version = "0.5.3" @@ -5140,21 +4851,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -5187,17 +4886,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unarray" version = "0.1.4" @@ -5300,7 +4988,6 @@ checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -5802,19 +5489,6 @@ name = "windows-link" version = "0.2.1" source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc" -[[package]] -name = "windows-native-keyring-store" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" -dependencies = [ - "byteorder 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", - "keyring-core", - "regex", - "windows-sys 0.61.2", - "zeroize", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -6006,15 +5680,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr 2.8.3", -] - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -6050,87 +5715,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener 5.4.2", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - [[package]] name = "zerocopy" version = "0.7.35" @@ -6242,44 +5826,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.3", - "winnow", -] diff --git a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md index 8b70397..fedd359 100644 --- a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md +++ b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md @@ -531,7 +531,6 @@ snapshot. Commits are on `main`. | *(this turn)* | **Phase A complete** — `Secret` newtype; `SmtpConfig` no longer derives `Serialize`; `relay()`; explicit TLS policy; local validation; HTTPS-only proxy endpoint; email THREAT_MODEL. Domain tests **38 → 68**. | | *(this turn)* | **C1d + C1c DONE** — `mail_proxy.rs` (the proxy HTTP client: request/response parsing, error mapping, a transport trait with reqwest/fetch/mock impls); `SetupDraft` (identity + backend, validated together); the backend chooser and two setup forms in `EmailAccountSetup`; the inbox branches to `spawn_proxy_verify` for the proxy backend. Domain tests **126 → 154**. Coverage tooling (`tools/test-email-coverage.sh`, 93.4% line, floors enforced) wired into `email.yml`. | | *(this turn)* | **Phase C + D COMPLETE** — C1e (`imap_client.rs`: trait + pure INTERNALDATE parser + feature-gated async-imap transport, native only); C1f (`credential_store.rs`: trait + fail-closed default); C3 (`email_cache.rs`: bodies through a `BodyCipher` before disk); C4b (inbox fetches real mail, loading/error/empty states); C5 (Compose sends via `spawn_send_message`); C6 (`email_pacing.rs`: `SendRateLimiter`+`SendPacing`, 100/hour); C7 (Refresh button re-fetches); D1 (More page is real: account + sign-out); D2 (lib.rs shims deleted, `NavigationBarAction` in a real module); D3 (`CachedWidget` decision documented); D4 (one-slot SMTP transport pool, keying tested); D5 (`EmailWorkerAction::None` dropped). Domain tests **154 → 195**. Coverage now **90.7%** over 12 files, floors enforced. | -| *(this turn)* | **C6/C7/C1f gaps closed** — C6: `email_bulk.rs` actually *uses* the pacing — `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter + abandon), wired into the Bulk page. C7: pull-to-refresh on the inbox (the SMS/M-Pesa `scrolled`+`scroll_position` pattern) in addition to the button. C1f: a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature; the fail-closed default remains when the feature is off. Domain tests **195 → 206**. | **Phase 0 is complete.** All seven items done; 0.7 was fixed upstream. @@ -701,8 +700,8 @@ send-only form. **It needs a product decision before any code.** | ~~C4a~~ | ~~**Inbox list + thread reader.**~~ **DONE** (`18bbb7b`) — sender list, thread push/pop, unread handling, signed-out gate. | | ~~C4b~~ | ~~**Wire it to real data.**~~ **DONE** — `sample_thread()` is gone from the UI (test-only now, gated in CI); the inbox fetches via `spawn_fetch_inbox` (backend-agnostic) and renders loading / error / empty states. | | ~~C5~~ | ~~**Give Compose a send button.**~~ **DONE** — `compose.rs` is a real form: to/subject/body, `EmailSendRequest::build_without_config` validation, two-tap confirm, and `spawn_send_message` (branches on the signed-in backend). | -| ~~C6~~ | ~~**Bulk pacing.**~~ **DONE** — `email_pacing.rs` ported the primitives; `email_bulk.rs` now *uses* them: `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter backstop + abandon), wired into the Bulk page. The Bulk tab finally sends >100 recipients. | -| ~~C7~~ | ~~**Pull-to-refresh + background fetch.**~~ **DONE** — a Refresh button re-fetches AND a pull-to-refresh gesture on the inbox (the SMS/M-Pesa `scrolled` + `scroll_position` pattern, throttled and guarded). The fetch is worker → `InboxFetched` action → drained on the UI thread. | +| ~~C6~~ | ~~**Bulk pacing.**~~ **DONE** — `email_pacing.rs`: `SendRateLimiter` + `SendPacing` ported with email-shaped limits (100/hour). Primitives are tested; no per-recipient loop exists to wire them into yet (the bulk path is one message to N recipients). | +| ~~C7~~ | ~~**Pull-to-refresh + background fetch.**~~ **DONE** — a Refresh button re-fetches; the fetch is worker → `InboxFetched` action → drained on the UI thread (the `Cx::post_action` shape, equivalent to SMS's `SignalToUI`). A gesture-based pull is deferred: Makepad has no pull-to-refresh primitive and the button is the cross-platform control. | #### C1 — DECIDED: support both, user-selectable @@ -772,7 +771,7 @@ extends cleanly. | ~~C1c~~ | **DONE** — backend chooser + two forms in `EmailAccountSetup`, driven by `SetupDraft` (identity + backend validated together); the inbox branches to the proxy verify. | | ~~C1d~~ | **DONE** — `mail_proxy.rs`: `ProxyApiClient` with `verify`/`list_inbox`/`send` over a `ProxyTransport` trait (reqwest/fetch/mock); request/response parsing and error mapping all host-tested. | | ~~C1e~~ | ~~`ImapSmtpBackend`.~~ **DONE** — `imap_client.rs`: `ImapTransport` trait, `ImapClient` (`verify` + `list_inbox`), a pure `INTERNALDATE` parser and envelope mapping (tested), and `AsyncImapTransport` over `async-imap` behind the `imap` feature (native only; a CI step checks it compiles). | -| ~~C1f~~ | ~~Keystore-backed credential storage.~~ **DONE** — `credential_store.rs`: the `CredentialStore` trait, a fail-closed default, AND a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature. The runtime vault is not host-verified (no secret service in CI), but the contract is pinned by tests and the code compiles + fails closed when the vault is absent. | +| ~~C1f~~ | ~~Keystore-backed credential storage.~~ **DONE (the seam)** — `credential_store.rs`: the `CredentialStore` trait and a fail-closed default. The actual `AndroidKeyStore` wiring remains the platform task it always was (not host-testable); the contract is pinned by tests so a platform impl has something to match. | ##### One thing I will not pretend diff --git a/crates/apps/nigig-email/src/email_frame/pages/bulk.rs b/crates/apps/nigig-email/src/email_frame/pages/bulk.rs index c610638..c56def9 100644 --- a/crates/apps/nigig-email/src/email_frame/pages/bulk.rs +++ b/crates/apps/nigig-email/src/email_frame/pages/bulk.rs @@ -1,9 +1,7 @@ use makepad_widgets::*; use nigig_core::email_account::AccountDraft; -use nigig_core::email_send::{EmailSendRequest, MAX_RECIPIENTS}; -use nigig_core::email_worker::{ - spawn_bulk_send, spawn_send_email, spawn_smtp_test, EmailWorkerAction, SmtpConfig, -}; +use nigig_core::email_send::EmailSendRequest; +use nigig_core::email_worker::{spawn_send_email, spawn_smtp_test, EmailWorkerAction, SmtpConfig}; script_mod! { use mod.prelude.widgets.* @@ -160,22 +158,22 @@ impl Widget for EmailBulkPage { let message = self.text_input(cx, ids!(message_input)).text(); let config = self.smtp_config.clone(); - // B1/B2 + C6: parse the recipient list and validate the - // message. `validate_bulk_message` accepts a list over the - // per-message cap (so the Bulk tab can actually bulk), and - // the send path batches it below. - match EmailSendRequest::validate_bulk_message(&to, &subject, &message) { + // B1/B2: build through EmailSendRequest so the recipient + // list is actually parsed. This field is labelled + // "To (comma-separated)" and used to be handed to a single + // Mailbox parse, so any list failed outright. + match EmailSendRequest::build(&config, &to, &subject, &message) { Err(e) => { self.label(cx, ids!(send_status)).set_text(cx, &e.message()); self.pending_send = None; self.view.redraw(cx); } - Ok((recipients, list)) => { + Ok((req, list)) => { // B5: require a second tap before spending money. // Email is billed by nobody but it is irreversible // and goes to real people; the SMS crate requires // the same confirmation for the same reason. - let n = recipients.len(); + let n = req.recipient_count(); let armed = self .pending_send .as_ref() @@ -189,12 +187,6 @@ impl Widget for EmailBulkPage { n, if n == 1 { "" } else { "s" } ); - if n > MAX_RECIPIENTS { - let batches = n.div_ceil(MAX_RECIPIENTS); - prompt.push_str(&format!( - " This will be sent in {batches} paced batches." - )); - } if list.duplicates > 0 || !list.rejected.is_empty() { prompt.push_str(&format!(" ({})", list.summary())); } @@ -202,20 +194,9 @@ impl Widget for EmailBulkPage { self.view.redraw(cx); } else { self.pending_send = None; - if n <= MAX_RECIPIENTS { - self.label(cx, ids!(send_status)) - .set_text(cx, &format!("Sending to {n} recipient(s)…")); - spawn_send_email(config, to, subject, message); - } else { - self.label(cx, ids!(send_status)).set_text( - cx, - &format!( - "Sending to {n} recipients in {} paced batches…", - n.div_ceil(MAX_RECIPIENTS) - ), - ); - spawn_bulk_send(config, recipients, subject, message); - } + self.label(cx, ids!(send_status)) + .set_text(cx, &format!("Sending to {n} recipient(s)…")); + spawn_send_email(config, to, subject, message); self.set_send_button_label(cx, true); self.view.redraw(cx); } @@ -240,27 +221,6 @@ impl Widget for EmailBulkPage { }; self.label(cx, ids!(send_status)).set_text(cx, &msg); } - // C6: paced bulk progress, after each batch and once at - // the end. - if let Some(EmailWorkerAction::BulkSendProgress(p)) = action.downcast_ref() { - if p.done { - self.set_send_button_label(cx, false); - let msg = format!( - "Bulk send finished: {} of {} batches sent, {} failed.", - p.sent_batches, p.total_batches, p.failed_batches - ); - self.label(cx, ids!(send_status)).set_text(cx, &msg); - } else { - self.label(cx, ids!(send_status)).set_text( - cx, - &format!( - "Sending batch {}/{}…", - p.sent_batches + p.failed_batches, - p.total_batches - ), - ); - } - } } } } diff --git a/crates/apps/nigig-email/src/email_frame/pages/inbox.rs b/crates/apps/nigig-email/src/email_frame/pages/inbox.rs index f8be217..fc93dca 100644 --- a/crates/apps/nigig-email/src/email_frame/pages/inbox.rs +++ b/crates/apps/nigig-email/src/email_frame/pages/inbox.rs @@ -186,9 +186,6 @@ pub struct EmailInboxPage { /// C4b: the last fetch failed; the message is shown in the status view. #[rust] load_error: Option, - /// C7: last time a pull-to-refresh fired, for the throttle. - #[rust] - last_refresh_secs: f64, #[rust] current_thread_view: Option, @@ -241,10 +238,6 @@ impl Widget for EmailInboxPage { if self.button(cx, ids!(refresh_btn)).clicked(actions) { self.begin_fetch(cx); } - // C7: pull-to-refresh -- the same pattern the SMS and M-Pesa - // transaction lists use (`scrolled` + scroll position over a - // threshold, throttled and guarded by the in-flight flag). - self.handle_portal_list_refresh_actions(cx, actions); } // Connection test result (SMTP or proxy) decides signed-in vs @@ -485,26 +478,6 @@ impl EmailInboxPage { self.view.redraw(cx); } - /// C7: pull-to-refresh on the inbox list. - /// - /// Mirrors the SMS conversations list: a scroll over the threshold - /// while at rest triggers a refresh, throttled to once per 1.2s and - /// suppressed while a fetch is already in flight. - fn handle_portal_list_refresh_actions(&mut self, cx: &mut Cx, actions: &Actions) { - let list = self.view.portal_list(cx, ids!(threads_list)); - let now = cx.seconds_since_app_start(); - let pull_distance = list.scroll_position(); - if list.scrolled(actions) - && pull_distance > 55.0 - && !self.loading - && self.session.is_signed_in() - && now - self.last_refresh_secs > 1.2 - { - self.last_refresh_secs = now; - self.begin_fetch(cx); - } - } - /// Set the two labels of the no-mail status view. fn set_status(&mut self, cx: &mut Cx, title: &str, subtitle: &str) { self.view.label(cx, ids!(status_title)).set_text(cx, title); diff --git a/crates/nigig-core/Cargo.toml b/crates/nigig-core/Cargo.toml index 4115037..a90e12b 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -12,11 +12,6 @@ async-rt = [] # TLS). Native only -- a browser cannot open a raw TCP socket -- and off by # default so builds that do not need IMAP never compile its dependency tree. imap = ["dep:async-imap", "dep:async-native-tls", "dep:async-net"] -# C1f: a real keystore-backed CredentialStore (the `keyring` crate: Linux -# Secret Service, Windows Credential Manager, macOS Keychain). Native only, -# off by default so wasm and keystore-free builds never pull its dependency -# tree. Without it, active_store() is the fail-closed UnavailableCredentialStore. -keystore = ["dep:keyring"] [dependencies] matrix_client = { path = "../matrix_client", default-features = false } @@ -56,8 +51,6 @@ lettre = { version = "0.11", default-features = false, features = [ async-imap = { version = "0.9", optional = true } async-native-tls = { version = "0.5", optional = true } async-net = { version = "2", optional = true } -# C1f: a real platform keystore (feature `keystore`), native only. -keyring = { version = "4", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2" diff --git a/crates/nigig-core/src/credential_store.rs b/crates/nigig-core/src/credential_store.rs index 70dde7a..ef9d8b3 100644 --- a/crates/nigig-core/src/credential_store.rs +++ b/crates/nigig-core/src/credential_store.rs @@ -70,73 +70,17 @@ impl CredentialStore for UnavailableCredentialStore { /// The credential store in use on this build. /// -/// With the `keystore` feature enabled (native only), this is a real -/// platform keystore -- the OS Secret Service / Credential Manager / -/// Keychain via the `keyring` crate -- so IMAP credentials survive an app -/// restart. Without it, this is the fail-closed `UnavailableCredentialStore` -/// and credentials are session-only. +/// This is the single place a platform swaps in its keystore-backed +/// implementation. On a host (and on any platform that has not wired a +/// keystore yet) it is `UnavailableCredentialStore`, and the behaviour +/// documented above applies. pub fn active_store() -> &'static dyn CredentialStore { - #[cfg(feature = "keystore")] - { - static KEYRING: KeyringCredentialStore = - KeyringCredentialStore::new("nigig-email"); - &KEYRING - } - #[cfg(not(feature = "keystore"))] - { - static DEFAULT: UnavailableCredentialStore = UnavailableCredentialStore; - &DEFAULT - } -} - -/// A real keystore, backed by the OS credential vault (C1f). -/// -/// `keyring` stores a credential under a (service, account) pair in the -/// platform's native vault: the Linux Secret Service (GNOME Keyring / -/// KWallet), Windows Credential Manager, or the macOS Keychain. The secret -/// never touches app storage; the OS encrypts and gates it. -/// -/// This is the implementation the plan's C1f pointed at. It is native-only -/// and gated behind the `keystore` feature. It is NOT host-tested: whether -/// a credential round-trips depends on the runtime environment's secret -/// service, which a CI container does not provide -- the same honest caveat -/// as the IMAP transport (C1e). A build without a secret service fails -/// closed (`store` returns `Err`), which is exactly the behaviour the seam -/// is meant to have. -#[cfg(feature = "keystore")] -pub struct KeyringCredentialStore { - service: &'static str, -} - -#[cfg(feature = "keystore")] -impl KeyringCredentialStore { - pub const fn new(service: &'static str) -> Self { - Self { service } - } -} - -#[cfg(feature = "keystore")] -impl CredentialStore for KeyringCredentialStore { - fn store(&self, account_id: &str, secret: &Secret) -> Result<(), String> { - let entry = keyring::Entry::new(self.service, account_id).map_err(|e| e.to_string())?; - entry - .set_password(secret.expose()) - .map_err(|e| e.to_string()) - } - - fn load(&self, account_id: &str) -> Result, String> { - let entry = keyring::Entry::new(self.service, account_id).map_err(|e| e.to_string())?; - match entry.get_password() { - Ok(p) => Ok(Some(Secret::new(p))), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(e.to_string()), - } - } - - fn delete(&self, account_id: &str) -> Result<(), String> { - let entry = keyring::Entry::new(self.service, account_id).map_err(|e| e.to_string())?; - entry.delete_credential().map_err(|e| e.to_string()) - } + // Kept behind an indirection so a platform impl can be selected by + // cfg without changing callers. There is none yet; see the module + // note -- the Android keystore is the C1f platform task and is not + // host-testable. + static DEFAULT: UnavailableCredentialStore = UnavailableCredentialStore; + &DEFAULT } #[cfg(test)] diff --git a/crates/nigig-core/src/email_bulk.rs b/crates/nigig-core/src/email_bulk.rs deleted file mode 100644 index 3e44e20..0000000 --- a/crates/nigig-core/src/email_bulk.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! Paced bulk sending (Phase C6, completed). -//! -//! C6 ported `SendRateLimiter`/`SendPacing` from `robius-sms`, but the -//! honest gap was: **no loop used them**. The Bulk tab sent ONE message to -//! N recipients and hit `MAX_RECIPIENTS` (100) hard -- a 500-recipient -//! list was refused, not paced. That is not bulk; it is a capped single -//! send. -//! -//! This module closes the gap. It splits a large recipient list into -//! provider-sized batches and schedules them with a gap, so the bulk path -//! both *fits* under the per-message cap and *paces* itself under the -//! relay's rate limit. The schedule is a pure function over plain data -- -//! fully host-testable -- and the worker that executes it is a thin loop. -//! -//! ## Why batches, not one message per recipient -//! -//! SMTP accepts up to ~100 `RCPT TO` per message, so sending N messages to -//! reach N recipients is N times the TCP+TLS+AUTH work for no benefit. -//! Batching is the correct shape: each batch is one SMTP transaction with -//! up to `batch_size` recipients, and the gap is between *batches*. - -use crate::email_pacing::{SendPacing, SendRateLimiter}; -use crate::email_send::MAX_RECIPIENTS; - -/// One batch of a bulk send, with the delay that precedes it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BulkBatch { - /// Recipient mailbox strings, already parsed/validated by the caller. - pub recipients: Vec, - /// Milliseconds to wait before sending this batch. The first batch is - /// always 0; subsequent batches accumulate `SendPacing::delay_ms()`. - pub delay_before_ms: i64, -} - -/// The default batch size: the same per-message recipient cap the send -/// path enforces, so a batch never trips `TooManyRecipients`. -pub fn default_batch_size() -> usize { - MAX_RECIPIENTS -} - -/// Split `recipients` into provider-sized batches with a pacing schedule. -/// -/// Pure and deterministic: `chunks` preserves input order, and the delay -/// for batch *i* is `i * pacing.delay_ms()`. `batch_size == 0` or an empty -/// list yields an empty plan (nothing to send, no panic). -pub fn bulk_send_plan( - recipients: &[String], - batch_size: usize, - pacing: SendPacing, -) -> Vec { - if batch_size == 0 || recipients.is_empty() { - return Vec::new(); - } - recipients - .chunks(batch_size) - .enumerate() - .map(|(i, chunk)| BulkBatch { - recipients: chunk.to_vec(), - delay_before_ms: pacing.delay_ms().saturating_mul(i as i64), - }) - .collect() -} - -/// Wall-clock epoch milliseconds, for the rate-limiter backstop. -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -/// Progress of a bulk send, posted to the UI after each batch. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BulkProgress { - pub sent_batches: usize, - pub total_batches: usize, - pub failed_batches: usize, - pub sent_recipients: usize, - pub total_recipients: usize, - /// True only on the final update. - pub done: bool, -} - -impl BulkProgress { - pub fn is_complete(&self) -> bool { - self.done - } -} - -/// Execute a bulk plan, sending each batch in turn. -/// -/// The loop that C6 exists for: pace between batches (`SendPacing`), and -/// hold the batch under the rate-limit ceiling (`SendRateLimiter`) rather -/// than abandoning the list when the cap is reached. `on_progress` is -/// called after every batch and once at the end (`done`), so the caller -/// decides how to surface it (post an action, update a label, …). -/// -/// `send_batch` receives the joined recipient string and returns Ok on a -/// successful SMTP transaction. The abandon flag is checked between every -/// step so a stop is responsive. -pub async fn run_bulk_send( - plan: Vec, - pacing: SendPacing, - abandon: &std::sync::atomic::AtomicBool, - mut on_progress: impl FnMut(&BulkProgress), - mut send_batch: F, -) -> BulkProgress -where - F: FnMut(&str) -> Fut, - Fut: std::future::Future>, -{ - let total_batches = plan.len(); - let total_recipients = plan.iter().map(|b| b.recipients.len()).sum(); - let mut limiter = SendRateLimiter::default(); - let mut sent = 0usize; - let mut failed = 0usize; - let mut sent_recipients = 0usize; - - for batch in plan { - // Pace before every batch except the first. - if batch.delay_before_ms > 0 { - let mut remaining = batch.delay_before_ms; - while remaining > 0 { - if abandon.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - let step = remaining.min(200); - crate::platform::sleep(std::time::Duration::from_millis(step as u64)).await; - remaining -= step; - } - } - if abandon.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - - // The rate limiter is the backstop: with a sane pacing gap it - // never fires; if the user forced delay=0 it waits out the ceiling - // instead of abandoning the list. - loop { - match limiter.allow_at(now_ms()) { - Ok(()) => break, - Err(wait_ms) => { - if abandon.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - let nap = wait_ms.clamp(50, 1000) as u64; - crate::platform::sleep(std::time::Duration::from_millis(nap)).await; - } - } - } - if abandon.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - - match send_batch(&batch.recipients.join(", ")).await { - Ok(()) => { - sent += 1; - sent_recipients += batch.recipients.len(); - } - Err(_) => { - failed += 1; - } - } - - let progress = BulkProgress { - sent_batches: sent, - total_batches, - failed_batches: failed, - sent_recipients, - total_recipients, - done: false, - }; - on_progress(&progress); - } - - let final_progress = BulkProgress { - sent_batches: sent, - total_batches, - failed_batches: failed, - sent_recipients, - total_recipients, - done: true, - }; - on_progress(&final_progress); - // `pacing` is consumed by the plan already; keep the parameter for the - // signature's symmetry with the planner and future rate-limiter tuning. - let _ = pacing; - final_progress -} - -#[cfg(test)] -mod tests { - use super::*; - - fn addrs(n: usize) -> Vec { - (0..n).map(|i| format!("u{i}@example.com")).collect() - } - - // ---- the plan ------------------------------------------------------ - - #[test] - fn a_small_list_is_one_batch_with_no_delay() { - let plan = bulk_send_plan(&addrs(10), 100, SendPacing::from_seconds(10)); - assert_eq!(plan.len(), 1); - assert_eq!(plan[0].recipients.len(), 10); - assert_eq!(plan[0].delay_before_ms, 0); - } - - #[test] - fn a_large_list_is_split_into_batches_in_order() { - let plan = bulk_send_plan(&addrs(250), 100, SendPacing::from_seconds(10)); - assert_eq!(plan.len(), 3, "250 / 100 = 3 batches"); - assert_eq!(plan[0].recipients.len(), 100); - assert_eq!(plan[1].recipients.len(), 100); - assert_eq!(plan[2].recipients.len(), 50); - // Order is preserved across the split. - assert_eq!(plan[0].recipients[0], "u0@example.com"); - assert_eq!(plan[1].recipients[0], "u100@example.com"); - assert_eq!(plan[2].recipients[0], "u200@example.com"); - } - - #[test] - fn delays_accumulate_across_batches() { - let plan = bulk_send_plan(&addrs(250), 100, SendPacing::from_seconds(10)); - assert_eq!(plan[0].delay_before_ms, 0); - assert_eq!(plan[1].delay_before_ms, 10_000); - assert_eq!(plan[2].delay_before_ms, 20_000); - } - - #[test] - fn a_zero_batch_size_or_empty_list_is_an_empty_plan() { - assert!(bulk_send_plan(&addrs(5), 0, SendPacing::default()).is_empty()); - assert!(bulk_send_plan(&[], 100, SendPacing::default()).is_empty()); - } - - #[test] - fn the_default_batch_size_matches_the_recipient_cap() { - assert_eq!(default_batch_size(), MAX_RECIPIENTS); - } - - #[test] - fn a_delay_of_zero_produces_no_gaps() { - let plan = bulk_send_plan(&addrs(250), 100, SendPacing::from_millis(0)); - assert!(plan.iter().all(|b| b.delay_before_ms == 0)); - } - - // ---- the worker loop, against an in-memory send -------------------- - - /// `run_bulk_send` sleeps via `platform::sleep` (tokio on native), so - /// the test drives it on a current-thread runtime. The send closure is - /// a mock that records its inputs, so the loop -- batching, pacing - /// gaps, abandon, progress -- is exercised without any network. - #[test] - fn the_loop_sends_every_batch_and_reports_final_progress() { - let plan = bulk_send_plan(&addrs(250), 100, SendPacing::from_millis(0)); - let abandon = std::sync::atomic::AtomicBool::new(false); - - let sent_calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - let progress_log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); - - let calls = sent_calls.clone(); - let log = progress_log.clone(); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .expect("runtime"); - - let final_progress = rt.block_on(run_bulk_send( - plan, - SendPacing::from_millis(0), - &abandon, - move |p| log.lock().unwrap().push(p.clone()), - move |recipients: &str| { - let calls = calls.clone(); - let s = recipients.to_string(); - async move { - calls.lock().unwrap().push(s); - Ok(()) - } - }, - )); - - assert_eq!(final_progress.sent_batches, 3); - assert_eq!(final_progress.failed_batches, 0); - assert_eq!(final_progress.sent_recipients, 250); - assert!(final_progress.done); - - // Three batches sent, in order, each with its recipients joined. - let calls = sent_calls.lock().unwrap(); - assert_eq!(calls.len(), 3); - assert_eq!(calls[0], addrs(100).join(", ")); - // The third batch is the remaining 50 recipients, u200..u249. - let tail: Vec = (200..250).map(|i| format!("u{i}@example.com")).collect(); - assert_eq!(calls[2], tail.join(", ")); - - // Progress is reported after each batch (done=false) plus a final - // (done=true): 4 updates for 3 batches. - let log = progress_log.lock().unwrap(); - assert_eq!(log.len(), 4); - assert!(!log[0].done); - assert!(!log[2].done); - assert!(log[3].done); - } - - #[test] - fn a_failed_batch_is_counted_but_does_not_stop_the_loop() { - let plan = bulk_send_plan(&addrs(250), 100, SendPacing::from_millis(0)); - let abandon = std::sync::atomic::AtomicBool::new(false); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .expect("runtime"); - - let final_progress = rt.block_on(run_bulk_send( - plan, - SendPacing::from_millis(0), - &abandon, - |_| {}, - |recipients: &str| { - let s = recipients.to_string(); - async move { - // Fail only the second batch. - if s.starts_with("u100@") { - Err("relay refused".to_string()) - } else { - Ok(()) - } - } - }, - )); - - assert_eq!(final_progress.sent_batches, 2); - assert_eq!(final_progress.failed_batches, 1); - assert_eq!(final_progress.sent_recipients, 150); - } - - #[test] - fn abandon_stops_the_loop_between_batches() { - let plan = bulk_send_plan(&addrs(250), 100, SendPacing::from_millis(0)); - let abandon = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let sent = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let sent_clone = sent.clone(); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .expect("runtime"); - - let abandon_for_closure = abandon.clone(); - let progress = rt.block_on(run_bulk_send( - plan, - SendPacing::from_millis(0), - &abandon, - |_| {}, - move |_: &str| { - let sent = sent_clone.clone(); - let abandon = abandon_for_closure.clone(); - async move { - let n = sent.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - // Abandon after the first batch is sent. - if n == 0 { - abandon.store(true, std::sync::atomic::Ordering::Relaxed); - } - Ok(()) - } - }, - )); - - assert_eq!(sent.load(std::sync::atomic::Ordering::Relaxed), 1); - assert_eq!(progress.sent_batches, 1); - assert!(progress.done, "abandon still emits a final update"); - } -} diff --git a/crates/nigig-core/src/email_send.rs b/crates/nigig-core/src/email_send.rs index ba9e155..1a0ca13 100644 --- a/crates/nigig-core/src/email_send.rs +++ b/crates/nigig-core/src/email_send.rs @@ -359,49 +359,6 @@ impl EmailSendRequest { )) } - /// Validate a bulk send's message WITHOUT the per-message recipient - /// cap (C6), returning the accepted mailbox strings and the parse - /// outcome. - /// - /// `build_without_config` refuses a list over `MAX_RECIPIENTS`; the - /// bulk path must instead *accept* the list and batch it. Everything - /// else is identical: no recipients, subject/body limits, empty body. - pub fn validate_bulk_message( - to: &str, - subject: &str, - body: &str, - ) -> Result<(Vec, RecipientList), SendError> { - let list = parse_recipients(to); - if list.accepted.is_empty() { - return Err(if list.rejected.is_empty() { - SendError::NoRecipients - } else { - SendError::AllRecipientsInvalid(list.rejected.clone()) - }); - } - if subject.len() > MAX_SUBJECT_BYTES { - return Err(SendError::SubjectTooLong { - bytes: subject.len(), - max: MAX_SUBJECT_BYTES, - }); - } - if body.trim().is_empty() { - return Err(SendError::EmptyBody); - } - if body.len() > MAX_BODY_BYTES { - return Err(SendError::BodyTooLarge { - bytes: body.len(), - max: MAX_BODY_BYTES, - }); - } - let addresses = list - .accepted - .iter() - .map(|r| r.to_mailbox_string()) - .collect(); - Ok((addresses, list)) - } - pub fn recipient_count(&self) -> usize { self.recipients.len() } @@ -599,37 +556,6 @@ mod tests { )); } - /// C6: a bulk list must NOT be refused at the per-message cap -- it is - /// accepted whole (the caller batches it) while subject/body limits - /// still hold. - #[test] - fn validate_bulk_message_accepts_a_list_over_the_cap() { - let many: Vec = (0..MAX_RECIPIENTS + 50) - .map(|i| format!("u{i}@example.com")) - .collect(); - let (addresses, list) = - EmailSendRequest::validate_bulk_message(&many.join(", "), "Hi", "Body").unwrap(); - assert_eq!(addresses.len(), MAX_RECIPIENTS + 50); - assert!(list.rejected.is_empty()); - } - - #[test] - fn validate_bulk_message_still_enforces_message_limits() { - assert_eq!( - EmailSendRequest::validate_bulk_message("", "s", "b").unwrap_err(), - SendError::NoRecipients - ); - assert_eq!( - EmailSendRequest::validate_bulk_message("a@x.com", "s", " ").unwrap_err(), - SendError::EmptyBody - ); - let s = "a".repeat(MAX_SUBJECT_BYTES + 1); - assert!(matches!( - EmailSendRequest::validate_bulk_message("a@x.com", &s, "b").unwrap_err(), - SendError::SubjectTooLong { .. } - )); - } - #[test] fn no_recipients_and_all_invalid_are_different_errors() { assert_eq!( diff --git a/crates/nigig-core/src/email_worker.rs b/crates/nigig-core/src/email_worker.rs index cdb69ba..004746b 100644 --- a/crates/nigig-core/src/email_worker.rs +++ b/crates/nigig-core/src/email_worker.rs @@ -401,67 +401,6 @@ pub fn spawn_send_email(config: SmtpConfig, to: String, subject: String, body: S }); } -/// Spawn a paced bulk send (C6, completed). -/// -/// The Bulk tab used to send ONE message to N recipients and hit -/// `MAX_RECIPIENTS` (100) hard -- a 500-recipient list was refused, not -/// paced. This splits the list into provider-sized batches and sends them -/// with a `SendPacing` gap, holding the batch under the rate-limit ceiling, -/// and posts `BulkSendProgress` after each batch and once at the end. -/// -/// The recipients are already-validated mailbox strings (the caller parses -/// the raw input through `parse_recipients` first, so a bad entry is -/// reported before any batch goes out). Same in-flight guard and abandon -/// control as the single send (B5/B6). -pub fn spawn_bulk_send(config: SmtpConfig, recipients: Vec, subject: String, body: String) { - if config.is_incomplete() { - Cx::post_action(EmailWorkerAction::SendResult(Err( - INCOMPLETE_CONFIG_MESSAGE.to_string(), - ))); - return; - } - if recipients.is_empty() { - Cx::post_action(EmailWorkerAction::SendResult(Err( - "Add at least one recipient.".to_string(), - ))); - return; - } - if SEND_IN_FLIGHT.swap(true, std::sync::atomic::Ordering::Relaxed) { - Cx::post_action(EmailWorkerAction::SendResult(Err( - SEND_ALREADY_RUNNING.to_string() - ))); - return; - } - SEND_ABANDONED.store(false, std::sync::atomic::Ordering::Relaxed); - - let batch_size = crate::email_bulk::default_batch_size(); - let pacing = crate::email_pacing::SendPacing::default(); - let plan = crate::email_bulk::bulk_send_plan(&recipients, batch_size, pacing); - - crate::platform::spawn(async move { - crate::email_bulk::run_bulk_send( - plan, - pacing, - &SEND_ABANDONED, - |p| Cx::post_action(EmailWorkerAction::BulkSendProgress(p.clone())), - |to: &str| { - // The send future must OWN the joined recipient string - // (and borrow the config/subject/body, which outlive it), - // so the closure copies the &str out and captures the rest - // by reference. - let to_owned = to.to_string(); - let config_ref = &config; - let subject_ref = &subject; - let body_ref = &body; - async move { send_email_impl(config_ref, &to_owned, subject_ref, body_ref).await } - }, - ) - .await; - SEND_IN_FLIGHT.store(false, std::sync::atomic::Ordering::Relaxed); - // The final done=true progress was already posted by the loop. - }); -} - // --- Native implementation: direct SMTP via lettre --- #[cfg(not(target_arch = "wasm32"))] @@ -867,9 +806,6 @@ pub enum EmailWorkerAction { /// the reason on failure, so the inbox can render loading/error/empty /// states without knowing which backend produced them. InboxFetched(Result, String>), - /// Progress of a paced bulk send (C6), posted after each batch and once - /// at the end (`done`). - BulkSendProgress(crate::email_bulk::BulkProgress), } #[cfg(test)] diff --git a/crates/nigig-core/src/lib.rs b/crates/nigig-core/src/lib.rs index 12c3761..4006762 100644 --- a/crates/nigig-core/src/lib.rs +++ b/crates/nigig-core/src/lib.rs @@ -26,7 +26,6 @@ pub mod mail_backend; pub mod mail_proxy; pub mod email_worker; pub mod email_pacing; -pub mod email_bulk; pub mod credential_store; pub mod email_cache; pub mod imap_client; diff --git a/tools/test-email-coverage.sh b/tools/test-email-coverage.sh index 5f59cbb..76d5538 100644 --- a/tools/test-email-coverage.sh +++ b/tools/test-email-coverage.sh @@ -53,7 +53,6 @@ nigig-core/src/email_store.rs:95 nigig-core/src/email_account.rs:95 nigig-core/src/secret.rs:95 nigig-core/src/email_pacing.rs:90 -nigig-core/src/email_bulk.rs:85 nigig-core/src/credential_store.rs:90 nigig-core/src/email_cache.rs:80 nigig-core/src/email_session.rs:90 @@ -131,7 +130,6 @@ EMAIL_FILES=( "$ROOT/crates/nigig-core/src/mail_proxy.rs" "$ROOT/crates/nigig-core/src/secret.rs" "$ROOT/crates/nigig-core/src/email_pacing.rs" - "$ROOT/crates/nigig-core/src/email_bulk.rs" "$ROOT/crates/nigig-core/src/credential_store.rs" "$ROOT/crates/nigig-core/src/email_cache.rs" "$ROOT/crates/nigig-core/src/email_session.rs" @@ -165,8 +163,8 @@ with open(path) as fh: keep = ("email_account.rs", "email_send.rs", "email_store.rs", "email_worker.rs", "mail_backend.rs", "mail_proxy.rs", "secret.rs", - "email_pacing.rs", "email_bulk.rs", "credential_store.rs", - "email_cache.rs", "email_session.rs", "imap_client.rs") + "email_pacing.rs", "credential_store.rs", "email_cache.rs", + "email_session.rs", "imap_client.rs") files = [f for f in data["data"][0]["files"] if f["filename"].endswith(keep)]