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/ui/compose/ComposeViewModel.kt:373 — high
onExit() during an in-flight send closes the screen (finish()) without saving a draft, which pops the ViewModel and cancels viewModelScope, killing sendMessage before the outbox insert commits — the composed message is silently lost (no outbox row, no draft, no error).
Failure scenario: User attaches a large file (e.g. 100 MB video), taps Send — performSend suspends in MailRepositoryImpl.sendMessage, which spends seconds in copyAttachments before outboxDao.insert. Impatient (or accidental) back-press fires BackHandler -> onExit: sending == true so saveOrDeleteDraft is skipped, finish() emits, the compose destination pops, the ViewModel is cleared and viewModelScope cancels. The insert never executes (runCatching converts the CancellationException to a failure Result whose onFailure updates a dead screen). The mail is in neither the outbox nor drafts and the user believes it was sent — silent mail loss. The 'sending' state machine has a terminal path that ends with neither an outbox row nor a draft.
Verifier justification (CONFIRMED): The claimed hole is real and unguarded. ComposeScreen.kt has an unconditional BackHandler { viewModel.onExit() } and finished.collect { onBack() }; onExit() (ComposeViewModel.kt:369-376) skips saveOrDeleteDraft when sending==true and calls finish(), popping the destination, clearing the ViewModel, and cancelling viewModelScope while trySend's coroutine is still suspended inside MailRepositoryImpl.sendMessage. sendMessage (MailRepositoryImpl.kt:506) does blocking copyAttachments (seconds for a large file) BEFORE outboxDao.insert and sendScheduler.sendNow(), all inside runCatching, which converts the CancellationException raised at the Room suspension points into a failure Result whose onFailure writes to a dead screen. Concrete trigger: compose a message whose last edit is within the 1.5s autosave debounce (so no draft row exists — autosaveDraft at line 392 also skips while sending), attach a large file, tap Send, press back while the send is in flight. Cancellation before the insert leaves no outbox row, no draft, no error — silent loss; cancellation during the insert dispatch commits the row but skips sendNow(), leaving the message stuck (SendWorker is only enqueued via sendNow/retryOutbox — the periodic SyncWorker never drains the outbox). Mitigant honestly noted: in longer compositions an earlier autosaved draft survives (deleted only on send success), so content is often recoverable in Drafts — but the user believes the mail was sent, so it is still silently-unsent mail.
Defective line:// Don't save a draft for a message that's mid-send (send() will finish the screen). if (!_state.value.sending) saveOrDeleteDraft() finish()
Fix hint: Make the outbox enqueue survive ViewModel death: wrap the enqueue path in performSend with withContext(NonCancellable) or launch it in an injected application-scoped CoroutineScope, and stop runCatching from swallowing CancellationException in MailRepositoryImpl.sendMessage. Alternatively (or additionally), have onExit() while sending==true either wait for the in-flight send or fall back to saveOrDeleteDraft() so the message is never dropped on the floor.
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/ui/compose/ComposeViewModel.kt:373` — high
onExit() during an in-flight send closes the screen (finish()) without saving a draft, which pops the ViewModel and cancels viewModelScope, killing sendMessage before the outbox insert commits — the composed message is silently lost (no outbox row, no draft, no error).
**Failure scenario:** User attaches a large file (e.g. 100 MB video), taps Send — performSend suspends in MailRepositoryImpl.sendMessage, which spends seconds in copyAttachments before outboxDao.insert. Impatient (or accidental) back-press fires BackHandler -> onExit: sending == true so saveOrDeleteDraft is skipped, finish() emits, the compose destination pops, the ViewModel is cleared and viewModelScope cancels. The insert never executes (runCatching converts the CancellationException to a failure Result whose onFailure updates a dead screen). The mail is in neither the outbox nor drafts and the user believes it was sent — silent mail loss. The 'sending' state machine has a terminal path that ends with neither an outbox row nor a draft.
**Verifier justification (CONFIRMED):** The claimed hole is real and unguarded. ComposeScreen.kt has an unconditional `BackHandler { viewModel.onExit() }` and `finished.collect { onBack() }`; onExit() (ComposeViewModel.kt:369-376) skips saveOrDeleteDraft when sending==true and calls finish(), popping the destination, clearing the ViewModel, and cancelling viewModelScope while trySend's coroutine is still suspended inside MailRepositoryImpl.sendMessage. sendMessage (MailRepositoryImpl.kt:506) does blocking copyAttachments (seconds for a large file) BEFORE outboxDao.insert and sendScheduler.sendNow(), all inside runCatching, which converts the CancellationException raised at the Room suspension points into a failure Result whose onFailure writes to a dead screen. Concrete trigger: compose a message whose last edit is within the 1.5s autosave debounce (so no draft row exists — autosaveDraft at line 392 also skips while sending), attach a large file, tap Send, press back while the send is in flight. Cancellation before the insert leaves no outbox row, no draft, no error — silent loss; cancellation during the insert dispatch commits the row but skips sendNow(), leaving the message stuck (SendWorker is only enqueued via sendNow/retryOutbox — the periodic SyncWorker never drains the outbox). Mitigant honestly noted: in longer compositions an earlier autosaved draft survives (deleted only on send success), so content is often recoverable in Drafts — but the user believes the mail was sent, so it is still silently-unsent mail.
**Defective line:** `// Don't save a draft for a message that's mid-send (send() will finish the screen).
if (!_state.value.sending) saveOrDeleteDraft()
finish()`
**Fix hint:** Make the outbox enqueue survive ViewModel death: wrap the enqueue path in performSend with withContext(NonCancellable) or launch it in an injected application-scoped CoroutineScope, and stop runCatching from swallowing CancellationException in MailRepositoryImpl.sendMessage. Alternatively (or additionally), have onExit() while sending==true either wait for the in-flight send or fall back to saveOrDeleteDraft() so the message is never dropped on the floor.
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/ui/compose/ComposeViewModel.kt:373— highonExit() during an in-flight send closes the screen (finish()) without saving a draft, which pops the ViewModel and cancels viewModelScope, killing sendMessage before the outbox insert commits — the composed message is silently lost (no outbox row, no draft, no error).
Failure scenario: User attaches a large file (e.g. 100 MB video), taps Send — performSend suspends in MailRepositoryImpl.sendMessage, which spends seconds in copyAttachments before outboxDao.insert. Impatient (or accidental) back-press fires BackHandler -> onExit: sending == true so saveOrDeleteDraft is skipped, finish() emits, the compose destination pops, the ViewModel is cleared and viewModelScope cancels. The insert never executes (runCatching converts the CancellationException to a failure Result whose onFailure updates a dead screen). The mail is in neither the outbox nor drafts and the user believes it was sent — silent mail loss. The 'sending' state machine has a terminal path that ends with neither an outbox row nor a draft.
Verifier justification (CONFIRMED): The claimed hole is real and unguarded. ComposeScreen.kt has an unconditional
BackHandler { viewModel.onExit() }andfinished.collect { onBack() }; onExit() (ComposeViewModel.kt:369-376) skips saveOrDeleteDraft when sending==true and calls finish(), popping the destination, clearing the ViewModel, and cancelling viewModelScope while trySend's coroutine is still suspended inside MailRepositoryImpl.sendMessage. sendMessage (MailRepositoryImpl.kt:506) does blocking copyAttachments (seconds for a large file) BEFORE outboxDao.insert and sendScheduler.sendNow(), all inside runCatching, which converts the CancellationException raised at the Room suspension points into a failure Result whose onFailure writes to a dead screen. Concrete trigger: compose a message whose last edit is within the 1.5s autosave debounce (so no draft row exists — autosaveDraft at line 392 also skips while sending), attach a large file, tap Send, press back while the send is in flight. Cancellation before the insert leaves no outbox row, no draft, no error — silent loss; cancellation during the insert dispatch commits the row but skips sendNow(), leaving the message stuck (SendWorker is only enqueued via sendNow/retryOutbox — the periodic SyncWorker never drains the outbox). Mitigant honestly noted: in longer compositions an earlier autosaved draft survives (deleted only on send success), so content is often recoverable in Drafts — but the user believes the mail was sent, so it is still silently-unsent mail.Defective line:
// Don't save a draft for a message that's mid-send (send() will finish the screen). if (!_state.value.sending) saveOrDeleteDraft() finish()Fix hint: Make the outbox enqueue survive ViewModel death: wrap the enqueue path in performSend with withContext(NonCancellable) or launch it in an injected application-scoped CoroutineScope, and stop runCatching from swallowing CancellationException in MailRepositoryImpl.sendMessage. Alternatively (or additionally), have onExit() while sending==true either wait for the in-flight send or fall back to saveOrDeleteDraft() so the message is never dropped on the floor.