resolveRoleFolder().pick() chose the destination for archive/reportSpam/
trash as the first selectable folder with the matching role, in server
LIST order. A provider's built-in folder (role via an RFC 6154 attribute,
e.g. [Gmail]/Spam via \Junk) and a same-named user folder (role via
roleFromDisplayName) can share a role, so the winner depended on which
one the server happened to LIST first — silently misrouting mail past
the provider's junk training, retention, and auto-purge.
Prefer the server-advertised special-use folder among same-role matches:
maxByOrNull { it.specialUse } picks a specialUse=true folder over
name-derived ones, and, because maxByOrNull returns the first max, keeps
the existing LIST-order behavior when no special-use folder exists.
Closes#58
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI runs ktlint/detekt and the E2E suites (not Android lintDebug), and the
KnownVuln rationale should not imply blanket TLS enforcement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prepare for F-Droid publication (issue #16):
- docs/fdroid-compliance.md: full dependency license audit (release
runtime classpath + buildscript classpath — all FOSS, no Play
Services/Firebase, no non-free Gradle plugins), an anti-feature
review of actual app behavior (none to declare: debug reporting is
opt-in/local-only with no endpoint by default, Android Backup is
gated off by default, Outlook OAuth is optional per-account with a
public client id), a complete network-surface inventory, and the
clean-room build verification (assembleRelease succeeds with no
secrets.properties).
- app/build.gradle.kts: stop embedding AGP's dependency-info block (a
Google-Play-encrypted dependency list in the APK signing block) in
APKs/bundles — a known F-Droid inclusion/reproducibility blocker.
- fastlane/metadata/android/en-US/: store listing (title, short/full
description, changelog for versionCode 1) that F-Droid reads from
the repo; listing .txt files deliberately carry no license headers.
- docs/fdroid/org.libremail.app.yml: commented template + instructions
for the eventual fdroiddata build recipe (submission out of scope).
- README.md: F-Droid section pointing at the above.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-review fixes: an all-empty paragraph group (a blank line isolated by an
alignment split) emitted <p></p>, which the parser collapses — it now emits one
<br> per line so blank lines round-trip. The identical span-merge helper that
existed in both the parser and RichTextEditing is now a single shared
mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop
their never-used default font resolver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Send now scans the subject and body for "attach" and its variants
(word-bounded, case-insensitive). When the text mentions one but the
message carries no attachment, an AlertDialog asks "Need to attach
anything?" — Yes returns to composing and pulses the attach button,
No sends the message as-is, and dismissing cancels the send. (#79)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RichStyle becomes a sealed interface (Bold/Italic/Underline/Strikethrough +
FontFamily/FontSize/FontColor/Highlight); RichTextContent gains alignments,
images, and baseStyle channels. The HTML serializer emits merged <span style>
runs, text-align on <p>/<li> (splitting merged paragraphs at alignment
boundaries), <img src="cid:…"> over the visible [image: name] token, and a
single outer <div style> for the base style. The parser is a faithful inverse
and additionally tolerates <del>/<strike>, px font sizes, #rgb colors, and
start/end alignment synonyms; unknown CSS is ignored without dropping text.
hasFormatting() covers every new channel so ComposeViewModel.normalizedHtml()
never silently drops serialized formatting. The editor carries parameterized
style identity via string annotations (libremail:style / libremail:image), maps
alignment onto ParagraphStyle ranges, holds baseStyle in separate field state,
and RichTextEditing.toggleStyle now replaces a different value of the same kind
while styleAt() answers "current value over the selection" for pickers.
ColorSwatchRow is added for the upcoming color/highlight dialogs. No UI change.
Closes#70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Cc and Bcc fields now start collapsed into small left-aligned link
buttons under the To box, freeing about two field heights of vertical
space for the message body. Tapping a link expands it into the regular
input field and focuses it; a field also expands on its own when it
already carries recipients (reply-all/mailto prefill, resumed drafts)
and never re-collapses once shown, so it cannot vanish mid-edit. The
expansion state lives in the UI via rememberSaveable and survives
rotation. Moving the Bcc field also gives it the medium shape every
sibling field already had.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
saveOrDeleteDraft persists the Bcc line, but the init-block restore
never copied it back, so reopening a draft silently dropped its Bcc
recipients (and re-saving then lost them for good).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tapping a new-mail notification only brought the app to the foreground:
the content PendingIntent was a bare launch intent shared by every
notification, and nothing on the activity side handled a message target.
Per-message notifications now carry an explicit open-message intent —
action + id extra + a per-message data URI, so each message keeps its
own PendingIntent under filterEquals instead of all collapsing onto one
FLAG_UPDATE_CURRENT entry. MainActivity parses the id on fresh launch
and in onNewIntent and hands it to the NavHost as pending state (the
pendingCompose handoff pattern) to navigate to the reader. The group
summary keeps the plain open-the-app intent.
Fixes#56
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's "Static analysis" job runs :app:ktlintCheck :app:detekt, which the
local preflight gate did not, so style violations in test/androidTest
source sets (which lintDebug skips) failed the merge gate only after
push. Add both to the /preflight skill and mirror the change in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Body expression on the signature line (function-signature) and one
argument per wrapped line (argument-list-wrapping) in the tests added
for drawer folder de-duplication.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The drawer rendered every standard-role folder with a generic friendly
name (e.g. "Drafts") and discarded the server name, so a Gmail account
with both a provider built-in folder and a same-named user folder showed
two identical entries (Drafts, Archive, Spam).
De-duplicate labels provider-agnostically: when 2+ folders would render
the same name, the provider's built-in special folder (identified by RFC
6154 SPECIAL-USE flags, now persisted on the folder cache) gets the
provider name appended ("Archive - Gmail"), a nested user folder gets its
parent location ("Reports (Work)"), and a top-level user folder keeps its
plain name. Only triggers on a real collision, so stock accounts are
unchanged.
Adds a `specialUse` column to the folders table (Room v11 -> v12).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MessageDao.observeAll() ran `SELECT * FROM messages` and returned full
MessageEntity rows — including the potentially large body/isHtml columns —
for every cached message at once. Dragging big HTML bodies through SQLite's
shared ~2 MB CursorWindow overflowed it once enough bodies were cached,
crashing with "Couldn't read row N from CursorWindow" (#51).
Replace it with observeSummaries(), a body-less column projection into a new
lightweight MessageSummary POJO. The list never renders or searches the body,
and the reader already loads it lazily per-message via getById when a message
is opened, so nothing else needs it.
Add a regression test that reads back rows whose bodies exceed the window.
Closes#51
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a guided, F-Droid-safe onboarding step and an Advanced Settings recovery
row that let users move LibreMail to "Unrestricted" battery usage, so IMAP
IDLE push (IdleService) and periodic WorkManager sync aren't throttled or
killed by Doze. Deep-links to the system app-details screen rather than the
restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog, so it needs no new
permission and is safe on Play (#17) and F-Droid (#16).
- BatteryPromptDecision: pure, unit-tested gate (supported && !unrestricted && !handled)
- BatteryOptimizationManager: reads isIgnoringBatteryOptimizations, builds the deep-link intent
- Onboarding step shown after the first account is added; skipped when already
unrestricted or already handled; re-checks status on resume
- Advanced Settings row shows current status and re-opens the system screen
- battery_prompt_handled flag persisted in the settings DataStore (kept out of AppSettings)
- Unit tests for the decision + view model; Espresso E2E for the step
Closes#49
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve LibreMailApp.kt: union the compose function params so mailto prefill
(pendingCompose/onComposeHandled) coexists with onboarding start-gating
(appViewModel) and the crash dialog (startupViewModel); keep LaunchedEffect +
getValue/remember imports. Verified locally: assembleDebug + testDebugUnitTest +
lintDebug + ktlintCheck + detekt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts from #40/#41/#44:
- build.gradle.kts: keep DEBUG_REPORT_ENDPOINT field + val, take #40's
outlookRedirectScheme (gmailRedirectScheme was deleted).
- LibreMailApp.kt: function takes BOTH appViewModel (start-dest gating, #44)
and startupViewModel (crash dialog, #42); use renamed AccountPickerScreen.
- SettingsScreen.kt: keep both the Diagnostics (#42) and Backup (#41) sections.
Verified locally: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Real cause of the API 29-36 E2E timeout (the earlier 5s->15s bump didn't help,
proving it wasn't slowness): AppPasswordSetupScreen is a scrolling Column and the
"Test and add" button sits below the fold on the short default matrix emulator, so
the positional performClick was a silent no-op -> no add -> no navigation -> the
add-another wait never resolved. It passed on API 37 only because that job uses a
taller pixel_2 AVD. performScrollTo() each field + the button before interacting,
matching the existing pattern in SettingsScreenTest. Verified compileDebugAndroid
TestKotlin + ktlintCheck on JDK 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OnboardingFlowTest passed on the API-37 job but timed out (ComposeTimeoutException
after 5000ms) across the animation-disabled API 29-36 matrix, at the single
async-gated transition: click -> viewModelScope coroutine -> addImapAccount ->
DONE -> LaunchedEffect -> navigate -> AddAnother render. The flow is correct
(green on API 37; ManualSetupScreenTest proves the add-callback path); the 5s cap
was just too tight for that compound step on slower matrix emulators. waitUntil
returns as soon as the text appears, so the happy path is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #32/#33 change added a required onReportProblem parameter to SettingsScreen
but left the existing instrumented SettingsScreenTest calling the old signature,
so :app:compileDebugAndroidTestKotlin failed in every E2E job (the local fast
gate never compiles the androidTest variant). Pass onReportProblem = {} in the
test. Verified with :app:compileDebugAndroidTestKotlin on JDK 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring README in line with the post-batch shipped state (issue #20, folding in
#31's README reconciliation):
- Rewrite the status blurb and feature list to cover the onboarding flow, rich
compose (HTML + multipart/alternative + signatures), full-history backfill
with a device-only retention cap, opt-in app lock, mailto/default-app, and
opt-in local debug reporting.
- Remove the Gmail OAuth setup section and the "no stored passwords for Gmail"
claim; Gmail/Yahoo/iCloud are now app-password IMAP/SMTP vendors.
- Add an "Accounts and onboarding" section (Outlook OAuth; Gmail/Yahoo/iCloud
app password with vendor app-password pages + Gmail 2SV note; Other IMAP/SMTP)
and keep the Outlook OAuth setup section.
- Add a "Privacy and data flow" note: opt-in cache encryption, local
user-initiated debug reporting (no hosted pipeline), and opt-in Android Backup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring rich composition to LibreMail (issues #36, #37, #38, and #23).
#36 HTML editor + toolbar
- New pure, JVM-testable rich-text model (`richtext/`): RichTextContent with
inline styles + links and block markers ("• ", "N. ", "> "), serializing to a
narrow email-safe HTML subset and back (fromHtml is a faithful inverse).
- Rich editor in ComposeScreen with a bold/italic/underline, bulleted/numbered
list, block-quote, and link toolbar, backed by AnnotatedString. Unformatted
text stays plaintext-only (null HTML) so it feels unchanged and is accessible.
- #23: rounded corners on the compose fields/body via MaterialTheme.shapes.
#37 multipart/alternative + reply/forward quoting
- SmtpSender builds multipart/alternative (text/plain + text/html), nested in
multipart/mixed when there are attachments; GraphSender sends HTML content.
- HtmlToText produces a readable text/plain fallback; ReplyBuilder quotes HTML
originals as clean blockquotes (tags stripped) without corruption.
- HTML body persists/restores through drafts and the outbox (new nullable
bodyHtml columns; Room v9->v10 migration + schema).
#38 signatures
- New signatures table (multiple per account, one default) + repository/DAO;
migration backfills the existing per-account signature as the default.
- Rich signatures reuse the #36 editor; a Signatures management screen (list,
add/edit/delete, set default) is linked from per-account settings.
- The account's default signature auto-inserts on new compose / reply / forward
(honoring the enable toggle), placed above the quote, and stays editable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the onboarding epic (#26–#31) as a single feature:
- #26 First-run nav scaffold: gate the start destination on the account
count (no accounts -> onboarding, else mailbox) via AppViewModel, holding
render until the count is known so there is no cold-start flash. Onboarding
is a nested nav graph with a graph-scoped OnboardingViewModel tracking the
first account added this session. Removes NoAccountState in favour of a
shared welcome/empty state.
- #28 Provider registry: MailProvider presets for Gmail/Yahoo/iCloud
(IMAP+SMTP host/port/security, help URL) mirroring Account.outlook, biased
to STARTTLS where documented; host/port/security unit-tested.
- #27 Vendor picker: AccountPickerScreen replaces the two-button setup screen
as the single entry point (onboarding + Settings/mailbox "Add account"),
routing Outlook -> OAuth, Gmail/Yahoo/iCloud -> app-password, Other -> manual.
- #29 App-password guided screen: one reusable screen per provider key with
explanation + security warning + help link; verifies/persists via
addImapAccount. ViewModel unit tests cover valid/invalid/failure paths.
- #30 "Add another?" prompt + first-account landing: after each onboarding
add, offer Yes (back to picker) / No (open the first account's inbox,
per-account filtered via a MAILBOX account nav arg). Only inside onboarding.
- #31 Instrumented onboarding E2E (welcome -> picker -> app-password add ->
add-another -> first inbox); managed-device list and CI matrix already in
lockstep. All new files carry the SPDX header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add intent filters so LibreMail handles mailto: (ACTION_VIEW / SENDTO)
and email SEND / SEND_MULTIPLE intents, opening a prefilled compose screen.
- MailtoParser: pure RFC 6068 parser (multiple recipients, to/cc/bcc/
subject/body, percent-encoding; preserves a literal '+'); JVM-tested.
- IntentComposeParser: builds a ComposePrefill from a mailto: URI or the
EXTRA_EMAIL/CC/BCC/SUBJECT/TEXT share extras.
- MainActivity parses the launch/new intent and hands a one-shot prefill to
the NavHost (guarded against config-change duplication).
- Compose form gains a Bcc field; Routes carry cc/bcc/body deep-link args.
- bcc wired end-to-end: OutgoingMessage, SMTP + Graph senders, and outbox +
drafts persistence via Room migration v9 -> v10.
- Multi-account send is served by the existing From picker on compose.
Default mail app: Android exposes no public RoleManager email role, so the
intent filters are what make LibreMail appear on the system "Open by default"
/ default-apps screen where the platform/OEM supports it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements #32 and #33: a strictly opt-in, F-Droid-safe debug reporting
client. Nothing ever leaves the device unless the user taps Submit.
#32 capture:
- CrashReporter installs a Thread.setDefaultUncaughtExceptionHandler (wired in
LibreMailApplication) that persists a structured crash record (stack trace +
app/version/device metadata + recent log ring buffer) locally, then delegates
to the previous handler. Never auto-sent.
- RingLogBuffer + AppLog: a bounded in-memory, non-PII log ring buffer.
- DiagnosticsCollector assembles a minimal bundle: app version, Android/device,
a fixed non-PII settings allow-list, and the log buffer.
- ReportStore persists pending reports as JSON files (not Room, so crash-time
saves are robust and independent of the encrypted/migrating DB).
- "Report a problem" entry point in Settings creates a report on demand.
#33 review & submit:
- ReportReviewScreen shows the full payload verbatim (exactly what would be
sent), a free-text comment field, and a prominent PII disclaimer, with
explicit Submit / Discard and Copy / Save-to-file alternatives.
- Submission is user-initiated only: ReportUploadWorker (WorkManager, queue +
retry, success/failure surfaced) POSTs to BuildConfig.DEBUG_REPORT_ENDPOINT,
which is EMPTY by default (ingest server #34 is out of scope for this repo).
- On next launch a saved crash report is offered for review via a dialog.
Tests: 23 JVM unit tests covering crash capture + persistence (offered next
launch), diagnostic-bundle assembly (minimal, non-PII), JSON round-trip, and
the "nothing sent without Submit" invariant.
Closes#32Closes#33
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in (off by default) toggle to include app data in system
Android Backup / Auto Backup, gated so only re-creatable user
preferences are ever backed up.
- Flip allowBackup to true and add LibreMailBackupAgent, which enforces
the runtime opt-in: onFullBackup runs only when the user enables
"Include settings in Android Backup" (default off), so no data leaves
the device otherwise. allowBackup is a manifest flag and can't be
toggled at runtime, hence the agent.
- Rewrite data_extraction_rules.xml (API 31+) and add backup_rules.xml
(API 29-30) as strict allowlists that back up ONLY the
libremail_settings DataStore. The Keystore-sealed cache passphrase
(libremail_dbkey) and the encrypted credentials + mail-cache database
(libremail.db) are excluded by omission; the cache re-downloads on
next sync.
- Add includeInBackup preference + setter (nudges BackupManager on
change) and a "Backup" settings section with F-Droid-honest copy
(off by default, uses Google infrastructure).
- BackupPolicy is the single source of truth for eligible/excluded
paths; unit tests cover the toggle default and assert the shipped XML
resources include only settings and never the secrets/DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gmail authenticates via app password + preconfigured IMAP/SMTP (decision
in #9), never OAuth, so the Gmail-OAuth implementation was unreachable
dead code. Remove it while keeping Outlook's AppAuth OAuth path intact.
- Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept).
- Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the
gmailAuthManager injection, and the SCOPE_GMAIL constant in
MailConnectionFactory.
- Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the
gmailRedirectScheme val from app/build.gradle.kts.
- Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to
the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest
now registers that scheme on RedirectUriReceiverActivity, so the app
manifest's now-redundant Outlook intent-filter is removed; the
AppCompat theme override (crash fix) is preserved. Verified the merged
manifest.
- Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example.
authType persists as the enum name in a TEXT column, so removing a
constant needs no Room schema change or migration.
Closes#39
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>