Add a throttle-aware Microsoft Graph HTTP layer (org.libremail.mail.graph) and route
the live me/sendMail path through it, composing with #360's AccountThrottleGate:
- GraphHttpClient: the single Graph HTTP transport seam; preserves the send path's
may-have-sent distinction (GraphTransportException) and parses Retry-After.
- GraphThrottle: caps Graph concurrency at 4, honors a 429/503 Retry-After via the
shared per-account backoff gate (retry after the honored wait, bounded), clears it
on a 2xx. Because the gate is shared+account-keyed, a Graph 429 also cools that
account's IMAP background work down.
- GraphBatch: multiplexes ops via $batch (<=20/call), collapsing N calls to
ceil(N/20); feeds per-op 429s 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, instead of failing over on the first throttle.
Outlook mail is read over IMAP and the Graph token is Mail.Send-scoped, so $batch
reads and draft-based chunked attachment upload have no live call site yet (they need
the Mail.ReadWrite scope, a re-consent-forcing change kept out of this perf ticket);
both ship as fully-tested capabilities of the Graph layer.
Unit tests (MockK the HTTP client, coroutines-test virtual time, no real sleeps) cover
429+Retry-After backoff, $batch call-count reduction, and chunked upload; an
instrumented test exercises the toolkit under the Android runtime. PII-free AppLog
(accountLogRef) throughout; SPDX on every file.
Closes#364
Add Gmail's documented IMAP caps (15 max simultaneous connections, 2,500 MB/day
download, 500 MB/day upload, 10,000 messages/labels per limit) as provider-scoped
config/policy that feeds the existing #360/#356 pacing machinery instead of
reinventing it:
- GmailSyncLimits: pure constants + `appliesTo(account)` provider detection via the
existing MailProvider.forImapHost lookup (no changes to MailProvider itself).
- GmailBandwidthTracker: a new, per-account/per-day download-byte tracker (mirrors
AccountThrottleGate's shape: ConcurrentHashMap state, injectable clock, PII-free
once-per-crossing AppLog breadcrumb). Proactive and orthogonal to the #360
AccountThrottleGate (which only reacts to a provider-issued throttle) and #356's
BackfillPacer (which paces slice cadence, not bytes) - same relationship
InteractiveImapGate already documents having to AccountThrottleGate.
Wiring: MailRepositoryImpl.prefetchMessage (the single funnel both MailBackfiller
and MailSyncer's background prefetch already share) records bytes actually pulled
over the network for Gmail accounts; MailBackfiller/MailSyncer's prefetchIfEnabled
consult isOverDailyBudget once per account before starting a batch and defer
body/attachment prefetch for the rest of the day once Gmail's budget is reached -
header paging/sync is never gated, and interactive fetches (open, attachment tap,
inline images) are never gated either, matching the existing interactive-priority
principle (#355/#360).
The 15-connection cap is already satisfied by the existing architecture
(ImapConnectionCache keeps one reused connection per account plus one dedicated
IDLE connection - 2 total, well under the cap); GmailSyncLimitsTest pins that
invariant against the documented ceiling so a future change that grows per-account
concurrency trips a test before it could approach Gmail's real limit. The
10k-messages-per-label figure is captured as a documented constant only - it is
deliberately NOT wired into a backfill stop condition, since issue #12's full
history backfill is intentional and a large real mailbox can exceed 10k messages.
AccountThrottleGate, BackfillPacer, ThrottleClassifier/ThrottleSignal/ThrottleBackoff,
InteractiveImapGate, and MailProvider are all untouched.
Closes#361
The outbound-link E2E tests verified the opened page with Espresso-Intents
(intending(ACTION_VIEW).respondWith(...) + intended(...)). intended() runs an
onView(isRoot()).check(...) whose RootViewPicker waits up to 10s for a
window-focused root. On the CI matrix emulator the activity window
intermittently reports has-window-focus=false, so the assertion flakes with
RootViewPicker$RootViewWithoutFocusException, failing the whole E2E leg and
forcing a 9-min retry. The intent stubs were already present and do NOT fix
this: no external activity launches, the focus loss is environmental (the same
run failed 8 unrelated RootViewPicker-based tests at once).
Verify these ACTION_VIEW/browser-open link taps by injecting a recording
LocalUriHandler and asserting the exact URL the screen opens. That keeps the
tests entirely on Compose interactions, which do not depend on window focus
(280+ Compose-only tests passed in the same failing run), so they are
deterministic without weakening the assertion (still asserts the provider
page / host).
Converted (ACTION_VIEW / UriHandler "browser-open" shape):
- AppPasswordSetupScreenTest.tappingCreateAppPasswordPage_launchesBrowserIntentToHelpUrl
- AppPasswordSetupScreenTest.imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage
- OutlookImapNoticeScreenTest.tappingImapHelpLink_opensTheMicrosoftArticle
Real-intent tests (hasComponent/Settings action, no UriHandler seam) keep
Espresso-Intents and are out of scope here.