Closes#363. Builds on the shared throttling framework already on main (#360's AccountThrottleGate, #356's BackfillPacer) by adding two iCloud-specific, provider-scoped policies, kept as new, self-contained files rather than a shared cross-provider table — per this ticket's parallel-safety note, since sibling issues #361 (Gmail) / #362 (Yahoo) / #364 (Outlook) are being implemented simultaneously and add their own provider's limits the same way.
1. Conservative simultaneous-connection cap — IcloudConnectionLimiter
Apple documents 5-8 concurrent IMAP connections per account; this pins to the conservative low end (5) via a per-account permit gate (kotlinx.coroutines.sync.Semaphore). Wired into MailBackfiller around both connection-opening call sites — the header-page fetch and the body/attachment prefetch loop, i.e. the "1 + K + attachments" per-page connection count issue #363's Context section describes. A no-op passthrough for every other provider (checked via MailProvider.forImapHost).
It composes with the existing framework rather than duplicating it:
#360 (AccountThrottleGate) is reactive (backs off after a throttle signal); MailBackfiller already checks it first and skips a throttled account for the rest of the slice. This gate is proactive — it keeps LibreMail's own request pattern from approaching that point.
#356 (BackfillPacer) paces how often a new slice starts (BackfillWorker composes pacer.runPaced { mailBackfiller.runBackfill() }); this gate bounds how many connections may be open within a slice — a different axis at a layer beneath the pacer, so neither fights the other.
2. Message-size limit — IcloudSendLimits
Enforces Apple's ~20 MB outgoing message-size cap before SmtpSender ever opens a connection. Estimates the actual encoded wire size (base64 inflates binary attachment bytes by ~4/3) rather than comparing raw file bytes directly — mirroring GraphSender's existing pre-send attachment-size guard (same fail-fast-locally philosophy). An over-cap send throws MessageTooLargeException, caught by SendWorker's existing runCatching/fold and turned into a clean, PII-free outbox error (outboxDao.setError) — no crash, no raw provider rejection, matches the existing "Graph rejection" handling shape.
Shared files touched (conflict-awareness for #361/#362/#364)
MailBackfiller.kt: new constructor dependency (icloudConnectionLimiter) + the two connection call sites wrapped in icloudConnectionLimiter.withPermit(account) { ... }. Also updated the 3 test files that construct MailBackfiller directly (MailBackfillerTest, MailMaintenanceGateTest, MailSyncConcurrencyTest) to pass the new dependency.
SendWorker.kt: one guard call (IcloudSendLimits.requireWithinLimit(...)) before smtpSender.send(...).
config/detekt/detekt.yml: added MailBackfillerTest.kt to the existing LargeClass excludes list (mirroring the pre-existing MailRepositoryImplTest exclusion) — the new connection-cap wiring tests tipped this cohesive single-SUT suite over detekt's LLOC boundary.
Both production edits are small and additive (a new constructor param + wrapped call sites; one guard call) — flagged here in case #361/#362/#364 touch the same regions.
Testing
IcloudConnectionLimiterTest (7 tests, JVM): cap enforcement (a caller past the cap waits for a permit), concurrency up to the cap, per-account isolation, non-iCloud passthrough (never gated), permit release on exception, PII-free logging, production cap value.
IcloudSendLimitsTest (9 tests, JVM): under/at/over the encoded-size boundary (including a case where raw attachment bytes look like they fit but base64 inflation pushes the estimate over — proving the guard isn't a naive raw-byte comparison), body bytes counted, non-iCloud passthrough, PII-free logging, exception message content.
MailBackfillerTest (+2 tests): real wiring proof — an iCloud account's backfill waits for a connection permit held elsewhere, then proceeds once it frees; a non-iCloud account is never gated by the same limiter instance.
SendWorkerTest (+2 tests): an oversized iCloud message fails cleanly without ever calling smtpSender.send (Result.retry(), clear PII-free outbox error); an iCloud message within the cap sends normally.
IcloudConnectionLimiterInstrumentedTest (androidTest, compiles): on-device proof using only the production @Inject constructor (mirrors BackfillPacerInstrumentedTest's convention of not relying on internal test-only constructors, which androidTest doesn't see).
Logging is PII-free throughout (accountLogRef, byte counts only, no host/email) via AppLog.
Gate status
Local 7-task fast gate green with JAVA_HOME overridden to JDK 21 (assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt). Per this ticket's instructions, the local emulator E2E was skipped (flaky in this environment) — CI's matrix is authoritative for the instrumented test.
Not arming auto-merge per instructions — please review.
## Summary
Closes #363. Builds on the shared throttling framework already on `main` (#360's `AccountThrottleGate`, #356's `BackfillPacer`) by adding two **iCloud-specific, provider-scoped** policies, kept as new, self-contained files rather than a shared cross-provider table — per this ticket's parallel-safety note, since sibling issues #361 (Gmail) / #362 (Yahoo) / #364 (Outlook) are being implemented simultaneously and add their own provider's limits the same way.
### 1. Conservative simultaneous-connection cap — `IcloudConnectionLimiter`
Apple documents 5-8 concurrent IMAP connections per account; this pins to the conservative low end (5) via a per-account permit gate (`kotlinx.coroutines.sync.Semaphore`). Wired into `MailBackfiller` around both connection-opening call sites — the header-page fetch and the body/attachment prefetch loop, i.e. the "`1 + K + attachments`" per-page connection count issue #363's Context section describes. A no-op passthrough for every other provider (checked via `MailProvider.forImapHost`).
It composes with the existing framework rather than duplicating it:
- **#360 (`AccountThrottleGate`)** is reactive (backs off *after* a throttle signal); `MailBackfiller` already checks it first and skips a throttled account for the rest of the slice. This gate is proactive — it keeps LibreMail's own request pattern from approaching that point.
- **#356 (`BackfillPacer`)** paces *how often* a new slice starts (`BackfillWorker` composes `pacer.runPaced { mailBackfiller.runBackfill() }`); this gate bounds *how many* connections may be open *within* a slice — a different axis at a layer beneath the pacer, so neither fights the other.
### 2. Message-size limit — `IcloudSendLimits`
Enforces Apple's ~20 MB outgoing message-size cap before `SmtpSender` ever opens a connection. Estimates the actual *encoded* wire size (base64 inflates binary attachment bytes by ~4/3) rather than comparing raw file bytes directly — mirroring `GraphSender`'s existing pre-send attachment-size guard (same fail-fast-locally philosophy). An over-cap send throws `MessageTooLargeException`, caught by `SendWorker`'s existing `runCatching`/`fold` and turned into a clean, PII-free outbox error (`outboxDao.setError`) — no crash, no raw provider rejection, matches the existing "Graph rejection" handling shape.
## Shared files touched (conflict-awareness for #361/#362/#364)
- **`MailBackfiller.kt`**: new constructor dependency (`icloudConnectionLimiter`) + the two connection call sites wrapped in `icloudConnectionLimiter.withPermit(account) { ... }`. Also updated the 3 test files that construct `MailBackfiller` directly (`MailBackfillerTest`, `MailMaintenanceGateTest`, `MailSyncConcurrencyTest`) to pass the new dependency.
- **`SendWorker.kt`**: one guard call (`IcloudSendLimits.requireWithinLimit(...)`) before `smtpSender.send(...)`.
- **`config/detekt/detekt.yml`**: added `MailBackfillerTest.kt` to the existing `LargeClass` excludes list (mirroring the pre-existing `MailRepositoryImplTest` exclusion) — the new connection-cap wiring tests tipped this cohesive single-SUT suite over detekt's LLOC boundary.
Both production edits are small and additive (a new constructor param + wrapped call sites; one guard call) — flagged here in case #361/#362/#364 touch the same regions.
## Testing
- `IcloudConnectionLimiterTest` (7 tests, JVM): cap enforcement (a caller past the cap waits for a permit), concurrency up to the cap, per-account isolation, non-iCloud passthrough (never gated), permit release on exception, PII-free logging, production cap value.
- `IcloudSendLimitsTest` (9 tests, JVM): under/at/over the encoded-size boundary (including a case where raw attachment bytes look like they fit but base64 inflation pushes the estimate over — proving the guard isn't a naive raw-byte comparison), body bytes counted, non-iCloud passthrough, PII-free logging, exception message content.
- `MailBackfillerTest` (+2 tests): real wiring proof — an iCloud account's backfill waits for a connection permit held elsewhere, then proceeds once it frees; a non-iCloud account is never gated by the same limiter instance.
- `SendWorkerTest` (+2 tests): an oversized iCloud message fails cleanly without ever calling `smtpSender.send` (`Result.retry()`, clear PII-free outbox error); an iCloud message within the cap sends normally.
- `IcloudConnectionLimiterInstrumentedTest` (androidTest, compiles): on-device proof using only the production `@Inject` constructor (mirrors `BackfillPacerInstrumentedTest`'s convention of not relying on `internal` test-only constructors, which `androidTest` doesn't see).
Logging is PII-free throughout (`accountLogRef`, byte counts only, no host/email) via `AppLog`.
## Gate status
Local 7-task fast gate green with `JAVA_HOME` overridden to JDK 21 (`assembleDebug`, `testDebugUnitTest`, `jacocoTestCoverageVerification`, `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, `detekt`). Per this ticket's instructions, the local emulator E2E was skipped (flaky in this environment) — CI's matrix is authoritative for the instrumented test.
Not arming auto-merge per instructions — please review.
✅Entered queue — 2026-07-09 00:57 UTC · Rule: default · triggered by merge protections
🚫Left the queue — 2026-07-09 01:15 UTC · at 8a70b329ab0a28fe93e5d4cefcf1bfffdb07ac63
This pull request spent 17 minutes 33 seconds in the queue, with no time running CI.
Reason
The pull request conflicts with the base branch
Hint
You should update or rebase your pull request.
If you want to requeue this pull request, you can post a @mergifyio queue comment.
Requeued — the merge queue status continues in this comment ↓.
<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"version": 1, "state": "dequeued", "queue_rule_name": "default", "queued_at": "2026-07-09T00:57:55.671259+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []}
-*- Mergify Payload End -*-
-->
# Merge Queue Status
- ✅ **Entered queue** — `2026-07-09 00:57 UTC` · Rule: `default` · triggered by merge protections
- 🚫 **Left the queue** — `2026-07-09 01:15 UTC` · at `8a70b329ab0a28fe93e5d4cefcf1bfffdb07ac63`
This pull request spent **17 minutes 33 seconds** in the queue, with no time running CI.
## Reason
The pull request conflicts with the base branch
## Hint
You should update or rebase your pull request.
If you want to requeue this pull request, you can post a `@mergifyio queue` comment.
Requeued — the merge queue status continues in [this comment ↓](https://github.com/JMR-dev/LibreMail/pull/471#issuecomment-4921432397).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Closes #363. Builds on the shared throttling framework already on
main(#360'sAccountThrottleGate, #356'sBackfillPacer) by adding two iCloud-specific, provider-scoped policies, kept as new, self-contained files rather than a shared cross-provider table — per this ticket's parallel-safety note, since sibling issues #361 (Gmail) / #362 (Yahoo) / #364 (Outlook) are being implemented simultaneously and add their own provider's limits the same way.1. Conservative simultaneous-connection cap —
IcloudConnectionLimiterApple documents 5-8 concurrent IMAP connections per account; this pins to the conservative low end (5) via a per-account permit gate (
kotlinx.coroutines.sync.Semaphore). Wired intoMailBackfilleraround both connection-opening call sites — the header-page fetch and the body/attachment prefetch loop, i.e. the "1 + K + attachments" per-page connection count issue #363's Context section describes. A no-op passthrough for every other provider (checked viaMailProvider.forImapHost).It composes with the existing framework rather than duplicating it:
AccountThrottleGate) is reactive (backs off after a throttle signal);MailBackfilleralready checks it first and skips a throttled account for the rest of the slice. This gate is proactive — it keeps LibreMail's own request pattern from approaching that point.BackfillPacer) paces how often a new slice starts (BackfillWorkercomposespacer.runPaced { mailBackfiller.runBackfill() }); this gate bounds how many connections may be open within a slice — a different axis at a layer beneath the pacer, so neither fights the other.2. Message-size limit —
IcloudSendLimitsEnforces Apple's ~20 MB outgoing message-size cap before
SmtpSenderever opens a connection. Estimates the actual encoded wire size (base64 inflates binary attachment bytes by ~4/3) rather than comparing raw file bytes directly — mirroringGraphSender's existing pre-send attachment-size guard (same fail-fast-locally philosophy). An over-cap send throwsMessageTooLargeException, caught bySendWorker's existingrunCatching/foldand turned into a clean, PII-free outbox error (outboxDao.setError) — no crash, no raw provider rejection, matches the existing "Graph rejection" handling shape.Shared files touched (conflict-awareness for #361/#362/#364)
MailBackfiller.kt: new constructor dependency (icloudConnectionLimiter) + the two connection call sites wrapped inicloudConnectionLimiter.withPermit(account) { ... }. Also updated the 3 test files that constructMailBackfillerdirectly (MailBackfillerTest,MailMaintenanceGateTest,MailSyncConcurrencyTest) to pass the new dependency.SendWorker.kt: one guard call (IcloudSendLimits.requireWithinLimit(...)) beforesmtpSender.send(...).config/detekt/detekt.yml: addedMailBackfillerTest.ktto the existingLargeClassexcludes list (mirroring the pre-existingMailRepositoryImplTestexclusion) — the new connection-cap wiring tests tipped this cohesive single-SUT suite over detekt's LLOC boundary.Both production edits are small and additive (a new constructor param + wrapped call sites; one guard call) — flagged here in case #361/#362/#364 touch the same regions.
Testing
IcloudConnectionLimiterTest(7 tests, JVM): cap enforcement (a caller past the cap waits for a permit), concurrency up to the cap, per-account isolation, non-iCloud passthrough (never gated), permit release on exception, PII-free logging, production cap value.IcloudSendLimitsTest(9 tests, JVM): under/at/over the encoded-size boundary (including a case where raw attachment bytes look like they fit but base64 inflation pushes the estimate over — proving the guard isn't a naive raw-byte comparison), body bytes counted, non-iCloud passthrough, PII-free logging, exception message content.MailBackfillerTest(+2 tests): real wiring proof — an iCloud account's backfill waits for a connection permit held elsewhere, then proceeds once it frees; a non-iCloud account is never gated by the same limiter instance.SendWorkerTest(+2 tests): an oversized iCloud message fails cleanly without ever callingsmtpSender.send(Result.retry(), clear PII-free outbox error); an iCloud message within the cap sends normally.IcloudConnectionLimiterInstrumentedTest(androidTest, compiles): on-device proof using only the production@Injectconstructor (mirrorsBackfillPacerInstrumentedTest's convention of not relying oninternaltest-only constructors, whichandroidTestdoesn't see).Logging is PII-free throughout (
accountLogRef, byte counts only, no host/email) viaAppLog.Gate status
Local 7-task fast gate green with
JAVA_HOMEoverridden to JDK 21 (assembleDebug,testDebugUnitTest,jacocoTestCoverageVerification,compileDebugAndroidTestKotlin,lintDebug,ktlintCheck,detekt). Per this ticket's instructions, the local emulator E2E was skipped (flaky in this environment) — CI's matrix is authoritative for the instrumented test.Not arming auto-merge per instructions — please review.
Merge Queue Status
2026-07-09 00:57 UTC· Rule:default· triggered by merge protections2026-07-09 01:15 UTC· at8a70b329ab0a28fe93e5d4cefcf1bfffdb07ac63This pull request spent 17 minutes 33 seconds in the queue, with no time running CI.
Reason
The pull request conflicts with the base branch
Hint
You should update or rebase your pull request.
If you want to requeue this pull request, you can post a
@mergifyio queuecomment.Requeued — the merge queue status continues in this comment ↓.
Merge Queue Status
2026-07-09 03:58 UTC· Rule:default· triggered by merge protections2026-07-09 04:04 UTC· atd926320123f12439b554d74a75047bbedb42432f· mergeThis pull request spent 5 minutes 43 seconds in the queue, including 2 seconds running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed