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/sync/MailSyncer.kt:137 — high
The sync engine never reads or stores IMAP UIDVALIDITY, but message identity is keyed purely on UID (MessageEntity.id = "accountId:folder:uid", Mappers.kt:137); after a server UIDVALIDITY change (RFC 3501 requires discarding all cached data), a reused UID collides with a stale cached row and updateHeaderContents pairs the new message's headers with the old message's cached body and attachments.
Failure scenario: Provider rebuilds/migrates a mailbox (or a folder is deleted and recreated), bumping UIDVALIDITY and reassigning UIDs from 1. Next sync fetches new messages whose UIDs equal stale cached rows' UIDs: insertNew (IGNORE) keeps the old row, markSynced + updateHeaderContents refresh only display fields 'without touching cached bodies' and leave bodyFetched=true — the user opens the new message and reads a completely different email's body/attachments (cross-message content corruption). Additionally BackfillProgressEntity's persisted nextBeforeUid/complete flags refer to the dead UID space, so backfill either falsely stays 'complete' or pages a wrong region, and stale rows below the recent window survive as phantom messages. No file in the repo mentions UIDVALIDITY (grep: zero hits).
Verifier justification (CONFIRMED): The repo has zero references to UIDVALIDITY (case-insensitive grep across all sources: no hits), and message identity is purely "accountId:folder:uid" (Mappers.kt:137). In MailSyncer.syncFolderHeaders, insertNew is OnConflictStrategy.IGNORE (MessageDao.kt:143, doc: "leaving existing rows (and their cached bodies/flags) intact") and updateHeaderContents refreshes only display fields, explicitly "leaving cached bodies and optimistic read/star flags untouched" and never clearing bodyFetched. MailRepositoryImpl.kt:179-183 fetches the body only if !routing.bodyFetched, so after a server UIDVALIDITY bump (folder delete/recreate, provider mailbox rebuild — routine on Dovecot/Courier) a reused UID collides with a stale cached row and the reader serves the OLD message's cached body and attachments under the NEW message's headers, with no invalidation path. RFC 3501 requires clients to discard cached data when UIDVALIDITY changes; this client cannot detect the change at all. The secondary claim about BackfillProgressEntity persisting nextBeforeUid/complete against a dead UID space also holds. Concrete trigger and wrong outcome are both fully determined by the code once UIDVALIDITY changes, so this is CONFIRMED rather than PLAUSIBLE; only the trigger's frequency (rare on Gmail/Outlook, common on self-hosted IMAP) keeps it below critical.
Fix hint: Store the folder's UIDVALIDITY (e.g. a FolderSyncState row or column on FolderEntity) captured from the IMAP SELECT in ImapClient.fetchRecent; at the start of syncFolderHeaders compare it to the persisted value and, on mismatch, delete all cached messages/attachments for that (account, folder), reset BackfillProgressEntity, persist the new value, then proceed with a fresh sync (skip first-sync notifications).
app/src/main/kotlin/org/libremail/mail/ImapClient.kt:408 — high
Destructive UID-based operations (deleteMessages/moveMessages/setFlag) resolve cached Room UIDs against the server without ever checking UIDVALIDITY, which is required by RFC 3501 before reusing stored UIDs; no code in the repo reads or persists UIDVALIDITY at all.
Failure scenario: A server rebuilds a folder and bumps UIDVALIDITY (Dovecot maildir restore, provider migration, folder delete+recreate) so UID numbering restarts; LibreMail's Room cache still holds old UIDs. The user swipes to delete a cached message: uidFolder.getMessageByUID(oldUid) now resolves a completely different, newer message, which is flagged \Deleted and permanently expunged via UID EXPUNGE — mail the user never selected is destroyed. The same stale-UID resolution corrupts moves, flag changes, and marks the wrong message \Seen in fetchBodyMarkingSeen.
Verifier justification (CONFIRMED): Case-insensitive grep for UIDVALIDITY across the entire repo returns zero matches — the client never reads, persists, or compares the folder's UIDVALIDITY, even though JavaMail exposes UIDFolder.getUIDValidity(). deleteMessages (ImapClient.kt:408), moveMessages (:434), setFlag (:377), and the body/attachment fetch paths all resolve Room-cached UIDs via getMessageByUID and act on whatever comes back. Concrete trigger: the server rebuilds a folder and bumps UIDVALIDITY (Dovecot dovecot-uidlist loss/restore, folder delete+recreate, provider migration) so UID numbering restarts; the cached UID (e.g. 5) now belongs to a different, newer message. User deletes the stale cached row → the wrong message is flagged \Deleted and permanently removed via UID EXPUNGE (expungeTargeted). RFC 3501 §2.3.1.1 explicitly forbids reusing stored UIDs without checking UIDVALIDITY. The repo's own #295/#319 work shows wrong-message expunge is treated as a data-safety invariant, yet this path violates it. Rated high rather than critical only because the trigger is an uncommon server-side event, not everyday operation — but when it fires the outcome is unrecoverable loss of mail the user never selected.
Fix hint: Persist each folder's UIDVALIDITY (new column on the folder entity) captured at sync time; in ImapClient, after opening the folder for any UID-based operation, compare ((UIDFolder) mailbox).getUIDValidity() against the stored value and abort (surface a re-sync-required error, invalidating the folder's cached UIDs) on mismatch instead of resolving stale UIDs.
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/sync/MailSyncer.kt:137` — high
The sync engine never reads or stores IMAP UIDVALIDITY, but message identity is keyed purely on UID (MessageEntity.id = "accountId:folder:uid", Mappers.kt:137); after a server UIDVALIDITY change (RFC 3501 requires discarding all cached data), a reused UID collides with a stale cached row and updateHeaderContents pairs the new message's headers with the old message's cached body and attachments.
**Failure scenario:** Provider rebuilds/migrates a mailbox (or a folder is deleted and recreated), bumping UIDVALIDITY and reassigning UIDs from 1. Next sync fetches new messages whose UIDs equal stale cached rows' UIDs: insertNew (IGNORE) keeps the old row, markSynced + updateHeaderContents refresh only display fields 'without touching cached bodies' and leave bodyFetched=true — the user opens the new message and reads a completely different email's body/attachments (cross-message content corruption). Additionally BackfillProgressEntity's persisted nextBeforeUid/complete flags refer to the dead UID space, so backfill either falsely stays 'complete' or pages a wrong region, and stale rows below the recent window survive as phantom messages. No file in the repo mentions UIDVALIDITY (grep: zero hits).
**Verifier justification (CONFIRMED):** The repo has zero references to UIDVALIDITY (case-insensitive grep across all sources: no hits), and message identity is purely "accountId:folder:uid" (Mappers.kt:137). In MailSyncer.syncFolderHeaders, insertNew is OnConflictStrategy.IGNORE (MessageDao.kt:143, doc: "leaving existing rows (and their cached bodies/flags) intact") and updateHeaderContents refreshes only display fields, explicitly "leaving cached bodies and optimistic read/star flags untouched" and never clearing bodyFetched. MailRepositoryImpl.kt:179-183 fetches the body only if !routing.bodyFetched, so after a server UIDVALIDITY bump (folder delete/recreate, provider mailbox rebuild — routine on Dovecot/Courier) a reused UID collides with a stale cached row and the reader serves the OLD message's cached body and attachments under the NEW message's headers, with no invalidation path. RFC 3501 requires clients to discard cached data when UIDVALIDITY changes; this client cannot detect the change at all. The secondary claim about BackfillProgressEntity persisting nextBeforeUid/complete against a dead UID space also holds. Concrete trigger and wrong outcome are both fully determined by the code once UIDVALIDITY changes, so this is CONFIRMED rather than PLAUSIBLE; only the trigger's frequency (rare on Gmail/Outlook, common on self-hosted IMAP) keeps it below critical.
**Defective line:** `id = "$accountId:$folder:$uid", (Mappers.kt:137) ... @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNew(messages: List<MessageEntity>) ... messageDao.markSynced(ids); messageDao.updateHeaderContents(entities) (MailSyncer.kt:136-137) ... if (account != null && !routing.bodyFetched) { ... imapClient.fetchBodyMarkingSeen(...) } (MailRepositoryImpl.kt:181)`
**Fix hint:** Store the folder's UIDVALIDITY (e.g. a FolderSyncState row or column on FolderEntity) captured from the IMAP SELECT in ImapClient.fetchRecent; at the start of syncFolderHeaders compare it to the persisted value and, on mismatch, delete all cached messages/attachments for that (account, folder), reset BackfillProgressEntity, persist the new value, then proceed with a fresh sync (skip first-sync notifications).
## `app/src/main/kotlin/org/libremail/mail/ImapClient.kt:408` — high
Destructive UID-based operations (deleteMessages/moveMessages/setFlag) resolve cached Room UIDs against the server without ever checking UIDVALIDITY, which is required by RFC 3501 before reusing stored UIDs; no code in the repo reads or persists UIDVALIDITY at all.
**Failure scenario:** A server rebuilds a folder and bumps UIDVALIDITY (Dovecot maildir restore, provider migration, folder delete+recreate) so UID numbering restarts; LibreMail's Room cache still holds old UIDs. The user swipes to delete a cached message: uidFolder.getMessageByUID(oldUid) now resolves a completely different, newer message, which is flagged \Deleted and permanently expunged via UID EXPUNGE — mail the user never selected is destroyed. The same stale-UID resolution corrupts moves, flag changes, and marks the wrong message \Seen in fetchBodyMarkingSeen.
**Verifier justification (CONFIRMED):** Case-insensitive grep for UIDVALIDITY across the entire repo returns zero matches — the client never reads, persists, or compares the folder's UIDVALIDITY, even though JavaMail exposes UIDFolder.getUIDValidity(). deleteMessages (ImapClient.kt:408), moveMessages (:434), setFlag (:377), and the body/attachment fetch paths all resolve Room-cached UIDs via getMessageByUID and act on whatever comes back. Concrete trigger: the server rebuilds a folder and bumps UIDVALIDITY (Dovecot dovecot-uidlist loss/restore, folder delete+recreate, provider migration) so UID numbering restarts; the cached UID (e.g. 5) now belongs to a different, newer message. User deletes the stale cached row → the wrong message is flagged \Deleted and permanently removed via UID EXPUNGE (expungeTargeted). RFC 3501 §2.3.1.1 explicitly forbids reusing stored UIDs without checking UIDVALIDITY. The repo's own #295/#319 work shows wrong-message expunge is treated as a data-safety invariant, yet this path violates it. Rated high rather than critical only because the trigger is an uncommon server-side event, not everyday operation — but when it fires the outcome is unrecoverable loss of mail the user never selected.
**Defective line:** `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)`
**Fix hint:** Persist each folder's UIDVALIDITY (new column on the folder entity) captured at sync time; in ImapClient, after opening the folder for any UID-based operation, compare ((UIDFolder) mailbox).getUIDValidity() against the stored value and abort (surface a re-sync-required error, invalidating the folder's cached UIDs) on mismatch instead of resolving stale UIDs.
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/sync/MailSyncer.kt:137— highThe sync engine never reads or stores IMAP UIDVALIDITY, but message identity is keyed purely on UID (MessageEntity.id = "accountId:folder:uid", Mappers.kt:137); after a server UIDVALIDITY change (RFC 3501 requires discarding all cached data), a reused UID collides with a stale cached row and updateHeaderContents pairs the new message's headers with the old message's cached body and attachments.
Failure scenario: Provider rebuilds/migrates a mailbox (or a folder is deleted and recreated), bumping UIDVALIDITY and reassigning UIDs from 1. Next sync fetches new messages whose UIDs equal stale cached rows' UIDs: insertNew (IGNORE) keeps the old row, markSynced + updateHeaderContents refresh only display fields 'without touching cached bodies' and leave bodyFetched=true — the user opens the new message and reads a completely different email's body/attachments (cross-message content corruption). Additionally BackfillProgressEntity's persisted nextBeforeUid/complete flags refer to the dead UID space, so backfill either falsely stays 'complete' or pages a wrong region, and stale rows below the recent window survive as phantom messages. No file in the repo mentions UIDVALIDITY (grep: zero hits).
Verifier justification (CONFIRMED): The repo has zero references to UIDVALIDITY (case-insensitive grep across all sources: no hits), and message identity is purely "accountId:folder:uid" (Mappers.kt:137). In MailSyncer.syncFolderHeaders, insertNew is OnConflictStrategy.IGNORE (MessageDao.kt:143, doc: "leaving existing rows (and their cached bodies/flags) intact") and updateHeaderContents refreshes only display fields, explicitly "leaving cached bodies and optimistic read/star flags untouched" and never clearing bodyFetched. MailRepositoryImpl.kt:179-183 fetches the body only if !routing.bodyFetched, so after a server UIDVALIDITY bump (folder delete/recreate, provider mailbox rebuild — routine on Dovecot/Courier) a reused UID collides with a stale cached row and the reader serves the OLD message's cached body and attachments under the NEW message's headers, with no invalidation path. RFC 3501 requires clients to discard cached data when UIDVALIDITY changes; this client cannot detect the change at all. The secondary claim about BackfillProgressEntity persisting nextBeforeUid/complete against a dead UID space also holds. Concrete trigger and wrong outcome are both fully determined by the code once UIDVALIDITY changes, so this is CONFIRMED rather than PLAUSIBLE; only the trigger's frequency (rare on Gmail/Outlook, common on self-hosted IMAP) keeps it below critical.
Defective line:
id = "$accountId:$folder:$uid", (Mappers.kt:137) ... @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertNew(messages: List<MessageEntity>) ... messageDao.markSynced(ids); messageDao.updateHeaderContents(entities) (MailSyncer.kt:136-137) ... if (account != null && !routing.bodyFetched) { ... imapClient.fetchBodyMarkingSeen(...) } (MailRepositoryImpl.kt:181)Fix hint: Store the folder's UIDVALIDITY (e.g. a FolderSyncState row or column on FolderEntity) captured from the IMAP SELECT in ImapClient.fetchRecent; at the start of syncFolderHeaders compare it to the persisted value and, on mismatch, delete all cached messages/attachments for that (account, folder), reset BackfillProgressEntity, persist the new value, then proceed with a fresh sync (skip first-sync notifications).
app/src/main/kotlin/org/libremail/mail/ImapClient.kt:408— highDestructive UID-based operations (deleteMessages/moveMessages/setFlag) resolve cached Room UIDs against the server without ever checking UIDVALIDITY, which is required by RFC 3501 before reusing stored UIDs; no code in the repo reads or persists UIDVALIDITY at all.
Failure scenario: A server rebuilds a folder and bumps UIDVALIDITY (Dovecot maildir restore, provider migration, folder delete+recreate) so UID numbering restarts; LibreMail's Room cache still holds old UIDs. The user swipes to delete a cached message: uidFolder.getMessageByUID(oldUid) now resolves a completely different, newer message, which is flagged \Deleted and permanently expunged via UID EXPUNGE — mail the user never selected is destroyed. The same stale-UID resolution corrupts moves, flag changes, and marks the wrong message \Seen in fetchBodyMarkingSeen.
Verifier justification (CONFIRMED): Case-insensitive grep for UIDVALIDITY across the entire repo returns zero matches — the client never reads, persists, or compares the folder's UIDVALIDITY, even though JavaMail exposes UIDFolder.getUIDValidity(). deleteMessages (ImapClient.kt:408), moveMessages (:434), setFlag (:377), and the body/attachment fetch paths all resolve Room-cached UIDs via getMessageByUID and act on whatever comes back. Concrete trigger: the server rebuilds a folder and bumps UIDVALIDITY (Dovecot dovecot-uidlist loss/restore, folder delete+recreate, provider migration) so UID numbering restarts; the cached UID (e.g. 5) now belongs to a different, newer message. User deletes the stale cached row → the wrong message is flagged \Deleted and permanently removed via UID EXPUNGE (expungeTargeted). RFC 3501 §2.3.1.1 explicitly forbids reusing stored UIDs without checking UIDVALIDITY. The repo's own #295/#319 work shows wrong-message expunge is treated as a data-safety invariant, yet this path violates it. Rated high rather than critical only because the trigger is an uncommon server-side event, not everyday operation — but when it fires the outcome is unrecoverable loss of mail the user never selected.
Defective line:
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)Fix hint: Persist each folder's UIDVALIDITY (new column on the folder entity) captured at sync time; in ImapClient, after opening the folder for any UID-based operation, compare ((UIDFolder) mailbox).getUIDValidity() against the stored value and abort (surface a re-sync-required error, invalidating the folder's cached UIDs) on mismatch instead of resolving stale UIDs.