fix(sync): SMTP send failures never feed AccountThrottleGate #496

Open
opened 2026-07-10 19:15:15 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:15:15 +00:00 (Migrated from github.com)

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).

Triage: below the cut (confirmed, medium) — backlog.

app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt:138 — medium

The SMTP send path never feeds AccountThrottleGate: sendQueued's onFailure records the error to the outbox row but never runs ThrottleClassifier.classify, so provider throttling/lockouts observed on send are invisible to the shared gate — asymmetric with the Graph path (GraphThrottle), which does record them.

Failure scenario: Yahoo locks the account (~1-hour auth lock, the exact case ThrottleBackoff.LOCKOUT_BASE_MS is sized for) or Gmail SMTP answers a throttle NO during an outbox drain: the classified signal is dropped, so backfill and sync keep paging the locked account at full speed (prolonging or re-arming the lock per the on-device perf finding), and SendWorker's own 30s-base WorkManager backoff keeps re-attempting sends far inside a lockout window. Notably ThrottleClassifier's 'too many messages' pattern is a send-side response that can never match today, because classify() is only invoked from the fetch paths (MailSyncer/MailBackfiller) — evidence the throttle-detection mechanism was left one transport short of where the signals actually occur.

Verifier justification (CONFIRMED): SendWorker.sendQueued's onFailure (SendWorker.kt:147) only does outboxDao.setError(entity.id, e.message) — it never calls ThrottleClassifier.classify nor AccountThrottleGate.onThrottle, and SendWorker imports neither; SmtpSender has no throttle logic either. classify() is invoked only from MailSyncer.kt:165 and MailBackfiller.kt:138 (fetch paths), making the send-side "too many messages" pattern (ThrottleClassifier.kt:95) unreachable, while the Graph send path DOES feed the gate via GraphThrottle.execute (GraphThrottle.kt:57-62), whose KDoc promises "the send and receive paths never fight the same provider limit from two directions" — so the asymmetry contradicts the documented design, not intent. Severity re-rated medium (not high): during a lockout the next sync/backfill error is itself classified by MailSyncer/MailBackfiller and feeds the gate after one probe, and WorkManager's exponential backoff bounds SendWorker's re-attempts — the impact is delayed throttle detection and one dropped signal class, not indefinitely stuck mail or full-speed hammering.

Defective line: onFailure = { e -> if (e is GraphSendException && e.mayHaveSent) { ... } else { outboxDao.setError(entity.id, e.message) failed = true AppLog.w(TAG, "send failed for ${accountLogRef(account.id)}; will retry") } }

Fix hint: In SendWorker.sendQueued's onFailure else-branch, run ThrottleClassifier.classify(e) and, on a non-null signal, inject AccountThrottleGate and call onThrottle(account.id, signal) (mirroring MailSyncer.kt:165); optionally also skip/short-circuit drains for accounts where isThrottled(account.id) is true, matching MailBackfiller's pre-check.

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**). **Triage: below the cut (confirmed, medium) — backlog.** ## `app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt:138` — medium The SMTP send path never feeds AccountThrottleGate: sendQueued's onFailure records the error to the outbox row but never runs ThrottleClassifier.classify, so provider throttling/lockouts observed on send are invisible to the shared gate — asymmetric with the Graph path (GraphThrottle), which does record them. **Failure scenario:** Yahoo locks the account (~1-hour auth lock, the exact case ThrottleBackoff.LOCKOUT_BASE_MS is sized for) or Gmail SMTP answers a throttle NO during an outbox drain: the classified signal is dropped, so backfill and sync keep paging the locked account at full speed (prolonging or re-arming the lock per the on-device perf finding), and SendWorker's own 30s-base WorkManager backoff keeps re-attempting sends far inside a lockout window. Notably ThrottleClassifier's 'too many messages' pattern is a send-side response that can never match today, because classify() is only invoked from the fetch paths (MailSyncer/MailBackfiller) — evidence the throttle-detection mechanism was left one transport short of where the signals actually occur. **Verifier justification (CONFIRMED):** SendWorker.sendQueued's onFailure (SendWorker.kt:147) only does outboxDao.setError(entity.id, e.message) — it never calls ThrottleClassifier.classify nor AccountThrottleGate.onThrottle, and SendWorker imports neither; SmtpSender has no throttle logic either. classify() is invoked only from MailSyncer.kt:165 and MailBackfiller.kt:138 (fetch paths), making the send-side "too many messages" pattern (ThrottleClassifier.kt:95) unreachable, while the Graph send path DOES feed the gate via GraphThrottle.execute (GraphThrottle.kt:57-62), whose KDoc promises "the send and receive paths never fight the same provider limit from two directions" — so the asymmetry contradicts the documented design, not intent. Severity re-rated medium (not high): during a lockout the next sync/backfill error is itself classified by MailSyncer/MailBackfiller and feeds the gate after one probe, and WorkManager's exponential backoff bounds SendWorker's re-attempts — the impact is delayed throttle detection and one dropped signal class, not indefinitely stuck mail or full-speed hammering. **Defective line:** `onFailure = { e -> if (e is GraphSendException && e.mayHaveSent) { ... } else { outboxDao.setError(entity.id, e.message) failed = true AppLog.w(TAG, "send failed for ${accountLogRef(account.id)}; will retry") } }` **Fix hint:** In SendWorker.sendQueued's onFailure else-branch, run ThrottleClassifier.classify(e) and, on a non-null signal, inject AccountThrottleGate and call onThrottle(account.id, signal) (mirroring MailSyncer.kt:165); optionally also skip/short-circuit drains for accounts where isThrottled(account.id) is true, matching MailBackfiller's pre-check.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#496