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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user