diff --git a/.forgejo/workflows/email.yml b/.forgejo/workflows/email.yml index df0922f..ba2c7f6 100644 --- a/.forgejo/workflows/email.yml +++ b/.forgejo/workflows/email.yml @@ -416,12 +416,13 @@ jobs: FLOOR=190 out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)" - # 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 + # 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 # 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 76ca3ac..411ebe0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,17 @@ 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" @@ -154,6 +165,18 @@ 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" @@ -177,6 +200,20 @@ 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" @@ -269,6 +306,17 @@ 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" @@ -840,6 +888,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -933,6 +982,33 @@ 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" @@ -1293,6 +1369,12 @@ 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" @@ -1328,6 +1410,12 @@ 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" @@ -1339,6 +1427,24 @@ 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" @@ -1713,6 +1819,16 @@ 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" @@ -1796,6 +1912,27 @@ 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" @@ -2585,6 +2722,15 @@ 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" @@ -2659,7 +2805,7 @@ dependencies = [ "cfg_aliases 0.2.1", "hashbrown 0.16.1 (git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc)", "hexf-parse", - "indexmap", + "indexmap 2.13.0", "makepad-error-log", "makepad-half", "num-traits 0.2.20", @@ -2832,6 +2978,7 @@ dependencies = [ "getrandom 0.2.17", "image", "js-sys", + "keyring", "lettre", "makepad-widgets", "matrix_client", @@ -3246,12 +3393,75 @@ 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" @@ -3432,6 +3642,16 @@ 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" @@ -3654,6 +3874,15 @@ 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" @@ -4353,6 +4582,25 @@ 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" @@ -4431,6 +4679,17 @@ 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" @@ -4799,6 +5058,36 @@ 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" @@ -4851,9 +5140,21 @@ 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" @@ -4886,6 +5187,17 @@ 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" @@ -4988,6 +5300,7 @@ checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -5489,6 +5802,19 @@ 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" @@ -5680,6 +6006,15 @@ 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" @@ -5715,6 +6050,87 @@ 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" @@ -5826,3 +6242,44 @@ 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 fedd359..8b70397 100644 --- a/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md +++ b/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md @@ -531,6 +531,7 @@ 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. @@ -700,8 +701,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`: `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. | +| ~~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. | #### C1 — DECIDED: support both, user-selectable @@ -771,7 +772,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 (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. | +| ~~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. | ##### 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 c56def9..c610638 100644 --- a/crates/apps/nigig-email/src/email_frame/pages/bulk.rs +++ b/crates/apps/nigig-email/src/email_frame/pages/bulk.rs @@ -1,7 +1,9 @@ use makepad_widgets::*; use nigig_core::email_account::AccountDraft; -use nigig_core::email_send::EmailSendRequest; -use nigig_core::email_worker::{spawn_send_email, spawn_smtp_test, EmailWorkerAction, SmtpConfig}; +use nigig_core::email_send::{EmailSendRequest, MAX_RECIPIENTS}; +use nigig_core::email_worker::{ + spawn_bulk_send, spawn_send_email, spawn_smtp_test, EmailWorkerAction, SmtpConfig, +}; script_mod! { use mod.prelude.widgets.* @@ -158,22 +160,22 @@ impl Widget for EmailBulkPage { let message = self.text_input(cx, ids!(message_input)).text(); let config = self.smtp_config.clone(); - // 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) { + // 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) { Err(e) => { self.label(cx, ids!(send_status)).set_text(cx, &e.message()); self.pending_send = None; self.view.redraw(cx); } - Ok((req, list)) => { + Ok((recipients, 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 = req.recipient_count(); + let n = recipients.len(); let armed = self .pending_send .as_ref() @@ -187,6 +189,12 @@ 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())); } @@ -194,9 +202,20 @@ impl Widget for EmailBulkPage { self.view.redraw(cx); } else { self.pending_send = None; - self.label(cx, ids!(send_status)) - .set_text(cx, &format!("Sending to {n} recipient(s)…")); - spawn_send_email(config, to, subject, message); + 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.set_send_button_label(cx, true); self.view.redraw(cx); } @@ -221,6 +240,27 @@ 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 fc93dca..f8be217 100644 --- a/crates/apps/nigig-email/src/email_frame/pages/inbox.rs +++ b/crates/apps/nigig-email/src/email_frame/pages/inbox.rs @@ -186,6 +186,9 @@ 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, @@ -238,6 +241,10 @@ 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 @@ -478,6 +485,26 @@ 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 a90e12b..4115037 100644 --- a/crates/nigig-core/Cargo.toml +++ b/crates/nigig-core/Cargo.toml @@ -12,6 +12,11 @@ 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 } @@ -51,6 +56,8 @@ 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 ef9d8b3..70dde7a 100644 --- a/crates/nigig-core/src/credential_store.rs +++ b/crates/nigig-core/src/credential_store.rs @@ -70,17 +70,73 @@ impl CredentialStore for UnavailableCredentialStore { /// The credential store in use on this build. /// -/// 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. +/// 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. pub fn active_store() -> &'static dyn CredentialStore { - // 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(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()) + } } #[cfg(test)] diff --git a/crates/nigig-core/src/email_bulk.rs b/crates/nigig-core/src/email_bulk.rs new file mode 100644 index 0000000..3e44e20 --- /dev/null +++ b/crates/nigig-core/src/email_bulk.rs @@ -0,0 +1,376 @@ +//! 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 1a0ca13..ba9e155 100644 --- a/crates/nigig-core/src/email_send.rs +++ b/crates/nigig-core/src/email_send.rs @@ -359,6 +359,49 @@ 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() } @@ -556,6 +599,37 @@ 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 004746b..cdb69ba 100644 --- a/crates/nigig-core/src/email_worker.rs +++ b/crates/nigig-core/src/email_worker.rs @@ -401,6 +401,67 @@ 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"))] @@ -806,6 +867,9 @@ 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 4006762..12c3761 100644 --- a/crates/nigig-core/src/lib.rs +++ b/crates/nigig-core/src/lib.rs @@ -26,6 +26,7 @@ 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 76d5538..5f59cbb 100644 --- a/tools/test-email-coverage.sh +++ b/tools/test-email-coverage.sh @@ -53,6 +53,7 @@ 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 @@ -130,6 +131,7 @@ 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" @@ -163,8 +165,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", "credential_store.rs", "email_cache.rs", - "email_session.rs", "imap_client.rs") + "email_pacing.rs", "email_bulk.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)]