Implements #364 — respect Microsoft Graph throttling on the Outlook/Graph path.
Context
Outlook is the odd one out of the provider set: mail is read over IMAP (outlook.office.com) but sent over Microsoft Graph (me/sendMail). The only Graph surface today is the send path, and its OAuth token is Mail.Send-scoped. This change builds a throttle-aware Graph HTTP layer and routes the live send path through it, composing with #360's AccountThrottleGate.
What landed (org.libremail.mail.graph)
GraphHttpClient — the single Graph HTTP transport seam. Preserves the send path's may-have-sent distinction (GraphTransportException: transmit-fail = safe, lost-response = maybe-sent) and parses Retry-After (delta-seconds + HTTP-date).
GraphThrottle — caps Graph concurrency at 4 (Graph 429s the 5th concurrent request), honors a 429/503 Retry-After via the shared per-account backoff gate (retry after the honored wait, bounded), and clears the backoff on a 2xx. Every Graph call goes through it.
GraphBatch — multiplexes ops via $batch (≤20/call), collapsing N calls to ceil(N/20); feeds per-op 429s inside the envelope back into the gate.
GraphUploadSession — createUploadSession + chunked Content-Range PUTs for content over Graph's ~4 MB one-shot ceiling (320 KiB-multiple chunks).
GraphSender.send now honors a Graph 429 once (Retry-After) before falling back to SMTP, and records the throttle so the account's IMAP background work backs off too.
GraphThrottle drives the existing AccountThrottleGate via ThrottleClassifier.classifyHttpStatus — no duplication. Because the gate is account-keyed and shared with the IMAP sync/backfill paths, a Graph send 429 cross-cools that account's background IMAP work, and a clean Graph response clears it.
Scope boundary (called out honestly)
$batch reads and draft-based chunked attachment upload need Mail.ReadWrite; the mail token is Mail.Send-only, so forcing that scope would re-consent every existing Outlook user — deliberately out of scope for a perf ticket. Both ship as fully-tested Graph-layer capabilities ready for a future read/draft surface. The oversized-attachment send path keeps its existing SMTP fallback (SMTP streams large files, needs no scope change).
Tests
JVM unit suites (MockK the HTTP seam, coroutines-test virtual time, no real sleeps): 429+Retry-After honored with backoff + gate composition, $batchreduces call count (25 ops → 2 calls), chunked upload for an over-threshold attachment (contiguous ranges reassemble to the original bytes), plus transport, parser, and the reworked GraphSenderSendTest.
Instrumented GraphThrottleInstrumentedTest exercises the toolkit under the Android runtime (compiles + runs on the CI matrix).
PII-free AppLog (accountLogRef) throughout; SPDX on every file.
Full 7-task local gate green under JDK 21 (assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt). Local emulator E2E is flaky here — leaning on the CI matrix as authoritative.
Conflict-awareness (#361/#362/#363)
Fully additive on the Graph path — no shared throttle/config file touched (AccountThrottleGate, ThrottleClassifier, ThrottleSignal, ThrottleBackoff, SendWorker all unchanged). Only GraphSender.kt is modified. Zero overlap with the IMAP sibling tickets.
Implements #364 — respect Microsoft Graph throttling on the Outlook/Graph path.
## Context
Outlook is the odd one out of the provider set: mail is **read over IMAP** (`outlook.office.com`) but **sent over Microsoft Graph** (`me/sendMail`). The only Graph surface today is the send path, and its OAuth token is **`Mail.Send`-scoped**. This change builds a throttle-aware Graph HTTP layer and routes the live send path through it, composing with #360's `AccountThrottleGate`.
## What landed (`org.libremail.mail.graph`)
- **`GraphHttpClient`** — the single Graph HTTP transport seam. Preserves the send path's may-have-sent distinction (`GraphTransportException`: transmit-fail = safe, lost-response = maybe-sent) and parses `Retry-After` (delta-seconds + HTTP-date).
- **`GraphThrottle`** — caps Graph concurrency at **4** (Graph 429s the 5th concurrent request), honors a **429/503 `Retry-After`** via the shared per-account backoff gate (retry after the honored wait, bounded), and clears the backoff on a 2xx. Every Graph call goes through it.
- **`GraphBatch`** — multiplexes ops via **`$batch`** (≤20/call), collapsing N calls to `ceil(N/20)`; feeds per-op 429s inside the envelope back into the gate.
- **`GraphUploadSession`** — `createUploadSession` + **chunked `Content-Range` PUTs** for content over Graph's ~4 MB one-shot ceiling (320 KiB-multiple chunks).
- **`GraphSender.send`** now honors a Graph 429 once (`Retry-After`) before falling back to SMTP, and records the throttle so the account's IMAP background work backs off too.
## Composition with #360
`GraphThrottle` drives the existing `AccountThrottleGate` via `ThrottleClassifier.classifyHttpStatus` — no duplication. Because the gate is account-keyed and shared with the IMAP sync/backfill paths, a Graph send 429 **cross-cools** that account's background IMAP work, and a clean Graph response clears it.
## Scope boundary (called out honestly)
`$batch` reads and draft-based chunked attachment upload need `Mail.ReadWrite`; the mail token is `Mail.Send`-only, so forcing that scope would re-consent every existing Outlook user — deliberately **out of scope** for a perf ticket. Both ship as fully-tested Graph-layer capabilities ready for a future read/draft surface. The oversized-attachment send path keeps its existing SMTP fallback (SMTP streams large files, needs no scope change).
## Tests
- JVM unit suites (MockK the HTTP seam, coroutines-test **virtual time, no real sleeps**): 429+`Retry-After` honored with backoff + gate composition, `$batch` **reduces call count** (25 ops → 2 calls), **chunked upload** for an over-threshold attachment (contiguous ranges reassemble to the original bytes), plus transport, parser, and the reworked `GraphSenderSendTest`.
- Instrumented `GraphThrottleInstrumentedTest` exercises the toolkit under the Android runtime (compiles + runs on the CI matrix).
- PII-free `AppLog` (`accountLogRef`) throughout; SPDX on every file.
Full 7-task local gate green under JDK 21 (assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt). Local emulator E2E is flaky here — leaning on the CI matrix as authoritative.
## Conflict-awareness (#361/#362/#363)
Fully additive on the **Graph** path — **no shared throttle/config file touched** (`AccountThrottleGate`, `ThrottleClassifier`, `ThrottleSignal`, `ThrottleBackoff`, `SendWorker` all unchanged). Only `GraphSender.kt` is modified. Zero overlap with the IMAP sibling tickets.
Closes #364
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.
Implements #364 — respect Microsoft Graph throttling on the Outlook/Graph path.
Context
Outlook is the odd one out of the provider set: mail is read over IMAP (
outlook.office.com) but sent over Microsoft Graph (me/sendMail). The only Graph surface today is the send path, and its OAuth token isMail.Send-scoped. This change builds a throttle-aware Graph HTTP layer and routes the live send path through it, composing with #360'sAccountThrottleGate.What landed (
org.libremail.mail.graph)GraphHttpClient— the single Graph HTTP transport seam. Preserves the send path's may-have-sent distinction (GraphTransportException: transmit-fail = safe, lost-response = maybe-sent) and parsesRetry-After(delta-seconds + HTTP-date).GraphThrottle— caps Graph concurrency at 4 (Graph 429s the 5th concurrent request), honors a 429/503Retry-Aftervia the shared per-account backoff gate (retry after the honored wait, bounded), and clears the backoff on a 2xx. Every Graph call goes through it.GraphBatch— multiplexes ops via$batch(≤20/call), collapsing N calls toceil(N/20); feeds per-op 429s inside the envelope back into the gate.GraphUploadSession—createUploadSession+ chunkedContent-RangePUTs for content over Graph's ~4 MB one-shot ceiling (320 KiB-multiple chunks).GraphSender.sendnow honors a Graph 429 once (Retry-After) before falling back to SMTP, and records the throttle so the account's IMAP background work backs off too.Composition with #360
GraphThrottledrives the existingAccountThrottleGateviaThrottleClassifier.classifyHttpStatus— no duplication. Because the gate is account-keyed and shared with the IMAP sync/backfill paths, a Graph send 429 cross-cools that account's background IMAP work, and a clean Graph response clears it.Scope boundary (called out honestly)
$batchreads and draft-based chunked attachment upload needMail.ReadWrite; the mail token isMail.Send-only, so forcing that scope would re-consent every existing Outlook user — deliberately out of scope for a perf ticket. Both ship as fully-tested Graph-layer capabilities ready for a future read/draft surface. The oversized-attachment send path keeps its existing SMTP fallback (SMTP streams large files, needs no scope change).Tests
Retry-Afterhonored with backoff + gate composition,$batchreduces call count (25 ops → 2 calls), chunked upload for an over-threshold attachment (contiguous ranges reassemble to the original bytes), plus transport, parser, and the reworkedGraphSenderSendTest.GraphThrottleInstrumentedTestexercises the toolkit under the Android runtime (compiles + runs on the CI matrix).AppLog(accountLogRef) throughout; SPDX on every file.Full 7-task local gate green under JDK 21 (assembleDebug, testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt). Local emulator E2E is flaky here — leaning on the CI matrix as authoritative.
Conflict-awareness (#361/#362/#363)
Fully additive on the Graph path — no shared throttle/config file touched (
AccountThrottleGate,ThrottleClassifier,ThrottleSignal,ThrottleBackoff,SendWorkerall unchanged). OnlyGraphSender.ktis modified. Zero overlap with the IMAP sibling tickets.Closes #364
Merge Queue Status
2026-07-09 01:30 UTC· Rule:default· triggered by merge protections2026-07-09 02:19 UTC· atc5144ebe03fbb7af26f00e32168665219f1baa3e· mergeThis pull request spent 49 minutes 16 seconds in the queue, including 24 minutes 46 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