Tapping a new-mail notification brings LibreMail to the foreground wherever it last was — it never navigates to the email the notification announced.
Root cause
Two-sided:
The notification carries no target.MailNotifier.contentIntent() builds a bare Intent(context, MainActivity::class.java) with no message id. Worse, every per-message notification and the group summary share that one PendingIntent (request code 0, filterEquals-identical intents).
Nothing handles a target anyway.MainActivity only parses compose intents (IntentComposeParser); there is no "open message" path into the NavHost.
Fix plan
Deep-link the per-message notification into the existing reader route (Routes.reader(messageId)), mirroring the established pendingCompose intent-handoff pattern (MainActivity → LibreMailApp → LaunchedEffect → navController.navigate).
New notifications/NotificationIntents.kt — owns the open-message intent contract: ACTION_OPEN_MESSAGE + EXTRA_MESSAGE_ID + a per-message data URI (libremail://message/{id}). The data URI is load-bearing: PendingIntent identity ignores extras, so without distinct URIs all messages would collapse onto one FLAG_UPDATE_CURRENTPendingIntent and every notification would open the most-recently-notified message. No manifest change (explicit intent — no new exported surface).
MailNotifier.kt — per-message notifications get the deep-link content intent; the group summary keeps the plain open-the-app intent.
MainActivity.kt — parse the message id in onCreate (fresh launch only) and onNewIntent (warm tap), hold as pendingOpenMessageId state beside pendingCompose.
LibreMailApp.kt — LaunchedEffect navigates to Routes.reader(id) and consumes the pending id. Reader pushes onto the current stack, so back lands in the mailbox.
New NotificationIntentsTest.kt (androidTest) — round-trip parse, null for launcher/mailto intents, and filterEquals uniqueness across message ids.
This is safe because MailSyncer persists new messages before notifying (same NonCancellable block), so the tapped message row always exists locally; a message since deleted server-side hits the reader's existing error state.
Acceptance criteria
Warm tap (app backgrounded): reader opens the exact message announced; back returns to the mailbox.
Cold tap (app swiped from recents): app cold-starts straight into the reader for that message.
With 2+ notifications showing, tapping an older one opens that message, not the newest.
Launcher open, mailto: links, and share-to-LibreMail still open compose as before.
CI passed gate green (unit tests, lint, ktlint, detekt, E2E matrix).
Out of scope (follow-up ideas)
Group-summary tap deep-linking to the account-filtered mailbox (Routes.mailboxForAccount).
Auto-clearing per-message notifications when mail is read on another device.
## Bug
Tapping a new-mail notification brings LibreMail to the foreground wherever it last was — it never navigates to the email the notification announced.
## Root cause
Two-sided:
1. **The notification carries no target.** `MailNotifier.contentIntent()` builds a bare `Intent(context, MainActivity::class.java)` with no message id. Worse, every per-message notification and the group summary share that one `PendingIntent` (request code 0, `filterEquals`-identical intents).
2. **Nothing handles a target anyway.** `MainActivity` only parses compose intents (`IntentComposeParser`); there is no "open message" path into the NavHost.
## Fix plan
Deep-link the per-message notification into the existing reader route (`Routes.reader(messageId)`), mirroring the established `pendingCompose` intent-handoff pattern (`MainActivity` → `LibreMailApp` → `LaunchedEffect` → `navController.navigate`).
- **New `notifications/NotificationIntents.kt`** — owns the open-message intent contract: `ACTION_OPEN_MESSAGE` + `EXTRA_MESSAGE_ID` + a per-message `data` URI (`libremail://message/{id}`). The data URI is load-bearing: `PendingIntent` identity ignores extras, so without distinct URIs all messages would collapse onto one `FLAG_UPDATE_CURRENT` `PendingIntent` and every notification would open the most-recently-notified message. No manifest change (explicit intent — no new exported surface).
- **`MailNotifier.kt`** — per-message notifications get the deep-link content intent; the group summary keeps the plain open-the-app intent.
- **`MainActivity.kt`** — parse the message id in `onCreate` (fresh launch only) and `onNewIntent` (warm tap), hold as `pendingOpenMessageId` state beside `pendingCompose`.
- **`LibreMailApp.kt`** — `LaunchedEffect` navigates to `Routes.reader(id)` and consumes the pending id. Reader pushes onto the current stack, so back lands in the mailbox.
- **New `NotificationIntentsTest.kt` (androidTest)** — round-trip parse, null for launcher/mailto intents, and `filterEquals` uniqueness across message ids.
This is safe because `MailSyncer` persists new messages before notifying (same `NonCancellable` block), so the tapped message row always exists locally; a message since deleted server-side hits the reader's existing error state.
## Acceptance criteria
- Warm tap (app backgrounded): reader opens the exact message announced; back returns to the mailbox.
- Cold tap (app swiped from recents): app cold-starts straight into the reader for that message.
- With 2+ notifications showing, tapping an **older** one opens **that** message, not the newest.
- Launcher open, `mailto:` links, and share-to-LibreMail still open compose as before.
- `CI passed` gate green (unit tests, lint, ktlint, detekt, E2E matrix).
## Out of scope (follow-up ideas)
- Group-summary tap deep-linking to the account-filtered mailbox (`Routes.mailboxForAccount`).
- Auto-clearing per-message notifications when mail is read on another device.
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.
Bug
Tapping a new-mail notification brings LibreMail to the foreground wherever it last was — it never navigates to the email the notification announced.
Root cause
Two-sided:
MailNotifier.contentIntent()builds a bareIntent(context, MainActivity::class.java)with no message id. Worse, every per-message notification and the group summary share that onePendingIntent(request code 0,filterEquals-identical intents).MainActivityonly parses compose intents (IntentComposeParser); there is no "open message" path into the NavHost.Fix plan
Deep-link the per-message notification into the existing reader route (
Routes.reader(messageId)), mirroring the establishedpendingComposeintent-handoff pattern (MainActivity→LibreMailApp→LaunchedEffect→navController.navigate).notifications/NotificationIntents.kt— owns the open-message intent contract:ACTION_OPEN_MESSAGE+EXTRA_MESSAGE_ID+ a per-messagedataURI (libremail://message/{id}). The data URI is load-bearing:PendingIntentidentity ignores extras, so without distinct URIs all messages would collapse onto oneFLAG_UPDATE_CURRENTPendingIntentand every notification would open the most-recently-notified message. No manifest change (explicit intent — no new exported surface).MailNotifier.kt— per-message notifications get the deep-link content intent; the group summary keeps the plain open-the-app intent.MainActivity.kt— parse the message id inonCreate(fresh launch only) andonNewIntent(warm tap), hold aspendingOpenMessageIdstate besidependingCompose.LibreMailApp.kt—LaunchedEffectnavigates toRoutes.reader(id)and consumes the pending id. Reader pushes onto the current stack, so back lands in the mailbox.NotificationIntentsTest.kt(androidTest) — round-trip parse, null for launcher/mailto intents, andfilterEqualsuniqueness across message ids.This is safe because
MailSyncerpersists new messages before notifying (sameNonCancellableblock), so the tapped message row always exists locally; a message since deleted server-side hits the reader's existing error state.Acceptance criteria
mailto:links, and share-to-LibreMail still open compose as before.CI passedgate green (unit tests, lint, ktlint, detekt, E2E matrix).Out of scope (follow-up ideas)
Routes.mailboxForAccount).