Tapping a new-mail notification now opens that email's reader — warm (app alive, onNewIntent) or cold (fresh launch). Back from the reader lands in the mailbox, same as opening from the list.
Why it was broken
MailNotifier.contentIntent() built a bare launch intent with no message id, and every notification (messages + group summary) shared that single PendingIntent.
MainActivity only parsed compose (mailto:/share) intents; there was no "open message" path into the NavHost.
How
New NotificationIntents owns the deep-link contract: ACTION_OPEN_MESSAGE, the id extra, and a per-message data URI. The URI is load-bearing — PendingIntent identity ignores extras, so without it all messages would collapse onto one FLAG_UPDATE_CURRENTPendingIntent and always open the newest message. Explicit intent → no manifest change, no new exported surface.
MailNotifier: per-message notifications use the deep-link intent; the group summary keeps the plain open-the-app intent.
MainActivity → LibreMailApp: the parsed id flows as pendingOpenMessageId state into a LaunchedEffect that navigates to Routes.reader(id) — the exact pendingCompose handoff pattern. Fresh-launch-only parsing in onCreate (config-change recreation restores the reader via the NavHost), plus onNewIntent for warm taps.
Safe by construction: MailSyncer persists messages before notifying, so the row exists at tap time; a message deleted in the meantime hits the reader's existing error state.
Testing
New instrumented NotificationIntentsTest: id round-trip (incl. URI-hostile ids), null for launcher/mailto: intents, and filterEquals distinctness across message ids (the PendingIntent-uniqueness property).
Local fast gate green: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, plus compileDebugAndroidTestKotlin; E2E matrix runs here in CI.
Fixes #56
## What
Tapping a new-mail notification now opens that email's reader — warm (app alive, `onNewIntent`) or cold (fresh launch). Back from the reader lands in the mailbox, same as opening from the list.
## Why it was broken
- `MailNotifier.contentIntent()` built a bare launch intent with no message id, and every notification (messages + group summary) shared that single `PendingIntent`.
- `MainActivity` only parsed compose (`mailto:`/share) intents; there was no "open message" path into the NavHost.
## How
- **New `NotificationIntents`** owns the deep-link contract: `ACTION_OPEN_MESSAGE`, the id extra, and a per-message `data` URI. The URI is load-bearing — `PendingIntent` identity ignores extras, so without it all messages would collapse onto one `FLAG_UPDATE_CURRENT` `PendingIntent` and always open the newest message. Explicit intent → no manifest change, no new exported surface.
- **`MailNotifier`**: per-message notifications use the deep-link intent; the group summary keeps the plain open-the-app intent.
- **`MainActivity` → `LibreMailApp`**: the parsed id flows as `pendingOpenMessageId` state into a `LaunchedEffect` that navigates to `Routes.reader(id)` — the exact `pendingCompose` handoff pattern. Fresh-launch-only parsing in `onCreate` (config-change recreation restores the reader via the NavHost), plus `onNewIntent` for warm taps.
- Safe by construction: `MailSyncer` persists messages before notifying, so the row exists at tap time; a message deleted in the meantime hits the reader's existing error state.
## Testing
- New instrumented `NotificationIntentsTest`: id round-trip (incl. URI-hostile ids), `null` for launcher/`mailto:` intents, and `filterEquals` distinctness across message ids (the PendingIntent-uniqueness property).
- Local fast gate green: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, plus `compileDebugAndroidTestKotlin`; E2E matrix runs here in CI.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
Fixes #56
What
Tapping a new-mail notification now opens that email's reader — warm (app alive,
onNewIntent) or cold (fresh launch). Back from the reader lands in the mailbox, same as opening from the list.Why it was broken
MailNotifier.contentIntent()built a bare launch intent with no message id, and every notification (messages + group summary) shared that singlePendingIntent.MainActivityonly parsed compose (mailto:/share) intents; there was no "open message" path into the NavHost.How
NotificationIntentsowns the deep-link contract:ACTION_OPEN_MESSAGE, the id extra, and a per-messagedataURI. The URI is load-bearing —PendingIntentidentity ignores extras, so without it all messages would collapse onto oneFLAG_UPDATE_CURRENTPendingIntentand always open the newest message. Explicit intent → no manifest change, no new exported surface.MailNotifier: per-message notifications use the deep-link intent; the group summary keeps the plain open-the-app intent.MainActivity→LibreMailApp: the parsed id flows aspendingOpenMessageIdstate into aLaunchedEffectthat navigates toRoutes.reader(id)— the exactpendingComposehandoff pattern. Fresh-launch-only parsing inonCreate(config-change recreation restores the reader via the NavHost), plusonNewIntentfor warm taps.MailSyncerpersists messages before notifying, so the row exists at tap time; a message deleted in the meantime hits the reader's existing error state.Testing
NotificationIntentsTest: id round-trip (incl. URI-hostile ids),nullfor launcher/mailto:intents, andfilterEqualsdistinctness across message ids (the PendingIntent-uniqueness property).assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt, pluscompileDebugAndroidTestKotlin; E2E matrix runs here in CI.🤖 Generated with Claude Code