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.
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.
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— mediumThe 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.