Compare commits

..

3 commits

Author SHA1 Message Date
2a74c6cac4 ci(email): gate the keystore feature, cover email_bulk
Some checks failed
email.yml / ci(email): gate the keystore feature, cover email_bulk (push) Failing after 0s
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
email.yml: the feature-compile check now covers imap,keystore together.
test-email-coverage.sh instruments email_bulk.rs (91.9% line) alongside the
rest of the domain; total 89.84%, floors enforced.

The review doc records C6/C7/C1f as fully closed, with the honest caveats
unchanged (network sockets and the OS vault are compile-checked, not
runtime-verified).
2026-08-17 04:29:30 +00:00
595ad6ad24 feat(email): pull-to-refresh (C7) and a real OS keystore (C1f)
C7: pull-to-refresh on the inbox, mirroring the SMS/M-Pesa transaction
lists (scrolled + scroll_position over a threshold, throttled to 1.2s and
guarded by the in-flight flag). The Refresh button remains for platforms
without a gesture.

C1f: a real KeyringCredentialStore behind the keystore feature -- the OS
credential vault (Linux Secret Service, Windows Credential Manager, macOS
Keychain) via the keyring crate, so IMAP credentials can survive a restart.
Native only; without the feature active_store() stays fail-closed. The
runtime vault is not host-verified (no secret service in CI), which is the
same honest caveat as the IMAP transport.
2026-08-17 04:29:30 +00:00
765e178737 feat(email): paced bulk send — batch and pace large recipient lists (C6)
The Bulk tab was capped at MAX_RECIPIENTS (100): a 500-recipient list was
refused with TooManyRecipients, not paced. That is a capped single send,
not bulk.

email_bulk.rs: bulk_send_plan splits a list into provider-sized batches
with a pacing schedule (pure, tested), and run_bulk_send executes the plan
— gap between batches, rate-limiter backstop, abandon check between every
step, per-batch progress. Tested against a mock send (batching, delays,
failed-batch counting, abandon).

email_send.rs: validate_bulk_message accepts a list over the cap (the
caller batches it) while still enforcing subject/body limits.

The Bulk page now sends <=100 recipients as one message and anything over
as paced batches, posting BulkSendProgress after each batch and at the end.
Domain tests 195 -> 206.
2026-08-17 04:29:30 +00:00
12 changed files with 1140 additions and 34 deletions

View file

@ -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

459
Cargo.lock generated
View file

@ -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",
]

View file

@ -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

View file

@ -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
),
);
}
}
}
}
}

View file

@ -186,6 +186,9 @@ pub struct EmailInboxPage {
/// C4b: the last fetch failed; the message is shown in the status view.
#[rust]
load_error: Option<String>,
/// C7: last time a pull-to-refresh fired, for the throttle.
#[rust]
last_refresh_secs: f64,
#[rust]
current_thread_view: Option<LiveId>,
@ -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);

View file

@ -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"

View file

@ -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<Option<Secret>, 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)]

View file

@ -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<String>,
/// 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<BulkBatch> {
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<F, Fut>(
plan: Vec<BulkBatch>,
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<Output = Result<(), String>>,
{
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<String> {
(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::<String>::new()));
let progress_log = std::sync::Arc::new(std::sync::Mutex::new(Vec::<BulkProgress>::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<String> = (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");
}
}

View file

@ -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<String>, 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<String> = (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!(

View file

@ -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<String>, 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<Vec<crate::email_store::EmailMessage>, 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)]

View file

@ -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;

View file

@ -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)]