feat(mailto): handle mailto: links and email share intents #43

Merged
JMR-dev merged 2 commits from feat-mailto-default-app into main 2026-07-01 15:41:13 +00:00
JMR-dev commented 2026-07-01 05:08:45 +00:00 (Migrated from github.com)

Closes #25.

What

Makes LibreMail handle mailto: links and email share intents, opening a prefilled compose screen, and lets it appear in the system default-apps list where the platform supports it.

Intent handling

  • AndroidManifest.xml: intent filters on MainActivity for ACTION_VIEW/ACTION_SENDTO with the mailto scheme (with BROWSABLE, which is also what surfaces LibreMail on the "Open by default" screen), and ACTION_SEND/ACTION_SEND_MULTIPLE for text/plain + message/rfc822.
  • MainActivity parses the launch intent (and onNewIntent) into a one-shot ComposePrefill, guarded by savedInstanceState == null so a rotation doesn't reopen a duplicate compose.
  • LibreMailApp navigates to compose from the prefill via a LaunchedEffect.

Parsing

  • MailtoParser — pure Kotlin RFC 6068 parser (no android.net.Uri, so it's fully JVM-unit-tested): multiple recipients, to/cc/bcc/subject/body, case-insensitive scheme/fields, RFC 3986 percent-decoding. A literal + is preserved (mailto encodes spaces as %20), so user+tag@example.com survives.
  • IntentComposeParser — turns a mailto: URI or EXTRA_EMAIL/EXTRA_CC/EXTRA_BCC/EXTRA_SUBJECT/EXTRA_TEXT extras into a ComposePrefill.

Compose / routing

  • Added a Bcc field to the compose form; Routes now carries cc/bcc/body deep-link args feeding ComposeViewModel via SavedStateHandle.
  • Multi-account: the existing From picker on the compose screen already lets the user choose which account sends.

bcc end-to-end

Bcc was a new concept, so it's threaded fully rather than left as a dead field: OutgoingMessage, SmtpSender (envelope BCC, header stripped), GraphSender (bccRecipients), and outbox + drafts persistence via Room migration v9 → v10 (bccAddresses column, schema 10.json committed).

Default mail app

Android has no public RoleManager email role (unlike SMS/Browser), so becoming the system default is OEM-dependent — hence "where supported." The intent filters make LibreMail appear in the system default-apps / "Open by default" screen where the platform exposes an email association.

Tests

  • MailtoParserTest (JVM): single/multiple recipients, subject, body, cc, bcc, to merge, percent-encoding, literal +, case-insensitivity, malformed escapes, empty mailto.
  • Extended SmtpSenderTest (GreenMail: bcc delivered, no Bcc header leaked), GraphSenderTest (bccRecipients payload), and ComposeViewModelTest (prefill from nav args + bcc on send).
  • Fast CI gate green locally on JDK 21: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.

Notes / left to CI

  • The actual intent-filter/deep-link launch and the Room migration test are instrumented — left to CI/manual (no emulator here).
  • Known edge: an unusually large shared body is passed through the nav-route arg (same mechanism as the existing to/subject prefill); a multi-hundred-KB text share could risk TransactionTooLargeException on state save. Left uncapped to avoid silently truncating legitimate content.

🤖 Generated with Claude Code

Closes #25. ## What Makes LibreMail handle `mailto:` links and email share intents, opening a **prefilled compose** screen, and lets it appear in the system default-apps list where the platform supports it. ### Intent handling - `AndroidManifest.xml`: intent filters on `MainActivity` for `ACTION_VIEW`/`ACTION_SENDTO` with the `mailto` scheme (with `BROWSABLE`, which is also what surfaces LibreMail on the "Open by default" screen), and `ACTION_SEND`/`ACTION_SEND_MULTIPLE` for `text/plain` + `message/rfc822`. - `MainActivity` parses the launch intent (and `onNewIntent`) into a one-shot `ComposePrefill`, guarded by `savedInstanceState == null` so a rotation doesn't reopen a duplicate compose. - `LibreMailApp` navigates to compose from the prefill via a `LaunchedEffect`. ### Parsing - **`MailtoParser`** — pure Kotlin RFC 6068 parser (no `android.net.Uri`, so it's fully JVM-unit-tested): multiple recipients, `to`/`cc`/`bcc`/`subject`/`body`, case-insensitive scheme/fields, RFC 3986 percent-decoding. A literal `+` is **preserved** (mailto encodes spaces as `%20`), so `user+tag@example.com` survives. - **`IntentComposeParser`** — turns a `mailto:` URI or `EXTRA_EMAIL`/`EXTRA_CC`/`EXTRA_BCC`/`EXTRA_SUBJECT`/`EXTRA_TEXT` extras into a `ComposePrefill`. ### Compose / routing - Added a **Bcc** field to the compose form; `Routes` now carries `cc`/`bcc`/`body` deep-link args feeding `ComposeViewModel` via `SavedStateHandle`. - **Multi-account**: the existing From picker on the compose screen already lets the user choose which account sends. ### bcc end-to-end Bcc was a new concept, so it's threaded fully rather than left as a dead field: `OutgoingMessage`, `SmtpSender` (envelope BCC, header stripped), `GraphSender` (`bccRecipients`), and outbox + drafts persistence via **Room migration v9 → v10** (`bccAddresses` column, schema `10.json` committed). ### Default mail app Android has **no public RoleManager email role** (unlike SMS/Browser), so becoming the system default is OEM-dependent — hence "where supported." The intent filters make LibreMail appear in the system default-apps / "Open by default" screen where the platform exposes an email association. ## Tests - `MailtoParserTest` (JVM): single/multiple recipients, subject, body, cc, bcc, `to` merge, percent-encoding, literal `+`, case-insensitivity, malformed escapes, empty mailto. - Extended `SmtpSenderTest` (GreenMail: bcc delivered, no `Bcc` header leaked), `GraphSenderTest` (`bccRecipients` payload), and `ComposeViewModelTest` (prefill from nav args + bcc on send). - Fast CI gate green locally on JDK 21: `assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt`. ## Notes / left to CI - The actual intent-filter/deep-link **launch** and the Room migration test are instrumented — left to CI/manual (no emulator here). - Known edge: an unusually large shared `body` is passed through the nav-route arg (same mechanism as the existing `to`/`subject` prefill); a multi-hundred-KB text share could risk `TransactionTooLargeException` on state save. Left uncapped to avoid silently truncating legitimate content. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.