From b03e36388814776748d9cd7d1b5f53b691712de5 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 8 Jul 2026 19:39:13 -0500 Subject: [PATCH] perf(yahoo): respect Yahoo/AOL IMAP limits & avoid the 1-hour auth lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../mail/AuthThrottleGateInstrumentedTest.kt | 105 ++++++++ .../org/libremail/data/sync/MailBackfiller.kt | 45 +++- .../kotlin/org/libremail/mail/AuthBackoff.kt | 48 ++++ .../org/libremail/mail/AuthThrottleGate.kt | 136 ++++++++++ .../kotlin/org/libremail/mail/ImapClient.kt | 76 +++++- .../org/libremail/mail/ProviderAuthPolicy.kt | 139 ++++++++++ .../libremail/data/sync/MailBackfillerTest.kt | 35 +++ .../data/sync/MailMaintenanceGateTest.kt | 2 + .../data/sync/MailSyncConcurrencyTest.kt | 2 + .../org/libremail/mail/AuthBackoffTest.kt | 104 ++++++++ .../libremail/mail/AuthThrottleGateTest.kt | 237 ++++++++++++++++++ .../mail/ImapClientAuthBackoffTest.kt | 125 +++++++++ .../libremail/mail/ProviderAuthPolicyTest.kt | 82 ++++++ 13 files changed, 1119 insertions(+), 17 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/mail/AuthThrottleGateInstrumentedTest.kt create mode 100644 app/src/main/kotlin/org/libremail/mail/AuthBackoff.kt create mode 100644 app/src/main/kotlin/org/libremail/mail/AuthThrottleGate.kt create mode 100644 app/src/main/kotlin/org/libremail/mail/ProviderAuthPolicy.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/AuthBackoffTest.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/AuthThrottleGateTest.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/ImapClientAuthBackoffTest.kt create mode 100644 app/src/test/kotlin/org/libremail/mail/ProviderAuthPolicyTest.kt diff --git a/app/src/androidTest/kotlin/org/libremail/mail/AuthThrottleGateInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/mail/AuthThrottleGateInstrumentedTest.kt new file mode 100644 index 0000000..acb11a2 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/mail/AuthThrottleGateInstrumentedTest.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt b/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt index 42bb8f0..3be6ab2 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt @@ -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 diff --git a/app/src/main/kotlin/org/libremail/mail/AuthBackoff.kt b/app/src/main/kotlin/org/libremail/mail/AuthBackoff.kt new file mode 100644 index 0000000..7c79482 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/AuthBackoff.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/libremail/mail/AuthThrottleGate.kt b/app/src/main/kotlin/org/libremail/mail/AuthThrottleGate.kt new file mode 100644 index 0000000..315f484 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/AuthThrottleGate.kt @@ -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() + + /** + * 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" + } +} diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 4a49550..fd36f72 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -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() + 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 diff --git a/app/src/main/kotlin/org/libremail/mail/ProviderAuthPolicy.kt b/app/src/main/kotlin/org/libremail/mail/ProviderAuthPolicy.kt new file mode 100644 index 0000000..8bd3691 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/ProviderAuthPolicy.kt @@ -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 + } +} diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt index 4a9ad75..16b83b9 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailBackfillerTest.kt @@ -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(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() 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 diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailMaintenanceGateTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailMaintenanceGateTest.kt index 410f941..83d6c97 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailMaintenanceGateTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailMaintenanceGateTest.kt @@ -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(), ) } diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailSyncConcurrencyTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailSyncConcurrencyTest.kt index c659e8e..cfe13ab 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/MailSyncConcurrencyTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/MailSyncConcurrencyTest.kt @@ -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(), ) } diff --git a/app/src/test/kotlin/org/libremail/mail/AuthBackoffTest.kt b/app/src/test/kotlin/org/libremail/mail/AuthBackoffTest.kt new file mode 100644 index 0000000..417f642 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/AuthBackoffTest.kt @@ -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 { + 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 + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/AuthThrottleGateTest.kt b/app/src/test/kotlin/org/libremail/mail/AuthThrottleGateTest.kt new file mode 100644 index 0000000..42e4e13 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/AuthThrottleGateTest.kt @@ -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(), any()) } 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 + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientAuthBackoffTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientAuthBackoffTest.kt new file mode 100644 index 0000000..b751d42 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientAuthBackoffTest.kt @@ -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(), any()) } 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 { 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 { 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 { 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 { client().listFolders(params()) } + assertFailsWith { 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 { client(reuse = true).listFolders(params(secret = "wrong-password")) } + assertTrue(gate.isAuthBlocked(params())) + assertFailsWith { client(reuse = true).listFolders(params()) } + } + + @Test + fun `a login succeeds again once the backoff window elapses`() = runTest { + assertFailsWith { 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())) + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/ProviderAuthPolicyTest.kt b/app/src/test/kotlin/org/libremail/mail/ProviderAuthPolicyTest.kt new file mode 100644 index 0000000..dd7155f --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/ProviderAuthPolicyTest.kt @@ -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 + } +}