Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).
Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:539 — high
copyAttachments silently swallows attachment-staging failures (and a null openInputStream) and can leave a truncated staged file, so a queued message is sent missing an attachment or with a corrupt one while sendMessage reports success.
Failure scenario: User attaches a cloud-provider file and taps Send. Between pick and enqueue the provider revokes the grant / goes offline (openInputStream throws SecurityException or returns null for a dead authority), or the copy hits disk-full mid-copyTo leaving a partial file in cacheDir/outbox///. runCatching discards the failure with no AppLog, the outbox row is inserted claiming the attachment in its JSON metadata, and SendWorker's stagedAttachments then sends the message with the attachment silently dropped or truncated — the sender believes it went out intact. Also violates the project's logging DoD (error path with no AppLog).
Verifier justification (CONFIRMED): MailRepositoryImpl.kt lines 539-543 wrap the attachment copy in a bare runCatching whose result is discarded with no AppLog; openInputStream's null return is also silently skipped via '?.'. sendMessage then inserts the outbox row unconditionally with attachment metadata JSON (line 523) and returns success. SendWorker.stagedAttachments (line 204) skips a missing staged file by design ('A missing file (a copy that failed) is skipped'), so the message is sent without the attachment; and in the disk-full/interrupted-copy case the partially written file exists in the index directory, so SendWorker attaches and sends the truncated file — nothing validates staged size or deletes the partial on failure. Concrete triggers: revoked content-URI grant (SecurityException), provider uninstalled between pick and send (null stream), disk-full mid-copyTo. No upstream guard or test covers this; the silent error path also violates the repo's logging Definition of Done.
Fix hint: In copyAttachments, make staging failures fatal to sendMessage: check openInputStream for null and rethrow (or return Result.failure) on any copy exception, deleting the partial file/dir first, and log via AppLog with accountLogRef. Alternatively verify each staged file exists (and delete partials on exception) before inserting the outbox row, so sendMessage reports failure to the composer instead of silently enqueueing a message missing or truncating attachments.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:509 — high
sendMessage runs copyAttachments' blocking ContentResolver stream copy on the caller's dispatcher, which is the main thread via ComposeViewModel.performSend (viewModelScope.launch), unlike sibling methods that wrap in withContext(Dispatchers.IO).
Failure scenario: User attaches a multi-MB photo/document picked from a cloud-backed DocumentsProvider (Drive/Photos) and taps Send: openInputStream + copyTo streams the bytes over the network on the main thread; the UI freezes for the whole copy and exceeds the ANR threshold, crashing the app mid-send (the outbox row is never inserted, so the message is silently lost).
Verifier justification (CONFIRMED): sendMessage (MailRepositoryImpl.kt:506) is a bare runCatching with no withContext(Dispatchers.IO), unlike sibling methods openMessage/inlineImages/downloadedAttachmentParts (lines 162/243/317) which all wrap in Dispatchers.IO. Line 509 calls copyAttachments, a non-suspend function doing contentResolver.openInputStream + input.copyTo (lines 540-541) synchronously on the caller's thread. The sole caller is ComposeViewModel.trySend -> performSend inside viewModelScope.launch with no dispatcher (ComposeViewModel.kt:455/477), i.e. Dispatchers.Main.immediate — so the blocking stream copy runs on the main thread. A multi-MB attachment from a cloud-backed DocumentsProvider streams over the network via the provider Binder pipe, blocking main past the ANR threshold; the outbox insert (line 510) has not yet executed, so an ANR kill silently loses the message. No guard elsewhere prevents this.
Defective line:override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching { requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } val outboxId = UUID.randomUUID().toString() copyAttachments(outboxId, outgoing.attachments)
Fix hint: In MailRepositoryImpl.sendMessage, wrap the body in withContext(Dispatchers.IO) { ... } to match openMessage/inlineImages (or make copyAttachments a suspend fun that itself hops to Dispatchers.IO). Add an AppLog breadcrumb around the attachment staging per DoD.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:534 — high
sendMessage stages attachments via copyAttachments — a blocking ContentResolver.openInputStream + copyTo loop — on the caller's dispatcher, which is the main thread (ComposeViewModel.trySend launches in viewModelScope), unlike openMessage/downloadAttachment/inlineImages which wrap their I/O in withContext(Dispatchers.IO).
Failure scenario: User attaches a large or cloud-backed file (e.g. a not-locally-cached Google Drive/DocumentsProvider URI, where openInputStream streams the content over the network) and taps Send: performSend -> sendMessage -> copyAttachments copies the full file byte stream on Dispatchers.Main.immediate, freezing the UI (the 'sending' spinner can't even animate) for seconds and triggering an ANR on slow storage or remote providers. Fix: wrap the sendMessage body (or at least copyAttachments) in withContext(Dispatchers.IO), matching the pattern already used by openMessage.
Verifier justification (CONFIRMED): MailRepositoryImpl.sendMessage (line 506) is override suspend fun sendMessage(...) = runCatching { ... copyAttachments(outboxId, outgoing.attachments) ... } with no withContext(Dispatchers.IO), while sibling methods openMessage (162), inlineImages (243), and downloadedAttachmentParts (317) all wrap in Dispatchers.IO. copyAttachments (534-545) is a plain blocking function: context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } }, so it runs on the caller's dispatcher. The sole caller is ComposeViewModel.performSend, reached via viewModelScope.launch { ... } (ComposeViewModel.kt:455) with no dispatcher argument — Dispatchers.Main.immediate. Room suspend DAO calls resume on Main, so the whole attachment byte-copy executes on the main thread. Trigger: attach a large or remote DocumentsProvider-backed file (openInputStream streams over the network) and tap Send — the UI freezes for the copy duration and ANRs past ~5s. No guard elsewhere refutes this; no injected dispatcher exists in either class.
Fix hint: In MailRepositoryImpl.sendMessage, wrap the body in withContext(Dispatchers.IO) { runCatching { ... } } (matching openMessage's pattern), or at minimum make copyAttachments a suspend fun that switches to Dispatchers.IO before opening/copying streams.
Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**).
**Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.**
## `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:539` — high
copyAttachments silently swallows attachment-staging failures (and a null openInputStream) and can leave a truncated staged file, so a queued message is sent missing an attachment or with a corrupt one while sendMessage reports success.
**Failure scenario:** User attaches a cloud-provider file and taps Send. Between pick and enqueue the provider revokes the grant / goes offline (openInputStream throws SecurityException or returns null for a dead authority), or the copy hits disk-full mid-copyTo leaving a partial file in cacheDir/outbox/<id>/<index>/. runCatching discards the failure with no AppLog, the outbox row is inserted claiming the attachment in its JSON metadata, and SendWorker's stagedAttachments then sends the message with the attachment silently dropped or truncated — the sender believes it went out intact. Also violates the project's logging DoD (error path with no AppLog).
**Verifier justification (CONFIRMED):** MailRepositoryImpl.kt lines 539-543 wrap the attachment copy in a bare runCatching whose result is discarded with no AppLog; openInputStream's null return is also silently skipped via '?.'. sendMessage then inserts the outbox row unconditionally with attachment metadata JSON (line 523) and returns success. SendWorker.stagedAttachments (line 204) skips a missing staged file by design ('A missing file (a copy that failed) is skipped'), so the message is sent without the attachment; and in the disk-full/interrupted-copy case the partially written file exists in the index directory, so SendWorker attaches and sends the truncated file — nothing validates staged size or deletes the partial on failure. Concrete triggers: revoked content-URI grant (SecurityException), provider uninstalled between pick and send (null stream), disk-full mid-copyTo. No upstream guard or test covers this; the silent error path also violates the repo's logging Definition of Done.
**Defective line:** `runCatching {
context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input ->
File(dir, safeName).outputStream().use { output -> input.copyTo(output) }
}
}`
**Fix hint:** In copyAttachments, make staging failures fatal to sendMessage: check openInputStream for null and rethrow (or return Result.failure) on any copy exception, deleting the partial file/dir first, and log via AppLog with accountLogRef. Alternatively verify each staged file exists (and delete partials on exception) before inserting the outbox row, so sendMessage reports failure to the composer instead of silently enqueueing a message missing or truncating attachments.
## `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:509` — high
sendMessage runs copyAttachments' blocking ContentResolver stream copy on the caller's dispatcher, which is the main thread via ComposeViewModel.performSend (viewModelScope.launch), unlike sibling methods that wrap in withContext(Dispatchers.IO).
**Failure scenario:** User attaches a multi-MB photo/document picked from a cloud-backed DocumentsProvider (Drive/Photos) and taps Send: openInputStream + copyTo streams the bytes over the network on the main thread; the UI freezes for the whole copy and exceeds the ANR threshold, crashing the app mid-send (the outbox row is never inserted, so the message is silently lost).
**Verifier justification (CONFIRMED):** sendMessage (MailRepositoryImpl.kt:506) is a bare runCatching with no withContext(Dispatchers.IO), unlike sibling methods openMessage/inlineImages/downloadedAttachmentParts (lines 162/243/317) which all wrap in Dispatchers.IO. Line 509 calls copyAttachments, a non-suspend function doing contentResolver.openInputStream + input.copyTo (lines 540-541) synchronously on the caller's thread. The sole caller is ComposeViewModel.trySend -> performSend inside viewModelScope.launch with no dispatcher (ComposeViewModel.kt:455/477), i.e. Dispatchers.Main.immediate — so the blocking stream copy runs on the main thread. A multi-MB attachment from a cloud-backed DocumentsProvider streams over the network via the provider Binder pipe, blocking main past the ANR threshold; the outbox insert (line 510) has not yet executed, so an ANR kill silently loses the message. No guard elsewhere prevents this.
**Defective line:** `override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching {
requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" }
val outboxId = UUID.randomUUID().toString()
copyAttachments(outboxId, outgoing.attachments)`
**Fix hint:** In MailRepositoryImpl.sendMessage, wrap the body in withContext(Dispatchers.IO) { ... } to match openMessage/inlineImages (or make copyAttachments a suspend fun that itself hops to Dispatchers.IO). Add an AppLog breadcrumb around the attachment staging per DoD.
## `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:534` — high
sendMessage stages attachments via copyAttachments — a blocking ContentResolver.openInputStream + copyTo loop — on the caller's dispatcher, which is the main thread (ComposeViewModel.trySend launches in viewModelScope), unlike openMessage/downloadAttachment/inlineImages which wrap their I/O in withContext(Dispatchers.IO).
**Failure scenario:** User attaches a large or cloud-backed file (e.g. a not-locally-cached Google Drive/DocumentsProvider URI, where openInputStream streams the content over the network) and taps Send: performSend -> sendMessage -> copyAttachments copies the full file byte stream on Dispatchers.Main.immediate, freezing the UI (the 'sending' spinner can't even animate) for seconds and triggering an ANR on slow storage or remote providers. Fix: wrap the sendMessage body (or at least copyAttachments) in withContext(Dispatchers.IO), matching the pattern already used by openMessage.
**Verifier justification (CONFIRMED):** MailRepositoryImpl.sendMessage (line 506) is `override suspend fun sendMessage(...) = runCatching { ... copyAttachments(outboxId, outgoing.attachments) ... }` with no withContext(Dispatchers.IO), while sibling methods openMessage (162), inlineImages (243), and downloadedAttachmentParts (317) all wrap in Dispatchers.IO. copyAttachments (534-545) is a plain blocking function: `context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } }`, so it runs on the caller's dispatcher. The sole caller is ComposeViewModel.performSend, reached via `viewModelScope.launch { ... }` (ComposeViewModel.kt:455) with no dispatcher argument — Dispatchers.Main.immediate. Room suspend DAO calls resume on Main, so the whole attachment byte-copy executes on the main thread. Trigger: attach a large or remote DocumentsProvider-backed file (openInputStream streams over the network) and tap Send — the UI freezes for the copy duration and ANRs past ~5s. No guard elsewhere refutes this; no injected dispatcher exists in either class.
**Defective line:** `private fun copyAttachments(outboxId: String, attachments: List<OutgoingAttachment>) { ... context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } } — called from: override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching { ... copyAttachments(outboxId, outgoing.attachments) — invoked from ComposeViewModel: viewModelScope.launch { ... performSend(account, s) }`
**Fix hint:** In MailRepositoryImpl.sendMessage, wrap the body in withContext(Dispatchers.IO) { runCatching { ... } } (matching openMessage's pattern), or at minimum make copyAttachments a suspend fun that switches to Dispatchers.IO before opening/copying streams.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).
Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:539— highcopyAttachments silently swallows attachment-staging failures (and a null openInputStream) and can leave a truncated staged file, so a queued message is sent missing an attachment or with a corrupt one while sendMessage reports success.
Failure scenario: User attaches a cloud-provider file and taps Send. Between pick and enqueue the provider revokes the grant / goes offline (openInputStream throws SecurityException or returns null for a dead authority), or the copy hits disk-full mid-copyTo leaving a partial file in cacheDir/outbox///. runCatching discards the failure with no AppLog, the outbox row is inserted claiming the attachment in its JSON metadata, and SendWorker's stagedAttachments then sends the message with the attachment silently dropped or truncated — the sender believes it went out intact. Also violates the project's logging DoD (error path with no AppLog).
Verifier justification (CONFIRMED): MailRepositoryImpl.kt lines 539-543 wrap the attachment copy in a bare runCatching whose result is discarded with no AppLog; openInputStream's null return is also silently skipped via '?.'. sendMessage then inserts the outbox row unconditionally with attachment metadata JSON (line 523) and returns success. SendWorker.stagedAttachments (line 204) skips a missing staged file by design ('A missing file (a copy that failed) is skipped'), so the message is sent without the attachment; and in the disk-full/interrupted-copy case the partially written file exists in the index directory, so SendWorker attaches and sends the truncated file — nothing validates staged size or deletes the partial on failure. Concrete triggers: revoked content-URI grant (SecurityException), provider uninstalled between pick and send (null stream), disk-full mid-copyTo. No upstream guard or test covers this; the silent error path also violates the repo's logging Definition of Done.
Defective line:
runCatching { context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } } }Fix hint: In copyAttachments, make staging failures fatal to sendMessage: check openInputStream for null and rethrow (or return Result.failure) on any copy exception, deleting the partial file/dir first, and log via AppLog with accountLogRef. Alternatively verify each staged file exists (and delete partials on exception) before inserting the outbox row, so sendMessage reports failure to the composer instead of silently enqueueing a message missing or truncating attachments.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:509— highsendMessage runs copyAttachments' blocking ContentResolver stream copy on the caller's dispatcher, which is the main thread via ComposeViewModel.performSend (viewModelScope.launch), unlike sibling methods that wrap in withContext(Dispatchers.IO).
Failure scenario: User attaches a multi-MB photo/document picked from a cloud-backed DocumentsProvider (Drive/Photos) and taps Send: openInputStream + copyTo streams the bytes over the network on the main thread; the UI freezes for the whole copy and exceeds the ANR threshold, crashing the app mid-send (the outbox row is never inserted, so the message is silently lost).
Verifier justification (CONFIRMED): sendMessage (MailRepositoryImpl.kt:506) is a bare runCatching with no withContext(Dispatchers.IO), unlike sibling methods openMessage/inlineImages/downloadedAttachmentParts (lines 162/243/317) which all wrap in Dispatchers.IO. Line 509 calls copyAttachments, a non-suspend function doing contentResolver.openInputStream + input.copyTo (lines 540-541) synchronously on the caller's thread. The sole caller is ComposeViewModel.trySend -> performSend inside viewModelScope.launch with no dispatcher (ComposeViewModel.kt:455/477), i.e. Dispatchers.Main.immediate — so the blocking stream copy runs on the main thread. A multi-MB attachment from a cloud-backed DocumentsProvider streams over the network via the provider Binder pipe, blocking main past the ANR threshold; the outbox insert (line 510) has not yet executed, so an ANR kill silently loses the message. No guard elsewhere prevents this.
Defective line:
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching { requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } val outboxId = UUID.randomUUID().toString() copyAttachments(outboxId, outgoing.attachments)Fix hint: In MailRepositoryImpl.sendMessage, wrap the body in withContext(Dispatchers.IO) { ... } to match openMessage/inlineImages (or make copyAttachments a suspend fun that itself hops to Dispatchers.IO). Add an AppLog breadcrumb around the attachment staging per DoD.
app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:534— highsendMessage stages attachments via copyAttachments — a blocking ContentResolver.openInputStream + copyTo loop — on the caller's dispatcher, which is the main thread (ComposeViewModel.trySend launches in viewModelScope), unlike openMessage/downloadAttachment/inlineImages which wrap their I/O in withContext(Dispatchers.IO).
Failure scenario: User attaches a large or cloud-backed file (e.g. a not-locally-cached Google Drive/DocumentsProvider URI, where openInputStream streams the content over the network) and taps Send: performSend -> sendMessage -> copyAttachments copies the full file byte stream on Dispatchers.Main.immediate, freezing the UI (the 'sending' spinner can't even animate) for seconds and triggering an ANR on slow storage or remote providers. Fix: wrap the sendMessage body (or at least copyAttachments) in withContext(Dispatchers.IO), matching the pattern already used by openMessage.
Verifier justification (CONFIRMED): MailRepositoryImpl.sendMessage (line 506) is
override suspend fun sendMessage(...) = runCatching { ... copyAttachments(outboxId, outgoing.attachments) ... }with no withContext(Dispatchers.IO), while sibling methods openMessage (162), inlineImages (243), and downloadedAttachmentParts (317) all wrap in Dispatchers.IO. copyAttachments (534-545) is a plain blocking function:context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } }, so it runs on the caller's dispatcher. The sole caller is ComposeViewModel.performSend, reached viaviewModelScope.launch { ... }(ComposeViewModel.kt:455) with no dispatcher argument — Dispatchers.Main.immediate. Room suspend DAO calls resume on Main, so the whole attachment byte-copy executes on the main thread. Trigger: attach a large or remote DocumentsProvider-backed file (openInputStream streams over the network) and tap Send — the UI freezes for the copy duration and ANRs past ~5s. No guard elsewhere refutes this; no injected dispatcher exists in either class.Defective line:
private fun copyAttachments(outboxId: String, attachments: List<OutgoingAttachment>) { ... context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> File(dir, safeName).outputStream().use { output -> input.copyTo(output) } } — called from: override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching { ... copyAttachments(outboxId, outgoing.attachments) — invoked from ComposeViewModel: viewModelScope.launch { ... performSend(account, s) }Fix hint: In MailRepositoryImpl.sendMessage, wrap the body in withContext(Dispatchers.IO) { runCatching { ... } } (matching openMessage's pattern), or at minimum make copyAttachments a suspend fun that switches to Dispatchers.IO before opening/copying streams.