perf(yahoo): respect Yahoo/AOL IMAP limits & avoid the 1-hour auth lockout

Yahoo/AOL trip an automated ~1-hour service lockout after too many rapid or
failed authentication attempts. An unguarded login repeater (notably the IDLE
reconnect loop, which starts at a 5s backoff) could fire several failed LOGINs
within the first minute and lock out a real user for an hour. Add a proactive,
Yahoo/AOL-scoped auth circuit-breaker that spaces out login attempts so we never
reach the lockout, building on the #360 reactive throttle framework rather than
reinventing it.

New (org.libremail.mail), all host-keyed so only Yahoo/AOL are gated:
- ProviderAuthPolicy: per-host AuthCadencePolicy (Yahoo/AOL enabled, everything
  else DISABLED). Also exposes the documented 5-connection and 10k-folder caps.
- AuthBackoff: pure schedule — exponential equal-jitter ramp (base 60s -> 30s
  floor after jitter, cap 15m) up to a 4-failure threshold, then a fixed 30m
  open-circuit window. Every wait stays well under the ~1h lockout.
- AuthThrottleGate (@Singleton): per-account state; onAuthFailure / onAuthSuccess
  / remainingAuthBlockMillis, PII-free logging.

Enforcement:
- ImapClient guards every real LOGIN (connect-per-op, reuse connect/reconnect,
  and the IDLE connection): skips the login (AuthBackoffException) while backing
  off, arms the gate on an AuthenticationFailedException, clears it on success.
  A transient (non-auth) connect error never arms the backoff.
- MailBackfiller skips an auth-blocked account exactly as it skips a reactively
  throttled one (#360), so BackfillPacer (#356) never spins a cooldown on it.

The 5-connection cap is already satisfied by connection reuse (#125/#357: ~1 warm
socket + 1 IDLE per account); the 10k folder truncation is respected for free by
backfill stopping when the server returns nothing older.

Tests: pure-schedule, policy-resolution, gate (virtual time, incl. composition
with #360), GreenMail enforcement (auth-fail arms / transient does not / blocked
skips), and an on-device instrumented gate test.

Closes #362
This commit is contained in:
2026-07-08 19:39:13 -05:00
parent d7429dfef0
commit b03e363888
13 changed files with 1119 additions and 17 deletions
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailProvider
import org.libremail.domain.model.MailSecurity
/**
* On-device proof of issue #362's proactive auth circuit-breaker on the REAL Android runtime (not the
* JVM stubs), across the CI API matrix. A real [AuthThrottleGate] with an always-Yahoo policy and a
* manual clock — so it is deterministic and never real-sleeps — must, for a Yahoo/AOL account: block a
* login after an auth failure, escalate rapid failures without reaching the ~1-hour lockout window, open
* a long fixed circuit past the threshold, isolate accounts, and clear on success; and it must be a
* total no-op for a non-gated host.
*
* Deliberately mock-free (no `mockk`, no framework `Context`): the gate's only inputs are plain lambdas
* and value objects, so this exercises the genuine state machine on the device and dodges the
* mockk-on-framework-types landmines. The JVM [AuthThrottleGateTest] covers the same contract under
* coroutines-test virtual time; this proves it survives the real dispatcher and API levels.
*/
@RunWith(AndroidJUnit4::class)
class AuthThrottleGateInstrumentedTest {
private var now = 0L
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
private fun gate(policyForHost: (String) -> AuthCadencePolicy = { yahoo }) =
AuthThrottleGate(nowMillis = { now }, random = { 0.0 }, policyForHost = policyForHost)
private fun params(user: String = "user@example.org", host: String = "imap.mail.yahoo.com") =
ImapConnectionParams(host, PORT, MailSecurity.SSL_TLS, user, secret = "secret", useXoauth2 = false)
@Test
fun anAuthFailureBlocksTheAccountAndEscalatesWithoutReachingTheLockout() {
val gate = gate()
val p = params()
val first = gate.onAuthFailure(p)
assertTrue("a failure blocks the account", gate.isAuthBlocked(p))
assertTrue("the first block is positive", first > 0L)
val second = gate.onAuthFailure(p)
assertTrue("a consecutive failure backs off longer", second > first)
// Never as long as the ~1-hour lockout the backoff exists to avoid.
repeat(RAPID_FAILURES) { assertTrue(gate.onAuthFailure(p) < ONE_HOUR_MS) }
}
@Test
fun theCircuitOpensToAFixedWindowPastTheThreshold() {
val gate = gate()
val p = params()
var last = 0L
repeat(yahoo.circuitOpenThreshold) { last = gate.onAuthFailure(p) }
assertEquals("the threshold failure opens the fixed circuit window", yahoo.circuitOpenMillis, last)
assertEquals("and it stays open at that window", yahoo.circuitOpenMillis, gate.onAuthFailure(p))
}
@Test
fun accountsAreIsolatedAndSuccessClearsTheBackoff() {
val gate = gate()
val blocked = params(user = "blocked@example.org")
val healthy = params(user = "healthy@example.org")
gate.onAuthFailure(blocked)
assertTrue(gate.isAuthBlocked(blocked))
assertFalse("one blocked account never stalls another", gate.isAuthBlocked(healthy))
gate.onAuthSuccess(blocked)
assertFalse("a successful login clears the backoff", gate.isAuthBlocked(blocked))
}
@Test
fun theWindowClearsOnceItElapses() {
val gate = gate()
val p = params()
val block = gate.onAuthFailure(p)
now += block
assertFalse("the account may retry once the window elapses", gate.isAuthBlocked(p))
}
@Test
fun aNonGatedHostIsNeverBlocked() {
val gate = gate(policyForHost = ProviderAuthPolicy::forHost)
val gmail = params(host = "imap.gmail.com")
assertEquals(0L, gate.onAuthFailure(gmail))
assertFalse(gate.isAuthBlocked(gmail))
}
private companion object {
const val PORT = 993
const val RAPID_FAILURES = 10
const val ONE_HOUR_MS = 60 * 60_000L
}
}
@@ -25,6 +25,7 @@ import org.libremail.data.settings.effectiveRetention
import org.libremail.domain.model.Account
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.AuthThrottleGate
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
@@ -59,6 +60,7 @@ class MailBackfiller @Inject constructor(
private val maintenanceGate: MailMaintenanceGate,
private val throttleGate: AccountThrottleGate,
private val interactiveGate: InteractiveImapGate,
private val authGate: AuthThrottleGate,
) {
/** One folder's slice outcome: pages fetched, and whether an immediate follow-up slice has work to do. */
private data class FolderResult(val batches: Int, val moreWork: Boolean)
@@ -75,19 +77,10 @@ class MailBackfiller @Inject constructor(
var remaining = maxBatches
var moreWork = false
accounts@ for (account in accountDao.getAll().map { it.toDomain() }) {
// Graceful degradation + per-account isolation (#360): an account still inside its throttle
// backoff window is skipped this slice — we don't page a provider that just rate-limited or
// locked us (hammering it makes throttling worse, the on-device perf finding). The window
// elapses on its own, so a later scheduled slice resumes this account automatically. A skip
// deliberately does NOT set moreWork: a slice whose only outstanding work is a throttled
// account reports "done" so the worker's slice-chaining loop stops instead of tight-looping
// over the skip. Other accounts are untouched.
val backoffRemaining = throttleGate.remainingBackoffMillis(account.id)
if (backoffRemaining > 0L) {
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: throttled, remaining=${backoffRemaining}ms")
continue@accounts
}
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: continue
// Skip an account backing off (throttle #360 / auth #362) or with unresolvable credentials —
// a null return does NOT set moreWork, so a slice whose only work is a backing-off account
// reports "done" instead of tight-looping over the skip. See [paramsForBackfill].
val params = paramsForBackfill(account) ?: continue@accounts
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
for (folder in messageDao.syncedFolders(account.id)) {
if (remaining <= 0) {
@@ -111,6 +104,32 @@ class MailBackfiller @Inject constructor(
moreWork
}
/**
* Resolves [account] to its IMAP connection params for this slice, or **null when the account must be
* skipped without paging** — it is inside a reactive throttle-backoff window (#360) or a proactive
* auth-backoff window (#362), or its credentials could not be resolved. Both backoffs are graceful
* degradation with per-account isolation: we don't page a provider that just rate-limited or locked us
* (hammering makes throttling worse — the on-device perf finding), and we don't drive a login that
* [org.libremail.mail.ImapClient] would only skip anyway, nudging a Yahoo/AOL account toward its
* ~1-hour lockout. Each window elapses on its own so a later scheduled slice resumes; a skip logs a
* PII-free breadcrumb and its caller does NOT set moreWork, so a slice whose only outstanding work is a
* backing-off account reports "done" rather than tight-looping over the skip.
*/
private suspend fun paramsForBackfill(account: Account): ImapConnectionParams? {
val throttleRemaining = throttleGate.remainingBackoffMillis(account.id)
if (throttleRemaining > 0L) {
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: throttled, remaining=${throttleRemaining}ms")
return null
}
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: return null
val authBlock = authGate.remainingAuthBlockMillis(params)
if (authBlock > 0L) {
AppLog.i(TAG, "backfill skip ${accountLogRef(account.id)}: auth backing off, remaining=${authBlock}ms")
return null
}
return params
}
/**
* Pages one folder, translating a failure into the slice's control flow (issue #360). Returns the
* [FolderResult] on success — or, for an ordinary transient error, a zero-page result whose
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import kotlin.math.min
/**
* The pure, proactive **auth-backoff schedule** for issue #362: given a provider's [AuthCadencePolicy]
* and how many times in a row an account has failed to authenticate ([consecutiveFailures], 1-based),
* returns how long to block further login attempts. Side-effect- and clock-free (the caller supplies the
* jitter draw) so the whole schedule is deterministically unit-testable; [AuthThrottleGate] owns the
* per-account state, clock, and logging.
*
* Two regimes, both aimed at never tripping Yahoo/AOL's ~1-hour auth lockout:
* - **Ramp** (`failures < circuitOpenThreshold`): exponential in the failure count
* (`base * 2^(failures-1)`), clamped to [AuthCadencePolicy.maxBackoffMillis], with **equal jitter** —
* half the capped target as a floor, the other half spread by [random] — so the result lies in
* `[capped/2, capped]` and a fleet throttled at once doesn't retry in lockstep. Mirrors the equal-jitter
* math of issue #360's [org.libremail.data.sync.ThrottleBackoff].
* - **Open circuit** (`failures >= circuitOpenThreshold`): a single long, *fixed* block
* ([AuthCadencePolicy.circuitOpenMillis]) — "back off long and stop". Deliberately un-jittered: once we
* give up probing, the window is a firm floor, not something jitter can shorten.
*
* A [disabled][AuthCadencePolicy.enabled] policy always returns `0` (never blocks), so non-Yahoo/AOL
* hosts are unaffected.
*/
object AuthBackoff {
/** Caps the exponential shift so `base shl (failures-1)` can never overflow before the cap applies. */
private const val MAX_SHIFT = 16
/**
* Block duration in milliseconds for the given 1-based [consecutiveFailures] under [policy], with the
* equal-jitter draw [random] (expected in `[0.0, 1.0)`). See the class doc for the ramp vs.
* open-circuit regimes.
*/
fun blockMillis(policy: AuthCadencePolicy, consecutiveFailures: Int, random: Double): Long {
require(consecutiveFailures >= 1) { "consecutiveFailures must be >= 1" }
if (!policy.enabled) return 0L
if (consecutiveFailures >= policy.circuitOpenThreshold) return policy.circuitOpenMillis
val shift = min(consecutiveFailures - 1, MAX_SHIFT)
val exponential = policy.baseBackoffMillis shl shift
// shl can overflow to <= 0 for a pathological count; treat that as "past the cap".
val capped = if (exponential in 1..policy.maxBackoffMillis) exponential else policy.maxBackoffMillis
val half = capped / 2
val jitter = (random.coerceIn(0.0, 1.0) * half).toLong()
return half + jitter
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.MessagingException
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.reporting.AppLog
import org.libremail.reporting.accountLogRef
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ThreadLocalRandom
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thrown by [ImapClient] instead of attempting a `LOGIN` while an account is inside its proactive
* auth-backoff window (issue #362): the login is *skipped*, not merely retried, so a Yahoo/AOL account
* never accumulates the rapid failed logins that trip the ~1-hour lockout. Extends [MessagingException]
* so existing IMAP error handling catches it, and carries **no PII** — only the remaining wait. Its
* message deliberately avoids any throttle/lock wording so [org.libremail.data.sync.ThrottleClassifier]
* does not misread it as a *reactive* throttle signal, and [ImapConnectionCache] does not treat it as a
* connection drop (its cause is null), so a reused connection is never needlessly rebuilt on it.
*/
class AuthBackoffException(remainingMillis: Long) :
MessagingException("IMAP login paused: backing off ${remainingMillis}ms to protect the account")
/**
* Per-account **proactive auth circuit-breaker** for issue #362 — the enforcing state machine that turns
* repeated authentication failures into an [AuthBackoff]-scheduled block, so LibreMail never hammers
* Yahoo/AOL's login endpoint into its automated **~1-hour lockout**.
*
* This is the proactive counterpart to issue #360's reactive [org.libremail.data.sync.AccountThrottleGate]:
* that gate reacts to a throttle/lock response the server *already sent*; this one prevents us from ever
* eliciting one. [ImapClient] consults [remainingAuthBlockMillis] before every real `LOGIN` (connect-per-op,
* the reuse cache's connect/reconnect, and the long-lived IDLE connection) and throws [AuthBackoffException]
* instead of connecting while blocked; it records the outcome via [onAuthFailure] / [onAuthSuccess]. The
* full-history backfill ([org.libremail.data.sync.MailBackfiller]) additionally *skips* an auth-blocked
* account, exactly as it skips a reactively-throttled one (#360) — so the two gates compose and the
* [org.libremail.data.sync.BackfillPacer] (#356) never burns cooldowns spinning on a blocked login.
*
* **Only Yahoo/AOL are gated.** State is created solely when [ProviderAuthPolicy.forHost] returns an
* enabled policy, so every other provider (Gmail/iCloud/Outlook, issues #361/#363/#364, and manual
* servers) is a no-op here and unchanged.
*
* **Per-account isolation & PII-free.** State is keyed by the account's connection identity
* (`host|port|username`), so one blocked account never stalls another, and every log line uses
* [accountLogRef] over that key — a salted-looking hash, never the address or host.
*
* State lives only in-process (`@Singleton`); a process restart clears it and simply re-probes, which is
* safe — the first attempt after restart is spaced from the previous run by however long the process was
* down, and any real re-failure immediately re-arms the backoff.
*/
@Singleton
class AuthThrottleGate internal constructor(
private val nowMillis: () -> Long,
private val random: () -> Double,
private val policyForHost: (String) -> AuthCadencePolicy,
) {
/** Production wiring: the real wall clock, a per-thread RNG for jitter, and the host-keyed policy. */
@Inject
constructor() : this(
nowMillis = System::currentTimeMillis,
random = { ThreadLocalRandom.current().nextDouble() },
policyForHost = ProviderAuthPolicy::forHost,
)
/** One account's auth state: consecutive failures, until when logins are blocked, the last wait, open? */
private data class State(
val failures: Int,
val blockedUntilMillis: Long,
val lastBlockMillis: Long,
val circuitOpen: Boolean,
)
private val states = ConcurrentHashMap<String, State>()
/**
* Records a failed authentication for [params]'s account and returns the resulting block in ms (0 when
* the host has no auth-lockout risk, so the call is a no-op). Escalates the consecutive-failure count
* so repeats back off exponentially, then extend into the fixed open-circuit window past the policy's
* threshold, and stamps the account blocked until `now + block`. Atomic per account. Logs a PII-free
* breadcrumb (failure count, block, and whether the circuit is now open).
*/
fun onAuthFailure(params: ImapConnectionParams): Long {
val policy = policyForHost(params.host)
if (!policy.enabled) return 0L
val now = nowMillis()
val updated = states.compute(key(params)) { _, previous ->
val failures = (previous?.failures ?: 0) + 1
val block = AuthBackoff.blockMillis(policy, failures, random())
State(
failures = failures,
blockedUntilMillis = now + block,
lastBlockMillis = block,
circuitOpen = failures >= policy.circuitOpenThreshold,
)
}!!
AppLog.w(
TAG,
"auth backoff ${logRef(params)} failures=${updated.failures} block=${updated.lastBlockMillis}ms" +
if (updated.circuitOpen) " circuit=open" else "",
)
return updated.lastBlockMillis
}
/**
* Clears any auth-backoff state for [params]'s account after a successful login, so a recovered
* account resumes at full speed with the failure count reset. Silent no-op when the account was not
* blocked (or the host is not gated), so [ImapClient] can call it on every successful connect.
*/
fun onAuthSuccess(params: ImapConnectionParams) {
val previous = states.remove(key(params)) ?: return
AppLog.i(TAG, "auth recovered ${logRef(params)} after ${previous.failures} failure(s)")
}
/**
* Milliseconds until [params]'s account may attempt a login again, or 0 when it is not blocked (or the
* window already elapsed). A passed window keeps its failure count until the next [onAuthSuccess], so a
* re-failure before recovery escalates rather than restarting from the base delay.
*/
fun remainingAuthBlockMillis(params: ImapConnectionParams): Long {
val state = states[key(params)] ?: return 0L
return (state.blockedUntilMillis - nowMillis()).coerceAtLeast(0L)
}
/** True while [params]'s account is inside its auth-backoff window and a login must be skipped. */
fun isAuthBlocked(params: ImapConnectionParams): Boolean = remainingAuthBlockMillis(params) > 0L
/** PII-free, stable reference for [params]'s account — a hash of the connection identity, never it. */
fun logRef(params: ImapConnectionParams): String = accountLogRef(key(params))
/** Connection identity keying the state: everything that pins a distinct authenticated login. */
private fun key(params: ImapConnectionParams): String = "${params.host}|${params.port}|${params.username}"
private companion object {
const val TAG = "AuthThrottleGate"
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.AuthenticationFailedException
import jakarta.mail.FetchProfile
import jakarta.mail.Flags
import jakarta.mail.Folder
@@ -114,6 +115,12 @@ class ImapClient internal constructor(
* no-UIDPLUS fallback path on its own.
*/
private val supportsUidPlus: (IMAPFolder) -> Boolean = ::probeUidPlusCapability,
/**
* Proactive auth circuit-breaker (issue #362): consulted before every real `LOGIN` so a Yahoo/AOL
* account never accumulates the rapid failed logins that trip its ~1-hour lockout. A no-op for every
* other host. Defaulted here so the test/harness seam constructs one without extra wiring.
*/
private val authGate: AuthThrottleGate = AuthThrottleGate(),
) {
/**
@@ -125,7 +132,10 @@ class ImapClient internal constructor(
* `false` (a build-config change, no code edit) restores connect-per-operation if a server
* misbehaves with a kept-alive socket. The internal constructor is the test/harness seam.
*/
@Inject constructor() : this(reuseConnections = BuildConfig.IMAP_CONNECTION_REUSE)
@Inject constructor(authGate: AuthThrottleGate) : this(
reuseConnections = BuildConfig.IMAP_CONNECTION_REUSE,
authGate = authGate,
)
/**
* Per-account keep-alive cache; allocated only when reuse is enabled, so a reuse-disabled build
@@ -540,9 +550,13 @@ class ImapClient internal constructor(
* connection to unblock idle()) or a connection error is thrown, leaving reconnection to the caller.
*/
suspend fun idle(params: ImapConnectionParams, onActivity: suspend () -> Unit) = withContext(Dispatchers.IO) {
// Proactive auth circuit-breaker (issue #362): the IDLE reconnect loop (IdleService) is the fastest
// login repeater — an unguarded auth failure there would storm Yahoo/AOL into their ~1-hour lockout
// — so skip the LOGIN while backing off, and feed the gate exactly as the connect-per-op path does.
guardAuthBackoff(params, op = "IDLE login")
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
store.connect(params.host, params.port, params.username, params.secret)
connectRecordingAuth(store, params)
// Close the just-connected store if opening the folder fails, so a failed connect in the
// IDLE reconnect loop can't leak connections until the server's per-account limit is hit.
val inbox = try {
@@ -715,14 +729,51 @@ class ImapClient internal constructor(
}
}
/** Builds and authenticates a fresh [Store] (`CONNECT + TLS + LOGIN`); the caller owns closing it. */
/**
* Builds and authenticates a fresh [Store] (`CONNECT + TLS + LOGIN`); the caller owns closing it.
*
* Gated by the proactive auth circuit-breaker (issue #362): while the account is inside its
* auth-backoff window the `LOGIN` is *skipped* ([guardAuthBackoff] throws [AuthBackoffException])
* rather than attempted, so a Yahoo/AOL account never accumulates the rapid failed logins that trip
* its ~1-hour lockout. An authentication failure feeds [AuthThrottleGate.onAuthFailure]; a success
* clears it. A transient (non-auth) connect error is rethrown untouched, never arming the backoff.
*/
private fun openConnectedStore(params: ImapConnectionParams): Store {
guardAuthBackoff(params, op = "login")
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val store = Session.getInstance(buildProps(protocol, params, reuse = reuseConnections)).getStore(protocol)
store.connect(params.host, params.port, params.username, params.secret)
connectRecordingAuth(store, params)
return store
}
/**
* Skips a login while the account is auth-backing-off (issue #362) by throwing [AuthBackoffException],
* so no `LOGIN` reaches the provider. Logged at INFO — an expected, protective skip, not an error.
* A no-op for non-gated (non-Yahoo/AOL) hosts, whose [AuthThrottleGate.remainingAuthBlockMillis] is 0.
*/
private fun guardAuthBackoff(params: ImapConnectionParams, op: String) {
val remaining = authGate.remainingAuthBlockMillis(params)
if (remaining > 0L) {
AppLog.i(TAG, "$op skipped ${authGate.logRef(params)}: auth backing off ${remaining}ms")
throw AuthBackoffException(remaining)
}
}
/**
* Runs the actual `store.connect` (`CONNECT + TLS + LOGIN`) and feeds the issue-#362 auth
* circuit-breaker: a rejected LOGIN ([isAuthFailure]) arms the backoff via [AuthThrottleGate], a
* success clears it, and a transient (non-auth) error is rethrown untouched — never arming it.
*/
private fun connectRecordingAuth(store: Store, params: ImapConnectionParams) {
try {
store.connect(params.host, params.port, params.username, params.secret)
} catch (e: Throwable) {
if (isAuthFailure(e)) authGate.onAuthFailure(params)
throw e
}
authGate.onAuthSuccess(params)
}
/**
* Tears down every kept-alive reused connection (`LOGOUT` + teardown); a no-op when reuse is
* disabled. `IdleService` drives this on the low-battery push-teardown path (#88/#89/#90), mirroring
@@ -799,6 +850,23 @@ private fun probeUidPlusCapability(folder: IMAPFolder): Boolean = runCatching {
folder.doCommand { protocol -> protocol.hasCapability(CAP_UIDPLUS) } as? Boolean
}.getOrNull() ?: false
/**
* True when [error] (or anything in its cause chain) is an [AuthenticationFailedException] — a rejected
* `LOGIN`, the only signal the issue-#362 auth circuit-breaker counts. Deliberately narrow: a transient
* network/socket error is NOT an auth failure and must never arm the backoff. Guards against a cyclic
* cause chain with an identity-based visited check, mirroring [ImapAuthError]'s walk.
*/
private fun isAuthFailure(error: Throwable): Boolean {
val seen = mutableListOf<Throwable>()
var current: Throwable? = error
while (current != null && seen.none { it === current }) {
if (current is AuthenticationFailedException) return true
seen.add(current)
current = current.cause
}
return false
}
/**
* True when [part] is a user-facing downloadable attachment: its `Content-Disposition` is
* `attachment`, OR it has a filename but no `Content-ID` header. A part with a filename AND a
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import org.libremail.domain.model.MailProvider
/**
* Per-provider IMAP limits and the **proactive auth cadence** for issue #362, keyed by IMAP host.
*
* This is the *config* half of the Yahoo/AOL work; the enforcing state machine is [AuthThrottleGate]
* and the pure schedule is [AuthBackoff]. It deliberately parallels issue #360's *reactive* family
* ([org.libremail.data.sync.ThrottleBackoff] / [org.libremail.data.sync.AccountThrottleGate]) — that
* family reacts to a throttle/lock response the server *already sent*; this one is proactive, spacing
* out login attempts so we never **reach** the response in the first place.
*
* **Why Yahoo/AOL are special.** Yahoo (and AOL, which shares Yahoo's mail platform) trip an automated
* **~1-hour service lockout** after too many rapid or failed authentication attempts, and cap a
* mailbox at **5 simultaneous IMAP connections** with the folder index truncated to **10,000
* messages** (issue #362's documented limits). The lockout is the dangerous one: a wrong app-password
* plus a naive retry loop (e.g. the IDLE reconnect loop, which starts at a 5-second backoff) can fire
* several failed `LOGIN`s within the first minute and get a *real user* locked out for an hour. So for
* these hosts the app must back off login attempts long and hard.
*
* **Every other provider is disabled here** ([AuthCadencePolicy.DISABLED]): Gmail, iCloud, Outlook,
* and manually-configured servers have no comparable 1-hour auth lockout, so the proactive
* circuit-breaker is a Yahoo/AOL-scoped no-op for them and their behaviour is unchanged (their own
* limits are issues #361/#363/#364). Keeping the policy host-keyed — rather than refactoring shared
* code — is what makes this change additive and safe to land alongside those siblings.
*/
data class AuthCadencePolicy(
/** When false the whole proactive auth circuit-breaker is inert for this host (records nothing). */
val enabled: Boolean,
/** First-failure backoff before a retry is permitted; doubles per consecutive failure. */
val baseBackoffMillis: Long,
/** Ceiling on the exponential ramp, so a single wait never grows without bound. */
val maxBackoffMillis: Long,
/** Consecutive failures after which the circuit *opens* — retries stop for [circuitOpenMillis]. */
val circuitOpenThreshold: Int,
/** The long, fixed block applied once the circuit is open: "back off long and stop" (issue #362). */
val circuitOpenMillis: Long,
/** Documented simultaneous-connection ceiling for this provider (see [ProviderAuthPolicy]). */
val maxConcurrentConnections: Int,
/** Documented server-side folder-index truncation (messages) for this provider. */
val folderIndexCap: Int,
) {
companion object {
/**
* The inert policy for every host without a Yahoo-style auth lockout. Every threshold is set so
* the gate can never block ([circuitOpenThreshold] unreachable, caps effectively unbounded), so a
* non-Yahoo/AOL account is never gated and behaves exactly as before issue #362.
*/
val DISABLED = AuthCadencePolicy(
enabled = false,
baseBackoffMillis = 0L,
maxBackoffMillis = 0L,
circuitOpenThreshold = Int.MAX_VALUE,
circuitOpenMillis = 0L,
maxConcurrentConnections = Int.MAX_VALUE,
folderIndexCap = Int.MAX_VALUE,
)
}
}
/**
* Resolves the [AuthCadencePolicy] for an IMAP host. Yahoo and AOL (one platform) get the conservative
* lockout-avoiding policy; everything else gets [AuthCadencePolicy.DISABLED].
*/
object ProviderAuthPolicy {
private const val MINUTE_MS = 60_000L
/**
* First-failure auth backoff (1 min → a 30 s floor after equal jitter, see [AuthBackoff]). The whole
* point is that the **second** login attempt lands ≥30 s after the first: Yahoo's lockout keys on
* *rapid* failures (attempts seconds apart, as an unguarded reconnect loop produces), and a ≥30 s
* spacing is decisively not rapid. This floor is well under the ~1-hour lockout window it protects.
*/
const val YAHOO_AUTH_BACKOFF_BASE_MS = MINUTE_MS
/**
* Ceiling on the exponential ramp (15 min). Comfortably under the ~1-hour lockout, so an account that
* recovers (a transient auth blip clears, or the user fixes the credential) resumes far sooner than a
* self-inflicted hour of silence, while still spacing attempts to at most a few per hour.
*/
const val YAHOO_AUTH_BACKOFF_MAX_MS = 15 * MINUTE_MS
/**
* Consecutive failed logins after which the circuit opens (4). A wrong app-password does not fix
* itself, so once we have failed this many times in a row we stop *ramping* and switch to the long
* fixed [YAHOO_AUTH_CIRCUIT_OPEN_MS] block — "back off long and stop" — rather than keep probing and
* risk accumulating enough failures to trip the lockout. Reached in ~3.5 min of spaced attempts.
*/
const val YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD = 4
/**
* The block applied once the circuit is open (30 min). Longer than the 15-min ramp cap (we have given
* up probing) yet still **under** the ~1-hour lockout, so recovery beats the lockout — and long
* enough that Yahoo's short rolling failure window fully decays between our probes, holding us to at
* most ~2 failed logins/hour once open: no rolling-window lockout heuristic reads that as rapid.
*/
const val YAHOO_AUTH_CIRCUIT_OPEN_MS = 30 * MINUTE_MS
/**
* Yahoo/AOL's documented simultaneous-connection ceiling (5). LibreMail stays well under this by
* design: connection reuse (issues #125/#357, ON by default) collapses a whole account to ~1 warm
* IMAP socket plus at most one long-lived IDLE connection — 2 per account, not the `1 + K +
* attachments` sockets the connect-per-operation path once opened per backfill page. Exposed as
* config so the invariant is checkable (see the policy tests) rather than only implicit.
*/
const val YAHOO_MAX_CONCURRENT_CONNECTIONS = 5
/**
* Yahoo/AOL's documented folder-index truncation (10,000 messages). The full-history backfill
* (issue #12) already respects this for free: paging older-than-UID simply returns empty once the
* server exposes nothing beyond the truncation point, which the backfiller treats as "folder fully
* backfilled". Exposed as config for visibility and so a future page-cap can reference it.
*/
const val YAHOO_FOLDER_INDEX_CAP = 10_000
/** Shared by Yahoo and AOL — one mail platform, one set of limits. */
private val YAHOO_AOL = AuthCadencePolicy(
enabled = true,
baseBackoffMillis = YAHOO_AUTH_BACKOFF_BASE_MS,
maxBackoffMillis = YAHOO_AUTH_BACKOFF_MAX_MS,
circuitOpenThreshold = YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD,
circuitOpenMillis = YAHOO_AUTH_CIRCUIT_OPEN_MS,
maxConcurrentConnections = YAHOO_MAX_CONCURRENT_CONNECTIONS,
folderIndexCap = YAHOO_FOLDER_INDEX_CAP,
)
/**
* The policy for [host] (the account's IMAP host). Yahoo/AOL — matched via the single source of truth
* [MailProvider.forImapHost], including host aliases — get [YAHOO_AOL]; everything else, including a
* null/blank or unknown host, gets [AuthCadencePolicy.DISABLED].
*/
fun forHost(host: String): AuthCadencePolicy = when (MailProvider.forImapHost(host)) {
MailProvider.YAHOO, MailProvider.AOL -> YAHOO_AOL
else -> AuthCadencePolicy.DISABLED
}
}
@@ -44,8 +44,10 @@ import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.AuthThrottleGate
import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import org.libremail.mail.ProviderAuthPolicy
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
@@ -530,6 +532,37 @@ class MailBackfillerTest {
assertTrue(logBuffer.snapshot().any { it.message.startsWith("backfill skip acct:") })
}
/**
* Issue #362 composes with #360/#356 by reusing the same skip: an account inside its proactive
* *auth*-backoff window is skipped exactly as a reactively-throttled one is — no server call, no
* `moreWork` (so [BackfillPacer] does not spin a cooldown on it), and the account is left alone so
* backfill never nudges a Yahoo/AOL account toward its ~1-hour lockout.
*/
@Test
fun `an account inside its auth-backoff window is skipped, not paged`() = runTest {
cached += fetchedMessage(uid = "60").toEntity("acct", "INBOX")
val imapClient = mockk<ImapClient>(relaxed = true)
// Treat the (127.0.0.1) test host as a gated Yahoo/AOL account and arm the backoff on its identity.
val authGate = AuthThrottleGate(
nowMillis = { 0L },
random = { 0.0 },
policyForHost = { ProviderAuthPolicy.forHost("imap.mail.yahoo.com") },
)
authGate.onAuthFailure(params())
val moreWork = backfiller(AccountSettings("acct"), imapClient = imapClient, authGate = authGate).runBackfill()
assertFalse(moreWork, "a slice whose only account is auth-blocked reports done, not more-work")
coVerify(exactly = 0) { imapClient.fetchOlderThan(any(), any(), any(), any()) }
assertTrue(authGate.isAuthBlocked(params()), "the account is still auth-backing-off")
assertTrue(
logBuffer.snapshot().any {
it.message.startsWith("backfill skip acct:") && it.message.contains("auth backing off")
},
"a PII-free auth-skip breadcrumb is recorded",
)
}
// --- issue #355: interactive-fetch priority -------------------------------------------------
/**
@@ -686,6 +719,7 @@ class MailBackfillerTest {
imapClient: ImapClient = client,
throttleGate: AccountThrottleGate = AccountThrottleGate(),
interactiveGate: InteractiveImapGate = InteractiveImapGate(),
authGate: AuthThrottleGate = AuthThrottleGate(),
): MailBackfiller {
val accountDao = mockk<AccountDao>()
coEvery { accountDao.getAll() } returns listOf(accountEntity)
@@ -743,6 +777,7 @@ class MailBackfillerTest {
maintenanceGate = MailMaintenanceGate(),
throttleGate = throttleGate,
interactiveGate = interactiveGate,
authGate = authGate,
).also {
lastMessageDao = messageDao
lastMailRepository = mailRepository
@@ -29,6 +29,7 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.mail.AuthThrottleGate
import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatus
@@ -202,6 +203,7 @@ class MailMaintenanceGateTest {
maintenanceGate = gate,
throttleGate = AccountThrottleGate(),
interactiveGate = InteractiveImapGate(),
authGate = AuthThrottleGate(),
)
}
@@ -29,6 +29,7 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.mail.AuthThrottleGate
import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatus
@@ -362,6 +363,7 @@ class MailSyncConcurrencyTest {
maintenanceGate = MailMaintenanceGate(),
throttleGate = AccountThrottleGate(),
interactiveGate = InteractiveImapGate(),
authGate = AuthThrottleGate(),
)
}
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import org.junit.Test
import org.libremail.domain.model.MailProvider
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* The pure proactive auth-backoff schedule (issue #362): an exponential *ramp* (equal jitter, capped)
* up to the circuit-open threshold, then a long *fixed* open-circuit window — "back off long and stop".
* A disabled policy never blocks. Deterministic (the caller supplies the jitter draw), so no clock or
* randomness leaks into the assertions.
*/
class AuthBackoffTest {
/** A small controlled policy so the ramp/cap arithmetic reads clearly (base 1s, cap 8s, circuit 60s). */
private val policy = AuthCadencePolicy(
enabled = true,
baseBackoffMillis = 1_000L,
maxBackoffMillis = 8_000L,
circuitOpenThreshold = 4,
circuitOpenMillis = 60_000L,
maxConcurrentConnections = 5,
folderIndexCap = 10_000,
)
/** random = 0.0 selects the lower jitter bound (capped/2); random = 1.0 selects the upper (capped). */
private fun low(failures: Int, p: AuthCadencePolicy = policy) = AuthBackoff.blockMillis(p, failures, random = 0.0)
private fun high(failures: Int, p: AuthCadencePolicy = policy) = AuthBackoff.blockMillis(p, failures, random = 1.0)
@Test
fun `the ramp doubles per failure at the lower jitter bound`() {
assertEquals(policy.baseBackoffMillis / 2, low(1)) // 500ms
assertEquals(policy.baseBackoffMillis, low(2)) // 1000ms
assertEquals(policy.baseBackoffMillis * 2, low(3)) // 2000ms
}
@Test
fun `the upper jitter bound of the first failure is the base delay`() {
assertEquals(policy.baseBackoffMillis, high(1))
}
@Test
fun `jitter keeps every ramp draw within the exponential half-window`() {
// Failure 2's capped target is 2*base; equal jitter must land in [base, 2*base] for any draw.
val lower = policy.baseBackoffMillis
val upper = policy.baseBackoffMillis * 2
for (thousandths in 0..1000) {
val delay = AuthBackoff.blockMillis(policy, consecutiveFailures = 2, random = thousandths / 1000.0)
assertTrue(delay in lower..upper, "draw $thousandths gave $delay, outside [$lower,$upper]")
}
}
@Test
fun `the ramp is capped at the policy maximum`() {
// Raise the threshold so the ramp actually reaches the cap: failure 5 wants 16*base > cap.
val ramped = policy.copy(circuitOpenThreshold = 100)
assertEquals(ramped.maxBackoffMillis / 2, low(5, ramped))
assertEquals(ramped.maxBackoffMillis, high(5, ramped))
}
@Test
fun `at the threshold the circuit opens to a long fixed window with no jitter`() {
// Failure 4 (== threshold) and beyond return the fixed open-circuit window for any jitter draw.
assertEquals(policy.circuitOpenMillis, low(4))
assertEquals(policy.circuitOpenMillis, high(4))
assertEquals(policy.circuitOpenMillis, low(9))
assertTrue(
policy.circuitOpenMillis > policy.maxBackoffMillis,
"the open-circuit window is longer than the ramp cap — we have given up probing",
)
}
@Test
fun `a disabled policy never blocks`() {
assertEquals(0L, high(1, AuthCadencePolicy.DISABLED))
assertEquals(0L, high(50, AuthCadencePolicy.DISABLED))
}
@Test
fun `a failure count below one is rejected`() {
assertFailsWith<IllegalArgumentException> {
AuthBackoff.blockMillis(policy, consecutiveFailures = 0, random = 0.0)
}
}
@Test
fun `the real yahoo schedule keeps every wait under the one-hour lockout`() {
// The heart of issue #362: however many times in a row auth fails, no single block reaches the
// ~1-hour lockout window — the backoff protects the account without ever matching the punishment.
val yahooHost = MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host
val yahoo = ProviderAuthPolicy.forHost(yahooHost)
for (failures in 1..12) {
assertTrue(low(failures, yahoo) < ONE_HOUR_MS, "failure $failures low bound reached the lockout window")
assertTrue(high(failures, yahoo) < ONE_HOUR_MS, "failure $failures high bound reached the lockout window")
}
}
private companion object {
const val ONE_HOUR_MS = 60 * 60_000L
}
}
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.sync.AccountThrottleGate
import org.libremail.data.sync.ThrottleBackoff
import org.libremail.data.sync.ThrottleKind
import org.libremail.data.sync.ThrottleSignal
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailProvider
import org.libremail.domain.model.MailSecurity
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [AuthThrottleGate] (issue #362) must, for a Yahoo/AOL account: block logins after an auth failure,
* escalate rapid consecutive failures without ever reaching the ~1-hour lockout window, open a long
* fixed circuit past the threshold, isolate accounts, reset on success, expose an accurate remaining
* window (proven against coroutines-test virtual time), and log only PII-free breadcrumbs. It must be a
* total no-op for a non-gated host, and it must *compose* with issue #360's reactive gate rather than
* fight it.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AuthThrottleGateTest {
private val logBuffer = RingLogBuffer()
/** A manual virtual clock for the non-timing tests; [gate] reads it live, so tests advance it by hand. */
private var now = 0L
/** The real Yahoo policy — the production values under test. */
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
/** random = 0.0 makes the equal-jitter draw deterministic (always the lower bound of the ramp). */
private fun gate(random: () -> Double = { 0.0 }, policyForHost: (String) -> AuthCadencePolicy = { yahoo }) =
AuthThrottleGate(nowMillis = { now }, random = random, policyForHost = policyForHost)
private fun params(user: String = "user@example.org", host: String = "imap.mail.yahoo.com") =
ImapConnectionParams(host, PORT, MailSecurity.SSL_TLS, user, secret = "secret", useXoauth2 = false)
@Before
fun setUp() {
// AppLog forwards to android.util.Log, a throwing no-op stub under plain JVM unit tests. Fully
// qualified so this file never imports android.util.Log (a detekt-forbidden import, epic #324).
mockkStatic(android.util.Log::class)
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = unmockkAll()
@Test
fun `onAuthFailure blocks the account and returns the backoff`() {
val gate = gate()
val p = params()
val backoff = gate.onAuthFailure(p)
assertTrue(backoff > 0L)
assertTrue(gate.isAuthBlocked(p))
assertEquals(backoff, gate.remainingAuthBlockMillis(p))
}
@Test
fun `rapid consecutive auth failures escalate the block within the ramp`() {
val gate = gate()
val p = params()
val first = gate.onAuthFailure(p)
val second = gate.onAuthFailure(p)
val third = gate.onAuthFailure(p)
assertTrue(second > first, "a consecutive failure must back off longer ($second !> $first)")
assertTrue(third > second, "and longer again ($third !> $second)")
}
@Test
fun `rapid auth failures never reach the one-hour lockout window`() {
val gate = gate()
val p = params()
// Hammer a wrong credential as fast as an unguarded loop would: every resulting block must stay
// well under the ~1-hour lockout it exists to prevent — that is the whole point of issue #362.
repeat(RAPID_FAILURES) {
val block = gate.onAuthFailure(p)
assertTrue(block < ThrottleBackoff.LOCKOUT_BASE_MS, "block $block reached the 1-hour lockout window")
}
}
@Test
fun `the circuit opens after the threshold to a long fixed window and stays open`() {
val gate = gate()
val p = params()
var lastBlock = 0L
repeat(yahoo.circuitOpenThreshold) { lastBlock = gate.onAuthFailure(p) }
assertEquals(yahoo.circuitOpenMillis, lastBlock, "the threshold failure opens the fixed circuit window")
// A further failure stays open at the same fixed window (no runaway escalation).
assertEquals(yahoo.circuitOpenMillis, gate.onAuthFailure(p))
assertTrue(gate.isAuthBlocked(p))
}
@Test
fun `onAuthSuccess clears the backoff and resets the failure count`() {
val gate = gate()
val p = params()
val first = gate.onAuthFailure(p)
gate.onAuthFailure(p) // escalate to failure 2
gate.onAuthSuccess(p)
assertFalse(gate.isAuthBlocked(p))
// A fresh failure after recovery starts back at the base (failure 1) delay.
assertEquals(first, gate.onAuthFailure(p))
}
@Test
fun `a blocked account never stalls another`() {
val gate = gate()
val blocked = params(user = "blocked@example.org")
val healthy = params(user = "healthy@example.org")
gate.onAuthFailure(blocked)
assertTrue(gate.isAuthBlocked(blocked))
assertFalse(gate.isAuthBlocked(healthy))
assertEquals(0L, gate.remainingAuthBlockMillis(healthy))
}
@Test
fun `an elapsed window stops blocking but still escalates a re-failure`() {
val gate = gate()
val p = params()
val first = gate.onAuthFailure(p)
now += first // the window elapses
assertFalse(gate.isAuthBlocked(p), "the account may attempt a login once its window passes")
// Re-failing before any success keeps the failure count — it escalates, not restarts.
val next = gate.onAuthFailure(p)
assertTrue(next > first)
}
@Test
fun `the block window clears exactly when the backoff elapses`() = runTest {
val gate = AuthThrottleGate(
nowMillis = { testScheduler.currentTime },
random = { 0.0 },
policyForHost = { yahoo },
)
val p = params()
val backoff = gate.onAuthFailure(p)
assertTrue(gate.isAuthBlocked(p))
advanceTimeBy(backoff - 1)
assertTrue(gate.isAuthBlocked(p), "still blocked just before the window elapses")
advanceTimeBy(1)
assertFalse(gate.isAuthBlocked(p), "cleared the instant the backoff elapses")
assertEquals(0L, gate.remainingAuthBlockMillis(p))
}
@Test
fun `a non-gated host is never blocked`() {
// Gmail has no 1-hour auth lockout, so its real policy is DISABLED: failures record nothing.
val gate = gate(policyForHost = ProviderAuthPolicy::forHost)
val gmail = params(host = "imap.gmail.com")
assertEquals(0L, gate.onAuthFailure(gmail))
assertFalse(gate.isAuthBlocked(gmail))
assertTrue(logBuffer.snapshot().isEmpty(), "an ungated host logs no auth-backoff breadcrumb")
}
@Test
fun `auth backoff and recovery log PII-free breadcrumbs`() {
val gate = gate()
val p = params(user = "secret.user@example.org", host = "imap.mail.yahoo.com")
gate.onAuthFailure(p)
gate.onAuthSuccess(p)
val messages = logBuffer.snapshot().map { it.message }
assertTrue(messages.any { it.startsWith("auth backoff acct:") && it.contains("failures=1") })
assertTrue(messages.any { it.startsWith("auth recovered acct:") })
messages.forEach {
assertFalse(it.contains("secret.user@example.org"), it)
assertFalse(it.contains("yahoo"), it)
}
}
@Test
fun `onAuthSuccess on a healthy account is silent`() {
gate().onAuthSuccess(params())
assertTrue(logBuffer.snapshot().isEmpty())
}
@Test
fun `composes with the reactive throttle gate without interference (issue #360)`() {
val authGate = gate()
val throttleGate = AccountThrottleGate(nowMillis = { now }, random = { 0.0 })
val p = params()
// The same account can be BOTH proactively auth-backing-off and reactively throttled; the two
// gates keep independent state and neither clears the other.
val authBlock = authGate.onAuthFailure(p)
val lockout = throttleGate.onThrottle("acct", ThrottleSignal(ThrottleKind.LOCKOUT))
assertTrue(authGate.isAuthBlocked(p))
assertTrue(throttleGate.isThrottled("acct"))
authGate.onAuthSuccess(p)
assertFalse(authGate.isAuthBlocked(p))
assertTrue(throttleGate.isThrottled("acct"), "clearing the auth gate must not clear the reactive one")
// The proactive auth backoff is always shorter than the reactive lockout it prevents reaching.
assertTrue(authBlock < lockout, "proactive auth backoff ($authBlock) must be shorter than lockout ($lockout)")
}
private companion object {
const val PORT = 993
const val RAPID_FAILURES = 10
}
}
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.ServerSetupTest
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailProvider
import org.libremail.domain.model.MailSecurity
import java.net.ServerSocket
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The issue-#362 auth circuit-breaker's **enforcement** inside [ImapClient], against a real GreenMail
* IMAP server. Proves the load-bearing distinctions end to end:
* - a rejected `LOGIN` (auth failure) **arms** the proactive backoff;
* - a transient connect error (connection refused) **does not** — "transient IMAP error ≠ auth backoff";
* - while backing off, a subsequent login is **skipped** ([AuthBackoffException]) rather than attempted,
* even with a correct credential, so no failed-login storm can reach the provider;
* - a successful login leaves (or clears) the backoff.
*
* The gate is injected with an always-Yahoo policy and a manual clock, so a GreenMail server on
* `127.0.0.1` is treated as a gated Yahoo/AOL account and the backoff window is controllable without
* real sleeps. Both the connect-per-op and connection-reuse code paths funnel through the same guarded
* `openConnectedStore`, so a reuse-on client is checked too.
*/
class ImapClientAuthBackoffTest {
private lateinit var greenMail: GreenMail
private var now = 0L
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
private val gate = AuthThrottleGate(nowMillis = { now }, random = { 0.0 }, policyForHost = { yahoo })
private fun client(reuse: Boolean = false) = ImapClient(reuseConnections = reuse, authGate = gate)
@Before
fun setUp() {
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
greenMail.start()
greenMail.setUser("alice@example.org", "secret")
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
}
@After
fun tearDown() {
greenMail.stop()
unmockkAll()
}
private fun params(secret: String = "secret", port: Int = greenMail.imap.port) = ImapConnectionParams(
host = "127.0.0.1",
port = port,
security = MailSecurity.NONE,
username = "alice@example.org",
secret = secret,
useXoauth2 = false,
)
@Test
fun `a wrong-password auth failure arms the proactive backoff`() = runTest {
assertFailsWith<Exception> { client().listFolders(params(secret = "wrong-password")) }
assertTrue(gate.isAuthBlocked(params()), "a rejected LOGIN must arm the auth circuit-breaker")
}
@Test
fun `a transient connect error does not arm the backoff`() = runTest {
val closedPort = ServerSocket(0).use { it.localPort } // now free → connection refused, not an auth NO
assertFailsWith<Exception> { client().listFolders(params(port = closedPort)) }
assertFalse(
gate.isAuthBlocked(params(port = closedPort)),
"a transient (non-auth) connect error must NOT arm the auth backoff",
)
}
@Test
fun `a successful login leaves the backoff clear`() = runTest {
client().listFolders(params())
assertFalse(gate.isAuthBlocked(params()))
}
@Test
fun `while backing off, a login is skipped even with a correct credential`() = runTest {
// Arm the backoff with one rejected login...
assertFailsWith<Exception> { client().listFolders(params(secret = "wrong-password")) }
assertTrue(gate.isAuthBlocked(params()))
// ...then further logins are SKIPPED (AuthBackoffException from the guard), not attempted — even a
// correct credential and a different operation, so no failed-login storm reaches the provider.
assertFailsWith<AuthBackoffException> { client().listFolders(params()) }
assertFailsWith<AuthBackoffException> { client().fetchRecent(params(), "INBOX", 10) }
}
@Test
fun `the reuse path is gated too`() = runTest {
// The connection-reuse client establishes its warm socket via the same guarded openConnectedStore.
assertFailsWith<Exception> { client(reuse = true).listFolders(params(secret = "wrong-password")) }
assertTrue(gate.isAuthBlocked(params()))
assertFailsWith<AuthBackoffException> { client(reuse = true).listFolders(params()) }
}
@Test
fun `a login succeeds again once the backoff window elapses`() = runTest {
assertFailsWith<Exception> { client().listFolders(params(secret = "wrong-password")) }
now += gate.remainingAuthBlockMillis(params()) // fast-forward past the window
client().listFolders(params()) // a correct login now goes through and clears the state
assertFalse(gate.isAuthBlocked(params()))
}
}
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import org.junit.Test
import org.libremail.domain.model.MailProvider
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [ProviderAuthPolicy] must gate **only** Yahoo/AOL — the providers with the ~1-hour auth lockout
* (issue #362) — with the conservative, lockout-avoiding cadence, and leave every other host
* ([AuthCadencePolicy.DISABLED]) so siblings #361/#363/#364 and manual servers are unaffected. It must
* also expose the documented connection / folder-index ceilings, and its whole schedule must stay under
* the ~1-hour lockout window it protects.
*/
class ProviderAuthPolicyTest {
/** The real IMAP host a provider's accounts use — the single source of truth, not a hard-coded string. */
private fun hostOf(provider: MailProvider) = provider.createAccount("user@example.com").imap.host
@Test
fun `yahoo and aol get the enabled lockout-avoiding policy`() {
for (provider in listOf(MailProvider.YAHOO, MailProvider.AOL)) {
val policy = ProviderAuthPolicy.forHost(hostOf(provider))
assertTrue(policy.enabled, "${provider.displayName} must be gated against its 1-hour auth lockout")
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_BASE_MS, policy.baseBackoffMillis)
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_MAX_MS, policy.maxBackoffMillis)
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_THRESHOLD, policy.circuitOpenThreshold)
assertEquals(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_MS, policy.circuitOpenMillis)
assertEquals(ProviderAuthPolicy.YAHOO_MAX_CONCURRENT_CONNECTIONS, policy.maxConcurrentConnections)
assertEquals(ProviderAuthPolicy.YAHOO_FOLDER_INDEX_CAP, policy.folderIndexCap)
}
}
@Test
fun `gmail and icloud are not gated`() {
for (provider in listOf(MailProvider.GMAIL, MailProvider.ICLOUD)) {
assertFalse(
ProviderAuthPolicy.forHost(hostOf(provider)).enabled,
"${provider.displayName} has no 1-hour auth lockout; issue #362 must leave it ungated",
)
}
}
@Test
fun `outlook, unknown, and blank hosts are not gated`() {
assertFalse(ProviderAuthPolicy.forHost("outlook.office365.com").enabled)
assertFalse(ProviderAuthPolicy.forHost("imap.example.com").enabled)
assertFalse(ProviderAuthPolicy.forHost("").enabled)
}
@Test
fun `the disabled policy can never block a login`() {
// A disabled policy's threshold is unreachable and its windows are zero, so a non-Yahoo/AOL
// account is provably never gated regardless of how [AuthBackoff] is called.
assertFalse(AuthCadencePolicy.DISABLED.enabled)
assertEquals(Int.MAX_VALUE, AuthCadencePolicy.DISABLED.circuitOpenThreshold)
assertEquals(0L, AuthBackoff.blockMillis(AuthCadencePolicy.DISABLED, consecutiveFailures = 1, random = 1.0))
}
@Test
fun `the whole yahoo schedule stays under the one-hour lockout window`() {
// Recovery must beat the lockout: every wait the policy can impose is under an hour, so a
// recovered account resumes far sooner than a self-inflicted hour of silence.
assertTrue(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_BASE_MS < ONE_HOUR_MS)
assertTrue(ProviderAuthPolicy.YAHOO_AUTH_BACKOFF_MAX_MS < ONE_HOUR_MS)
assertTrue(ProviderAuthPolicy.YAHOO_AUTH_CIRCUIT_OPEN_MS < ONE_HOUR_MS)
}
@Test
fun `the documented connection and folder-index ceilings are exposed`() {
// Reuse (issues #125/#357, ON by default) keeps an account to ~1 warm IMAP socket + at most one
// IDLE connection = 2, comfortably under this documented ceiling of 5.
assertEquals(5, ProviderAuthPolicy.YAHOO_MAX_CONCURRENT_CONNECTIONS)
assertEquals(10_000, ProviderAuthPolicy.YAHOO_FOLDER_INDEX_CAP)
}
private companion object {
const val ONE_HOUR_MS = 60 * 60_000L
}
}