Files
LibreMail/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
T
JMR-devandClaude Opus 4.8 21c74df794 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 <noreply@anthropic.com>
2026-07-04 21:22:37 -05:00

696 lines
33 KiB
Kotlin

// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.FetchProfile
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Multipart
import jakarta.mail.Part
import jakarta.mail.Session
import jakarta.mail.Store
import jakarta.mail.UIDFolder
import jakarta.mail.event.MessageCountAdapter
import jakarta.mail.event.MessageCountEvent
import jakarta.mail.internet.ContentType
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimePart
import jakarta.mail.internet.MimeUtility
import jakarta.mail.search.BodyTerm
import jakarta.mail.search.FromStringTerm
import jakarta.mail.search.OrTerm
import jakarta.mail.search.SubjectTerm
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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
/** A folder (mailbox) listed from the server, with the metadata needed to classify and display it. */
data class FetchedFolder(
val fullName: String,
val displayName: String,
/** Raw IMAP attributes from the LIST response (RFC 6154 SPECIAL-USE flags, \Noselect, …). */
val attributes: List<String>,
/** False for \Noselect containers (e.g. Gmail's "[Gmail]" parent) that can't be opened. */
val selectable: Boolean,
/**
* The hierarchy separator the server reported for this folder in its LIST response (e.g. '/' for
* Gmail, '.' for some servers). Null when the server reported none (a flat namespace) or it could
* not be read. Persisted so a folder's parent is split on the authoritative delimiter rather than
* one re-inferred from the name (issue #66).
*/
val hierarchyDelimiter: Char? = null,
)
/** A message header fetched from the server (no body — that arrives with the reader). */
data class FetchedMessage(
val uid: String,
val sender: String,
val senderEmail: String,
val subject: String,
val timestampMillis: Long,
val isRead: Boolean,
val isFlagged: Boolean,
)
/** A message body extracted from the server, with metadata for any attachment parts. */
data class MessageContent(val body: String, val isHtml: Boolean, val attachments: List<AttachmentPart> = emptyList())
/**
* Metadata for one downloadable part. [partIndex] is its position in attachment-tree order.
* [contentId] is the normalized `Content-ID` (angle brackets stripped) for an inline image referenced
* from the HTML body via `cid:` — null for an ordinary attachment. Inline parts are persisted so the
* reader can resolve `cid:` requests, but excluded from the user-facing attachment list.
*/
data class AttachmentPart(
val partIndex: Int,
val filename: String,
val mimeType: String,
val sizeBytes: Long,
val contentId: String? = null,
)
/** A downloaded attachment's bytes plus the metadata needed to open it. */
class DownloadedAttachment(val filename: String, val mimeType: String, val bytes: ByteArray)
/** The original message's fields needed to compose a reply, reply-all, or forward. */
data class ReplyContext(
val fromEmail: String,
val toRecipients: List<String>,
val ccRecipients: List<String>,
val subject: String,
val sentDateMillis: Long,
val body: String,
val isHtml: Boolean,
)
/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@Singleton
class ImapClient(private val reuseConnections: Boolean) {
/**
* Production entry point. Connection reuse is a SPIKE flag (issue #125), **OFF by default** so it
* cannot destabilize the connect-per-operation behaviour on `main`: with it off, [withStore] is
* byte-for-byte today's connect + LOGOUT-per-call. Once real-device validation (see
* `docs/perf/issue-125-connection-reuse-spike.md`) confirms the win, wire this to a setting or
* `BuildConfig`; today only the reuse harness flips it on via the primary constructor.
*/
@Inject constructor() : this(reuseConnections = false)
/**
* Per-account keep-alive cache; allocated only when the spike flag is on, so the default build
* carries neither the state nor the reuse code path.
*/
private val connectionCache: ImapConnectionCache? =
if (reuseConnections) ImapConnectionCache(::openConnectedStore) else null
/** Connects and returns the account's folders with their SPECIAL-USE attributes. Throws on failure. */
suspend fun listFolders(params: ImapConnectionParams): List<FetchedFolder> = withContext(Dispatchers.IO) {
withStore(params) { store ->
val folders = store.defaultFolder.list("*").map { folder ->
val attributes = if (folder is IMAPFolder) {
runCatching { folder.attributes.toList() }.getOrDefault(emptyList())
} else {
emptyList()
}
val reportedSeparator = runCatching { folder.separator }.getOrNull()
// Fall back to '/' only for splitting off the display name; the persisted delimiter
// stays null when the server reported none, so parentOf can tell "unknown" (infer)
// from a real separator.
val separator = reportedSeparator ?: '/'
FetchedFolder(
fullName = folder.fullName,
displayName = folder.fullName.substringAfterLast(separator),
attributes = attributes,
selectable = attributes.none { it.equals("\\Noselect", ignoreCase = true) },
hierarchyDelimiter = reportedSeparator?.takeUnless { it == Char.MIN_VALUE },
)
}
// Some servers don't return INBOX from a wildcard LIST; guarantee it's always present.
if (folders.none { it.fullName.equals("INBOX", ignoreCase = true) }) {
listOf(FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true)) + folders
} else {
folders
}
}
}
/** Fetches the most recent [limit] headers of [folder], newest first. */
suspend fun fetchRecent(params: ImapConnectionParams, folder: String, limit: Int): List<FetchedMessage> =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_ONLY)
try {
val total = mailbox.messageCount
if (total == 0) return@withStore emptyList()
val messages = mailbox.getMessages(maxOf(1, total - limit + 1), total)
mailbox.fetch(
messages,
FetchProfile().apply {
add(FetchProfile.Item.ENVELOPE)
add(FetchProfile.Item.FLAGS)
add(UIDFolder.FetchProfileItem.UID)
},
)
val uidFolder = mailbox as UIDFolder
messages.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Fetches up to [limit] headers of [folder] immediately older than [beforeUid] (i.e. with a
* server UID strictly less than it), newest-first — the backwards page used by the full-history
* backfill (issue #12). Pass [Long.MAX_VALUE] to start from the newest message.
*
* Bounded in both memory and network cost: the boundary message number is located with a
* binary search over message numbers by UID (UIDs increase monotonically with message number),
* costing O(log n) tiny `UID FETCH` round trips, and only the [limit]-sized batch is materialized.
* Returns empty when nothing older exists, which the caller treats as "folder fully backfilled".
*/
suspend fun fetchOlderThan(
params: ImapConnectionParams,
folder: String,
beforeUid: Long,
limit: Int,
): List<FetchedMessage> = withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_ONLY)
try {
val total = mailbox.messageCount
if (total == 0 || beforeUid <= 1L) return@withStore emptyList()
val uidFolder = mailbox as UIDFolder
// Highest message number whose UID < beforeUid (0 when nothing is older).
val boundary = highestMessageNumberBelowUid(mailbox, uidFolder, total, beforeUid)
if (boundary == 0) return@withStore emptyList()
val start = maxOf(1, boundary - limit + 1)
val messages = mailbox.getMessages(start, boundary)
mailbox.fetch(
messages,
FetchProfile().apply {
add(FetchProfile.Item.ENVELOPE)
add(FetchProfile.Item.FLAGS)
add(UIDFolder.FetchProfileItem.UID)
},
)
messages.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Binary-searches message numbers `1..total` for the highest one whose UID is `< beforeUid`.
* Robust to expunges (it reads live UIDs), so it works even if the message that had exactly
* [beforeUid] has since been removed. Returns 0 when every message's UID is `>= beforeUid`.
*/
private fun highestMessageNumberBelowUid(mailbox: Folder, uidFolder: UIDFolder, total: Int, beforeUid: Long): Int {
var lo = 1
var hi = total
var boundary = 0
while (lo <= hi) {
val mid = (lo + hi) ushr 1
val midUid = uidFolder.getUID(mailbox.getMessage(mid))
if (midUid < beforeUid) {
boundary = mid
lo = mid + 1
} else {
hi = mid - 1
}
}
return boundary
}
/** Runs an IMAP SEARCH over [folder] (subject/from/body) and returns matching headers. */
suspend fun search(params: ImapConnectionParams, folder: String, query: String, limit: Int): List<FetchedMessage> =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_ONLY)
try {
val term = OrTerm(arrayOf(SubjectTerm(query), FromStringTerm(query), BodyTerm(query)))
val matches = mailbox.search(term).toList()
if (matches.isEmpty()) return@withStore emptyList()
val recent = if (matches.size > limit) matches.takeLast(limit) else matches
mailbox.fetch(
recent.toTypedArray(),
FetchProfile().apply {
add(FetchProfile.Item.ENVELOPE)
add(FetchProfile.Item.FLAGS)
add(UIDFolder.FetchProfileItem.UID)
},
)
val uidFolder = mailbox as UIDFolder
recent.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { mailbox.close(false) }
}
}
}
/** Fetches a message body by UID from [folder] and marks it \Seen on the server. */
suspend fun fetchBodyMarkingSeen(params: ImapConnectionParams, folder: String, uid: String): MessageContent =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_WRITE)
try {
val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
val content = (extractBody(message) ?: MessageContent("", isHtml = false))
.copy(attachments = collectAttachments(message))
message.setFlag(Flags.Flag.SEEN, true)
content
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Fetches a message body + attachment metadata by UID **without** marking it \Seen (opens READ_ONLY
* and uses BODY.PEEK). Used to pre-cache content during sync so opening the message is instant.
*/
suspend fun fetchBodyPeek(params: ImapConnectionParams, folder: String, uid: String): MessageContent =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_ONLY)
try {
val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
(message as? IMAPMessage)?.setPeek(true)
(extractBody(message) ?: MessageContent("", isHtml = false))
.copy(attachments = collectAttachments(message))
} finally {
runCatching { mailbox.close(false) }
}
}
}
/** Downloads the bytes of one attachment part (identified by its [partIndex]) from [folder]. */
suspend fun fetchAttachment(
params: ImapConnectionParams,
folder: String,
uid: String,
partIndex: Int,
): DownloadedAttachment = withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_ONLY)
try {
val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
val parts = mutableListOf<Part>()
collectAttachmentParts(message, parts)
val part = parts.getOrNull(partIndex) ?: error("Attachment $partIndex not found")
val bytes = part.inputStream.use { it.readBytes() }
DownloadedAttachment(
filename = attachmentName(part) ?: "attachment",
mimeType = baseType(part),
bytes = bytes,
)
} finally {
runCatching { mailbox.close(false) }
}
}
}
suspend fun setFlag(params: ImapConnectionParams, folder: String, uid: String, flag: Flags.Flag, value: Boolean) =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_WRITE)
try {
(mailbox as UIDFolder).getMessageByUID(uid.toLong())?.setFlag(flag, value)
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Deletes a single message by UID. Delegates to [deleteMessages] so even a lone delete uses the
* same **targeted** UID EXPUNGE — never the untargeted expunge that would also remove other
* `\Deleted`-flagged mail in the folder (issue #295).
*/
suspend fun deleteMessage(params: ImapConnectionParams, folder: String, uid: String) =
deleteMessages(params, folder, listOf(uid))
/**
* Permanently deletes the messages with [uids] from [folder] in a **single** IMAP session: opens
* the folder once, flags the matched messages `\Deleted`, then issues one targeted UID EXPUNGE via
* [expungeTargeted]. This is the batch, data-safe path the repository's multi-message delete/expunge
* routes through — one login + one expunge for the whole selection, instead of the N logins + N
* expunges a per-UID [deleteMessage] loop would pay, and it never touches unrelated `\Deleted` mail
* (issue #295). A no-op when [uids] is empty or none of them resolve to a message in [folder].
*/
suspend fun deleteMessages(params: ImapConnectionParams, folder: String, uids: List<String>) =
withContext(Dispatchers.IO) {
if (uids.isEmpty()) return@withContext
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_WRITE)
try {
val uidFolder = mailbox as UIDFolder
val messages = uids.mapNotNull { uidFolder.getMessageByUID(it.toLong()) }.toTypedArray()
if (messages.isEmpty()) return@withStore
mailbox.setFlags(messages, Flags(Flags.Flag.DELETED), true)
expungeTargeted(mailbox, messages)
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Moves messages from [sourceFolder] to [destFolder] by UID. Implemented as copy + \Deleted +
* targeted expunge so it works on every IMAP server (no reliance on the RFC 6851 MOVE extension).
*/
suspend fun moveMessages(
params: ImapConnectionParams,
sourceFolder: String,
uids: List<String>,
destFolder: String,
) = withContext(Dispatchers.IO) {
if (uids.isEmpty()) return@withContext
withStore(params) { store ->
val mailbox = store.getFolder(sourceFolder)
mailbox.open(Folder.READ_WRITE)
try {
val uidFolder = mailbox as UIDFolder
val messages = uids.mapNotNull { uidFolder.getMessageByUID(it.toLong()) }.toTypedArray()
if (messages.isEmpty()) return@withStore
mailbox.copyMessages(messages, store.getFolder(destFolder))
mailbox.setFlags(messages, Flags(Flags.Flag.DELETED), true)
expungeTargeted(mailbox, messages)
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Permanently removes exactly [messages] — which the caller has already flagged `\Deleted` — with a
* **targeted** UID EXPUNGE (RFC 4315, [IMAPFolder.expunge]). Deliberately never the untargeted
* [Folder.expunge], which expunges *every* `\Deleted`-flagged message in [mailbox] — including ones
* a second client, Gmail, or a partial earlier move left flagged — the data-loss bug in issue #295.
* Every folder this client opens is an [IMAPFolder], so the cast holds in production and under
* GreenMail. The server must advertise UIDPLUS (Gmail, Outlook, and GreenMail do); on one that does
* not, Angus raises "UID EXPUNGE not supported" here rather than silently falling back to the
* unrelated-mail-destroying untargeted expunge — a loud failure is the correct, safe outcome.
*/
private fun expungeTargeted(mailbox: Folder, messages: Array<Message>) {
(mailbox as IMAPFolder).expunge(messages)
}
/**
* Fetches the fields needed to compose a reply/forward (recipients + body) without marking the
* original \Seen (opens READ_ONLY), so building a reply doesn't change the message's read state.
*/
suspend fun fetchForReply(params: ImapConnectionParams, folder: String, uid: String): ReplyContext =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val mailbox = store.getFolder(folder)
mailbox.open(Folder.READ_ONLY)
try {
val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong())
?: error("Message $uid not found")
// BODY.PEEK: reading the body to quote it must not set the \Seen flag.
(message as? IMAPMessage)?.setPeek(true)
val from = message.from?.firstOrNull() as? InternetAddress
val content = extractBody(message) ?: MessageContent("", isHtml = false)
ReplyContext(
fromEmail = from?.address.orEmpty(),
toRecipients = message.recipientEmails(Message.RecipientType.TO),
ccRecipients = message.recipientEmails(Message.RecipientType.CC),
subject = message.subject ?: "",
sentDateMillis = (message.sentDate ?: message.receivedDate)?.time
?: System.currentTimeMillis(),
body = content.body,
isHtml = content.isHtml,
)
} finally {
runCatching { mailbox.close(false) }
}
}
}
/**
* Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The
* server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them
* to the message-count listener (not by returning from idle()), so we forward each push to
* [onActivity] via a conflated channel. Runs until the coroutine is cancelled (which closes the
* 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) {
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)
// 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 {
(store.getFolder("INBOX") as IMAPFolder).also { it.open(Folder.READ_ONLY) }
} catch (e: Throwable) {
runCatching { store.close() }
throw e
}
AppLog.d(TAG, "IDLE connected")
val pushes = Channel<Unit>(Channel.CONFLATED)
inbox.addMessageCountListener(object : MessageCountAdapter() {
override fun messagesAdded(event: MessageCountEvent) {
AppLog.d(TAG, "IDLE push: ${event.messages.size} new message(s)")
pushes.trySend(Unit)
}
})
coroutineScope {
val syncer = launch {
for (signal in pushes) onActivity()
}
// Close the connection the moment this scope is cancelled (renewal timeout or
// service stop). Doing it here — at cancellation *start*, not job completion —
// unblocks the blocking idle() read below so the loop exits promptly; a
// completion handler would never run while idle() is still blocked.
val closer = launch {
try {
awaitCancellation()
} finally {
withContext(NonCancellable) { runCatching { store.close() } }
}
}
// Sync once on connect to catch anything that arrived before IDLE was established.
pushes.trySend(Unit)
try {
while (isActive) {
inbox.idle()
}
} catch (e: Exception) {
if (isActive) throw e // a real connection error: let the caller reconnect
} finally {
closer.cancel()
syncer.cancel()
pushes.close()
runCatching { inbox.close(false) }
runCatching { store.close() }
}
}
}
/** Recursively finds the best body part: HTML preferred, plain text otherwise. */
private fun extractBody(part: Part): MessageContent? = when {
part.isMimeType("text/html") -> MessageContent(part.content.toString(), isHtml = true)
part.isMimeType("text/plain") -> MessageContent(part.content.toString(), isHtml = false)
part.isMimeType("multipart/*") -> extractFromMultipart(part)
else -> null
}
/** The best sub-part of a multipart body: the first HTML part, else the first plain-text part. */
private fun extractFromMultipart(part: Part): MessageContent? {
val multipart = part.content as? Multipart ?: return null
var plain: MessageContent? = null
for (i in 0 until multipart.count) {
val child = multipart.getBodyPart(i)
if (Part.ATTACHMENT.equals(child.disposition, ignoreCase = true)) continue
val result = extractBody(child) ?: continue
if (result.isHtml) return result
if (plain == null) plain = result
}
return plain
}
/**
* Walks the MIME tree and returns downloadable-part metadata in a stable, depth-first order.
* Inline images (a `Content-ID` referenced from the HTML via `cid:`) are included so their
* bytes can be fetched by [partIndex] and resolved by the reader, but each carries its
* [AttachmentPart.contentId] so the display layer can filter them out of the attachment list.
*/
private fun collectAttachments(message: Part): List<AttachmentPart> {
val parts = mutableListOf<Part>()
collectAttachmentParts(message, parts)
return parts.mapIndexed { index, part ->
AttachmentPart(
partIndex = index,
filename = attachmentName(part) ?: "attachment",
mimeType = baseType(part),
sizeBytes = part.size.toLong().coerceAtLeast(0L),
contentId = if (isInlineImagePart(part)) inlineContentId(part) else null,
)
}
}
private fun collectAttachmentParts(part: Part, into: MutableList<Part>) {
when {
part.isMimeType("multipart/*") -> {
val multipart = part.content as? Multipart ?: return
for (i in 0 until multipart.count) collectAttachmentParts(multipart.getBodyPart(i), into)
}
isAttachmentPart(part) || isInlineImagePart(part) -> into.add(part)
}
}
private fun attachmentName(part: Part): String? = part.fileName?.let {
runCatching { MimeUtility.decodeText(it) }.getOrDefault(it)
}
private fun baseType(part: Part): String = runCatching {
ContentType(part.contentType).baseType
}.getOrDefault("application/octet-stream")
/** The email addresses of the given recipient type, skipping any that aren't internet addresses. */
private fun Message.recipientEmails(type: Message.RecipientType): List<String> = (
getRecipients(type)
?: emptyArray()
).mapNotNull { (it as? InternetAddress)?.address }
private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage {
val from = from?.firstOrNull() as? InternetAddress
return FetchedMessage(
uid = uidFolder.getUID(this).toString(),
sender = from?.personal ?: from?.address ?: "(unknown sender)",
senderEmail = from?.address.orEmpty(),
subject = subject ?: "(no subject)",
timestampMillis = (sentDate ?: receivedDate)?.time ?: System.currentTimeMillis(),
isRead = isSet(Flags.Flag.SEEN),
isFlagged = isSet(Flags.Flag.FLAGGED),
)
}
/**
* Runs [block] against a connected [Store]. With the reuse flag OFF (default) this is the original
* behaviour: a fresh, authenticated connection per call, torn down in `finally`. With it ON, the
* call borrows a kept-alive per-account connection from [connectionCache] (established once, reused
* across folder-opens) instead — see issue #125.
*/
private suspend fun <T> withStore(params: ImapConnectionParams, block: (Store) -> T): T {
val cache = connectionCache
return if (cache != null) {
cache.withStore(params, block)
} else {
val store = openConnectedStore(params)
try {
block(store)
} finally {
runCatching { store.close() }
}
}
}
/** Builds and authenticates a fresh [Store] (`CONNECT + TLS + LOGIN`); the caller owns closing it. */
private fun openConnectedStore(params: ImapConnectionParams): Store {
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)
return store
}
/**
* SPIKE hook (issue #125): closes any kept-alive reused connections (`LOGOUT` + teardown), a no-op
* when the reuse flag is OFF. The reuse harness calls this to force settlement; a shipped feature
* would also drive it from an idle-eviction timer and the low-battery push teardown (#88/#89/#90).
*/
suspend fun closeReusedConnections() {
connectionCache?.closeAll()
}
private fun buildProps(protocol: String, params: ImapConnectionParams, reuse: Boolean = false): Properties =
Properties().apply {
put("mail.store.protocol", protocol)
put("mail.$protocol.host", params.host)
put("mail.$protocol.port", params.port.toString())
put("mail.$protocol.connectiontimeout", TIMEOUT_MS)
put("mail.$protocol.timeout", TIMEOUT_MS)
put("mail.$protocol.writetimeout", TIMEOUT_MS)
if (params.security == MailSecurity.STARTTLS) {
put("mail.$protocol.starttls.enable", "true")
put("mail.$protocol.starttls.required", params.strictStartTls.toString())
}
// Verify the server certificate matches the host whenever TLS is used. Angus already
// defaults this to true; set it explicitly so a future library-default change can't
// silently disable hostname checking and expose us to MITM. (No-op for MailSecurity.NONE.)
put("mail.$protocol.ssl.checkserveridentity", "true")
if (params.useXoauth2) {
put("mail.$protocol.auth.mechanisms", "XOAUTH2")
}
if (reuse) {
// SPIKE (issue #125): pin a reused Store to exactly one authenticated socket. The
// per-account mutex already serializes access, so one pooled connection suffices — and
// capping it here makes "1 connection for N opens" the literal, provable invariant.
put("mail.$protocol.connectionpoolsize", "1")
put("mail.$protocol.separatestoreconnection", "false")
}
}
private companion object {
const val TIMEOUT_MS = "15000"
const val TAG = "LibreMailIdle"
}
}
/**
* 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
* `Content-ID` (an inline image carried by `Content-Disposition: inline`) is deliberately NOT an
* attachment — it belongs in the message body, not the attachment list (issue #133).
*/
internal fun isAttachmentPart(part: Part): Boolean = Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) ||
(!part.fileName.isNullOrBlank() && inlineContentId(part) == null)
/**
* True when [part] is an inline image embedded in the HTML body and referenced from it via
* `cid:<Content-ID>` (e.g. a USPS Informed Delivery digest's mail-piece thumbnails): it has a
* `Content-ID`, is an image, and is not already an [isAttachmentPart]. Such parts are excluded from
* the attachment list and instead served to the reader's WebView by their Content-ID.
*/
internal fun isInlineImagePart(part: Part): Boolean =
inlineContentId(part) != null && part.isMimeType("image/*") && !isAttachmentPart(part)
/**
* The normalized `Content-ID` of [part] (surrounding angle brackets stripped), or null if it has
* none. Reads it via [MimePart.getContentID] rather than `getHeader("Content-ID")`: over IMAP the
* Content-ID comes from the already-fetched BODYSTRUCTURE, whereas a raw header lookup would force
* (and often miss on) a separate per-part MIME-header fetch.
*/
internal fun inlineContentId(part: Part): String? = runCatching { (part as? MimePart)?.contentID }.getOrNull()
?.trim()?.trim('<', '>')?.trim()?.takeUnless { it.isBlank() }