From 21c74df7947646a0b267129bbd3b721e917ea3b7 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 4 Jul 2026 21:22:37 -0500 Subject: [PATCH] feat(logging): connectivity/send -> AppLog + breadcrumbs, scrub email PII Migrate ImapClient/SendWorker/IdleService off raw android.util.Log to AppLog, per the debug-logging strangler epic (#324), so their diagnostics reach the RingLogBuffer (and a submitted DebugReport) instead of logcat-only: - ImapClient: IDLE connect + IDLE push (message count) breadcrumbs. - SendWorker: outbox-drain count on entry, per-message sent/failed result, and the existing Graph->SMTP fallback warning. - IdleService: IDLE watch start, cache-locked defer, and the existing IDLE-dropped/retrying warning. Also closes #297: SendWorker and IdleService logged the raw account.email via Log.w on the Graph->SMTP fallback and IDLE-drop paths. Both now log accountLogRef(account.id) instead -- a short, stable, non-reversible per-account reference -- so the account's email never reaches Logcat or a report. Rewrites ImapClientTest/SendWorkerTest to install a real RingLogBuffer via AppLog.install(...) and assert on its contents (migrated calls + new breadcrumbs), instead of verifying a mocked Log; every assertion also checks no line carries the test account's email, regression-covering #297. Adds a SendWorkerTest case that drives a real SmtpSender against an in-process GreenMail SMTP server end to end. android.util.Log is still stubbed (by fully-qualified name, without importing it) where AppLog's Logcat passthrough would otherwise crash the unmocked Android stub in a JVM test. Closes #328 Closes #297 Part of #324 Co-Authored-By: Claude Opus 4.8 --- .../org/libremail/data/sync/SendWorker.kt | 132 +++++++++++------- .../kotlin/org/libremail/mail/ImapClient.kt | 6 +- .../kotlin/org/libremail/push/IdleService.kt | 7 +- .../org/libremail/data/sync/SendWorkerTest.kt | 122 +++++++++++++++- .../org/libremail/mail/ImapClientTest.kt | 24 +++- 5 files changed, 221 insertions(+), 70 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 087253a..4bade40 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -2,7 +2,6 @@ package org.libremail.data.sync import android.content.Context -import android.util.Log import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters @@ -24,6 +23,8 @@ import org.libremail.mail.GraphSendException import org.libremail.mail.GraphSender import org.libremail.mail.SendableAttachment import org.libremail.mail.SmtpSender +import org.libremail.reporting.AppLog +import org.libremail.reporting.accountLogRef import java.io.File import kotlin.coroutines.cancellation.CancellationException @@ -57,66 +58,89 @@ class SendWorker @AssistedInject constructor( val attachmentUriGrants = this.attachmentUriGrants.get() val pending = outboxDao.getAll() if (pending.isEmpty()) return Result.success() + AppLog.i(TAG, "outbox drain: ${pending.size} queued") var anyFailed = false for (entity in pending) { - val attachmentDir = File(applicationContext.cacheDir, "outbox/${entity.id}") - val account = accountDao.getById(entity.accountId)?.toDomain() - if (account == null) { - outboxDao.delete(entity.id) // account removed — drop the queued message - attachmentDir.deleteRecursively() - attachmentUriGrants.releaseUnreferenced(entity.attachmentUris()) - continue - } - runCatching { - val message = OutgoingMessage( - accountId = entity.accountId, - to = entity.toAddresses, - cc = entity.ccAddresses, - bcc = entity.bccAddresses, - subject = entity.subject, - body = entity.body, - bodyHtml = entity.bodyHtml, - ) - val attachments = stagedAttachments(attachmentDir, entity.attachments.toOutgoingAttachments()) - if (account.authType == AuthType.OAUTH_OUTLOOK) { - sendOutlook(connectionFactory, account, message, attachments) - } else { - smtpSender.send( - connectionFactory.smtpParamsFor(account), - from = account.email, - message = message, - attachments = attachments, - ) - } - }.fold( - onSuccess = { - outboxDao.delete(entity.id) - attachmentDir.deleteRecursively() - // The picked bytes were staged at enqueue; with the row sent, drop the persistable - // grant unless a live draft/outbox row still references the same URI (security review). - attachmentUriGrants.releaseUnreferenced(entity.attachmentUris()) - }, - onFailure = { e -> - if (e is GraphSendException && e.mayHaveSent) { - // Graph may already have delivered this; auto-retrying (or any other send) - // would duplicate it, so leave it queued with a clear status and let the - // user decide. Not counted as a failure, so WorkManager won't auto-retry. - outboxDao.setError( - entity.id, - "Send status unknown — check your Sent folder, then retry or cancel", - ) - } else { - outboxDao.setError(entity.id, e.message) - anyFailed = true - } - }, - ) + val failed = sendQueued(entity, outboxDao, accountDao, connectionFactory, attachmentUriGrants) + if (failed) anyFailed = true } // Retry (with WorkManager backoff) so failed sends are reattempted when conditions improve. return if (anyFailed) Result.retry() else Result.success() } + /** + * Sends one queued [entity] — or drops it if its account was removed — updating the outbox row + * and releasing its staged attachment grant. Returns true if the send genuinely failed (should + * count toward a WorkManager retry); the ambiguous "may have sent" Graph case returns false, since + * it is deliberately left queued rather than retried (see [sendOutlook]). + */ + private suspend fun sendQueued( + entity: OutboxEntity, + outboxDao: OutboxDao, + accountDao: AccountDao, + connectionFactory: MailConnectionFactory, + attachmentUriGrants: AttachmentUriGrants, + ): Boolean { + val attachmentDir = File(applicationContext.cacheDir, "outbox/${entity.id}") + val account = accountDao.getById(entity.accountId)?.toDomain() + if (account == null) { + outboxDao.delete(entity.id) // account removed — drop the queued message + attachmentDir.deleteRecursively() + attachmentUriGrants.releaseUnreferenced(entity.attachmentUris()) + return false + } + var failed = false + runCatching { + val message = OutgoingMessage( + accountId = entity.accountId, + to = entity.toAddresses, + cc = entity.ccAddresses, + bcc = entity.bccAddresses, + subject = entity.subject, + body = entity.body, + bodyHtml = entity.bodyHtml, + ) + val attachments = stagedAttachments(attachmentDir, entity.attachments.toOutgoingAttachments()) + if (account.authType == AuthType.OAUTH_OUTLOOK) { + sendOutlook(connectionFactory, account, message, attachments) + } else { + smtpSender.send( + connectionFactory.smtpParamsFor(account), + from = account.email, + message = message, + attachments = attachments, + ) + } + }.fold( + onSuccess = { + outboxDao.delete(entity.id) + attachmentDir.deleteRecursively() + // The picked bytes were staged at enqueue; with the row sent, drop the persistable + // grant unless a live draft/outbox row still references the same URI (security review). + attachmentUriGrants.releaseUnreferenced(entity.attachmentUris()) + val via = if (account.authType == AuthType.OAUTH_OUTLOOK) "Graph" else "SMTP" + AppLog.i(TAG, "sent ${accountLogRef(account.id)} via $via") + }, + onFailure = { e -> + if (e is GraphSendException && e.mayHaveSent) { + // Graph may already have delivered this; auto-retrying (or any other send) would + // duplicate it, so leave it queued with a clear status and let the user decide. + // Not counted as a failure, so WorkManager won't auto-retry. + outboxDao.setError( + entity.id, + "Send status unknown — check your Sent folder, then retry or cancel", + ) + } else { + outboxDao.setError(entity.id, e.message) + failed = true + AppLog.w(TAG, "send failed for ${accountLogRef(account.id)}; will retry") + } + }, + ) + return failed + } + /** * Outlook prefers Microsoft Graph. Fall back to SMTP only when Graph definitely did NOT send * (a rejection, a pre-send/transport error, or a token failure); never fall back when the Graph @@ -143,7 +167,7 @@ class SendWorker @AssistedInject constructor( throw e } catch (e: Exception) { // Graph was never reached (e.g. token refresh failed) — SMTP cannot duplicate it. - Log.w(TAG, "Graph send failed for ${account.email}; falling back to SMTP", e) + AppLog.w(TAG, "Graph send failed for ${accountLogRef(account.id)}; falling back to SMTP", e) smtpSender.send( connectionFactory.smtpParamsFor(account), from = account.email, diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 43d11ea..dfb4a3f 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail -import android.util.Log import jakarta.mail.FetchProfile import jakarta.mail.Flags import jakarta.mail.Folder @@ -33,6 +32,7 @@ import org.eclipse.angus.mail.imap.IMAPFolder import org.eclipse.angus.mail.imap.IMAPMessage import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity +import org.libremail.reporting.AppLog import java.util.Properties import javax.inject.Inject import javax.inject.Singleton @@ -474,12 +474,12 @@ class ImapClient(private val reuseConnections: Boolean) { runCatching { store.close() } throw e } - Log.d(TAG, "IDLE connected") + AppLog.d(TAG, "IDLE connected") val pushes = Channel(Channel.CONFLATED) inbox.addMessageCountListener(object : MessageCountAdapter() { override fun messagesAdded(event: MessageCountEvent) { - Log.d(TAG, "IDLE push: ${event.messages.size} new message(s)") + AppLog.d(TAG, "IDLE push: ${event.messages.size} new message(s)") pushes.trySend(Unit) } }) diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 801247c..f41e1ef 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.IBinder -import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -38,6 +37,7 @@ import org.libremail.domain.model.Account import org.libremail.mail.ImapClient import org.libremail.power.BatteryStatusProvider import org.libremail.reporting.AppLog +import org.libremail.reporting.accountLogRef import javax.inject.Inject /** @@ -91,7 +91,7 @@ class IdleService : Service() { // Can't open the encrypted DB without the user present. Defer (stop) and let the app // restart push after the next unlock, rather than block the service and ANR. if (cacheGuard.isCacheLocked()) { - Log.i(TAG, "encrypted cache locked; deferring IDLE push until the app is unlocked") + AppLog.i(TAG, "encrypted cache locked; deferring IDLE push until the app is unlocked") stopSelf() return@launch } @@ -200,6 +200,7 @@ class IdleService : Service() { * idle()'s on-connect sync, so no mail is missed across renewals. */ private suspend fun watchAccount(account: Account) { + AppLog.i(TAG, "IDLE watch start ${accountLogRef(account.id)}") var backoffMs = INITIAL_BACKOFF_MS while (scope.isActive) { try { @@ -212,7 +213,7 @@ class IdleService : Service() { } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.w(TAG, "IDLE for ${account.email} dropped; retrying in ${backoffMs}ms", e) + AppLog.w(TAG, "IDLE for ${accountLogRef(account.id)} dropped; retrying in ${backoffMs}ms", e) delay(backoffMs) backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) } diff --git a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt index 2b9361d..1326337 100644 --- a/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/sync/SendWorkerTest.kt @@ -2,8 +2,9 @@ package org.libremail.data.sync import android.content.Context -import android.util.Log import androidx.work.ListenableWorker.Result +import com.icegreen.greenmail.util.GreenMail +import com.icegreen.greenmail.util.ServerSetupTest import dagger.Lazy import io.mockk.coEvery import io.mockk.coVerify @@ -25,12 +26,16 @@ import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.data.local.toOutgoingAttachmentsJson import org.libremail.data.security.EncryptedCacheGuard +import org.libremail.domain.model.MailSecurity import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.SmtpParams import org.libremail.mail.GraphSendException import org.libremail.mail.GraphSender import org.libremail.mail.SendableAttachment import org.libremail.mail.SmtpSender +import org.libremail.reporting.AppLog +import org.libremail.reporting.RingLogBuffer +import org.libremail.reporting.accountLogRef import java.io.File import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -42,6 +47,11 @@ import kotlin.test.assertTrue * the outbox: sending each queued message over SMTP or Microsoft Graph, deleting it on success, * flagging failures for a retry, and — crucially for the "may have sent" case — never auto-retrying * a Graph request that might already have delivered. + * + * It also logs breadcrumbs via [AppLog] (outbox-drain size, per-message send result, the Graph→SMTP + * fallback) — asserted here against a real [RingLogBuffer] rather than a mocked `Log`, per #328. Those + * assertions double as the regression cover for #297: `account.email` must never reach a log line, + * only the non-PII [accountLogRef]. */ class SendWorkerTest { @@ -56,6 +66,7 @@ class SendWorkerTest { private val lazyConnection = mockk> { every { get() } returns connectionFactory } private val lazyGrants = mockk> { every { get() } returns attachmentUriGrants } private val cacheGuard = mockk() + private val logBuffer = RingLogBuffer() private lateinit var cacheDir: File private lateinit var appContext: Context @@ -66,6 +77,16 @@ class SendWorkerTest { appContext = mockk(relaxed = true) every { appContext.cacheDir } returns cacheDir coEvery { cacheGuard.isCacheLocked() } returns false + // AppLog forwards every call to Logcat; stub the Android stub (by fully-qualified name, so + // this file — like the production code it exercises — never imports android.util.Log; only + // AppLog.kt may) so a JVM unit test doesn't crash on the unmocked method, and install a real + // buffer so the breadcrumb + #297 no-PII assertions below read actual recorded lines rather + // than a `Log` verification. + mockkStatic(android.util.Log::class) + every { android.util.Log.i(any(), any()) } returns 0 + every { android.util.Log.w(any(), any()) } returns 0 + every { android.util.Log.w(any(), any(), any()) } returns 0 + AppLog.install(logBuffer) } @After @@ -128,6 +149,17 @@ class SendWorkerTest { coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) } } + @Test + fun `doWork logs the outbox-drain breadcrumb with the queued count`() = runTest { + coEvery { outboxDao.getAll() } returns listOf(entity()) + coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP") + coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk() + + worker().doWork() + + assertTrue(logBuffer.snapshot().map { it.message }.contains("outbox drain: 1 queued")) + } + @Test fun `drops a queued message whose account was removed`() = runTest { val row = entity() @@ -142,7 +174,7 @@ class SendWorkerTest { } @Test - fun `sends a password account message over SMTP then clears it`() = runTest { + fun `sends a password account message over SMTP then clears it, logging a PII-free breadcrumb`() = runTest { coEvery { outboxDao.getAll() } returns listOf(entity()) coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP") coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk() @@ -152,10 +184,13 @@ class SendWorkerTest { coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) } coVerify { outboxDao.delete("m1") } coVerify { attachmentUriGrants.releaseUnreferenced(any()) } + val messages = logBuffer.snapshot().map { it.message } + assertTrue(messages.contains("sent ${accountLogRef("acct")} via SMTP"), "messages=$messages") + messages.forEach { assertFalse(it.contains("acct@example.org"), it) } } @Test - fun `an SMTP failure flags the row and retries`() = runTest { + fun `an SMTP failure flags the row, retries, and logs a PII-free failure breadcrumb`() = runTest { coEvery { outboxDao.getAll() } returns listOf(entity()) coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP") coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk() @@ -165,10 +200,13 @@ class SendWorkerTest { coVerify { outboxDao.setError("m1", "smtp down") } coVerify(exactly = 0) { outboxDao.delete(any()) } + val messages = logBuffer.snapshot().map { it.message } + assertTrue(messages.contains("send failed for ${accountLogRef("acct")}; will retry"), "messages=$messages") + messages.forEach { assertFalse(it.contains("acct@example.org"), it) } } @Test - fun `sends an Outlook message over Graph`() = runTest { + fun `sends an Outlook message over Graph, logging a PII-free breadcrumb`() = runTest { coEvery { outboxDao.getAll() } returns listOf(entity()) coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK") coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token" @@ -178,6 +216,9 @@ class SendWorkerTest { coVerify { graphSender.send("graph-token", any(), any()) } coVerify { outboxDao.delete("m1") } coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) } + val messages = logBuffer.snapshot().map { it.message } + assertTrue(messages.contains("sent ${accountLogRef("acct")} via Graph"), "messages=$messages") + messages.forEach { assertFalse(it.contains("acct@example.org"), it) } } @Test @@ -212,9 +253,7 @@ class SendWorkerTest { } @Test - fun `a Graph transport error falls back to SMTP`() = runTest { - mockkStatic(Log::class) - every { Log.w(any(), any(), any()) } returns 0 + fun `a Graph transport error falls back to SMTP and logs a PII-free fallback breadcrumb`() = runTest { coEvery { outboxDao.getAll() } returns listOf(entity()) coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK") coEvery { connectionFactory.graphTokenFor(any()) } throws RuntimeException("token refresh failed") @@ -224,6 +263,75 @@ class SendWorkerTest { coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) } coVerify { outboxDao.delete("m1") } + val messages = logBuffer.snapshot().map { it.message } + assertTrue( + messages.any { it.startsWith("Graph send failed for ${accountLogRef("acct")}; falling back to SMTP") }, + "messages=$messages", + ) + messages.forEach { assertFalse(it.contains("acct@example.org"), it) } + } + + @Test + fun `regression #297 - no send breadcrumb ever contains the account email`() = runTest { + coEvery { outboxDao.getAll() } returns listOf(entity()) + coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK") + coEvery { connectionFactory.graphTokenFor(any()) } throws RuntimeException("token refresh failed") + coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk() + + worker().doWork() + + val snapshot = logBuffer.snapshot() + // This path logs the outbox-drain, the Graph->SMTP fallback (with a scrubbed throwable), and + // the send-result breadcrumbs — the richest set of log lines for one message. None may carry + // the account's raw email, only the non-reversible accountLogRef. + assertTrue(snapshot.isNotEmpty(), "expected breadcrumbs to be recorded") + snapshot.forEach { entry -> + assertFalse(entry.message.contains("acct@example.org"), entry.message) + assertFalse(entry.message.contains("@example.org"), entry.message) + } + } + + @Test + fun `sends over a real SMTP server end-to-end and logs a PII-free breadcrumb`() = runTest { + // The "connectivity/send" E2E surface for this ticket: a real SmtpSender talking to a real + // (in-process) SMTP server, rather than the mocked smtpSender used by the tests above. + val greenMail = GreenMail(ServerSetupTest.SMTP) + greenMail.start() + greenMail.setUser("acct@example.org", "smtp-secret") + try { + coEvery { outboxDao.getAll() } returns listOf(entity()) + coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP") + coEvery { connectionFactory.smtpParamsFor(any()) } returns SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "acct@example.org", + secret = "smtp-secret", + useXoauth2 = false, + ) + val realSmtpWorker = SendWorker( + appContext, + mockk(relaxed = true), + lazyOutbox, + lazyAccount, + SmtpSender(), + graphSender, + lazyConnection, + cacheGuard, + lazyGrants, + ) + + assertEquals(Result.success(), realSmtpWorker.doWork()) + greenMail.waitForIncomingEmail(1) + + assertEquals(1, greenMail.receivedMessages.size) + coVerify { outboxDao.delete("m1") } + val messages = logBuffer.snapshot().map { it.message } + assertTrue(messages.contains("sent ${accountLogRef("acct")} via SMTP"), "messages=$messages") + messages.forEach { assertFalse(it.contains("acct@example.org"), it) } + } finally { + greenMail.stop() + } } @Test diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index 7fc9131..0acab89 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail -import android.util.Log import com.icegreen.greenmail.util.GreenMail import com.icegreen.greenmail.util.GreenMailUtil import com.icegreen.greenmail.util.ServerSetupTest @@ -31,6 +30,8 @@ import org.junit.Before import org.junit.Test import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity +import org.libremail.reporting.AppLog +import org.libremail.reporting.RingLogBuffer import java.util.Properties import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -359,8 +360,14 @@ class ImapClientTest { @Test fun `idle syncs once on connect, again on newly delivered mail, and stops on cancel`() = runBlocking { - mockkStatic(Log::class) // idle() logs connect/push at debug level - every { Log.d(any(), any()) } returns 0 + // idle() logs connect/push breadcrumbs via AppLog, which forwards to Logcat; stub the Android + // stub (by fully-qualified name, so this file never imports android.util.Log — only AppLog.kt + // may) so the JVM test doesn't crash on the unmocked method, and install a real buffer so the + // breadcrumbs can be asserted directly instead of via a Log verification. + mockkStatic(android.util.Log::class) + every { android.util.Log.d(any(), any()) } returns 0 + val buffer = RingLogBuffer() + AppLog.install(buffer) val activity = Channel(Channel.UNLIMITED) val job = launch(Dispatchers.IO) { client.idle(params()) { activity.send(Unit) } } try { @@ -373,6 +380,17 @@ class ImapClientTest { job.cancelAndJoin() // cancelling closes the connection and unblocks idle() } assertTrue(job.isCompleted, "the idle loop must terminate on cancellation") + + val messages = buffer.snapshot().map { it.message } + assertTrue(messages.contains("IDLE connected"), "messages=$messages") + assertTrue(messages.any { it == "IDLE push: 1 new message(s)" }, "messages=$messages") + // idle() only holds host/username via ImapConnectionParams and must stay account-agnostic — + // attribution belongs to the IdleService caller (accountLogRef) — regression guard for #297. + val connectionParams = params() + messages.forEach { message -> + assertFalse(message.contains(connectionParams.host), message) + assertFalse(message.contains(connectionParams.username), message) + } } /** Creates [folderName] (holding messages) if it does not already exist. */ -- 2.47.3