SmtpSender.inlinePart built the MIME header as `<${attachment.contentId}>`
and GraphSender put contentId straight into the Graph JSON — neither
stripped CR/LF or other ISO control characters. Not exploitable today
(contentId is always an app-generated `img-<uuid>@libremail`), but if an
external value ever reached contentId the SMTP path would be a MIME
header-injection vector.
New shared sanitizeContentId() strips ISO control chars (incl. CR/LF),
applied at both sinks: the SMTP Content-ID header and the Graph JSON
field. No behavior change for the app-generated ids in use today.
Tests: SmtpSenderTest sends an inline image whose contentId contains
`\r\nX-Injected: evil` and asserts (via GreenMail) no injected header
appears on any MIME part and the Content-ID stays a single line;
GraphSenderTest asserts the control chars are stripped from the payload.
Closes#204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RichTextHtml.inlineCss and baseCss interpolated the font-family CSS value into
the emitted style attribute raw. The whole attribute is escaped (escapeAttr), so
a value can't break out of style="…" or inject a tag, and it isn't reachable
today (the picker only offers the fixed FontRegistry stacks; reply/forward
flattens sender HTML to plaintext first) — but a non-registry value would let
`;`/`:` inject a sibling CSS declaration inside the attribute.
Constrain the emitted font-family to a safe charset (the characters a real font
stack uses — letters, digits, spaces, commas, quotes, hyphens, periods,
underscores), dropping anything else instead of emitting it raw. All seven
bundled FontRegistry stacks are within this set, so the built-in fonts are
unaffected. RichTextHtml stays a pure module (no dependency on the UI-layer
FontRegistry), so the charset restriction is the layer-clean form of the
whitelist.
Test: a RichStyle.FontFamily("Arial; color:red") no longer appears raw in the
emitted HTML (inline and base-style), while a registry stack with quotes/commas
still emits.
Closes#205
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The compose attachment picker and inline-image picker call
takePersistableUriPermission on every pick, but nothing ever released
those grants (releasePersistableUriPermission was absent repo-wide). The
app accumulated indefinite read access to every file/photo ever attached
and could hit the per-app persisted-grant cap — after which the take
(swallowed by runCatching) silently fails and a later draft's image
won't reload. (Post-batch security review, Low.)
The picked bytes are copied into the app cache at enqueue
(copyAttachments), so a grant is only truly needed to reload an image
when a *draft* is reopened. New AttachmentUriGrants releases a URI's
grant once no remaining draft or outbox row references it; callers invoke
it after the referencing row is gone:
- MailRepositoryImpl.deleteDraft (a deleted draft can't reopen)
- MailRepositoryImpl.cancelOutboxMessage
- SendWorker after a send succeeds, and when a queued message is dropped
because its account was removed
A URI still referenced by another live draft/outbox row is kept; a
release of a grant not actually held throws and is swallowed.
Also hardens attachment filenames: sanitizeAttachmentName strips path
separators and ISO control chars (incl. CR/LF) from picked/received
display names before they become on-disk or MIME filenames, so a crafted
name can't traverse directories or inject header lines. Applied in the
compose picker (queryFileName) and outbox/incoming staging.
Tests: pure release-decision (unreferencedUris), the filename sanitizer,
and the repository wiring (deleteDraft/cancelOutboxMessage call the
releaser with the removed row's URIs, after deleting the row).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire inline images through the whole send pipeline.
Compose UI: an image-picker (image/*, persistable URI grant, mirroring the
attachment picker) behind a new toolbar button appended at the END of the
toolbar — after the block/link buttons and the font/size/align controls — so it
never shifts the bullet button the compose E2E taps without scrolling. Picking
an image adds an inline OutgoingAttachment and hands the editor a
PendingInlineImage, which RichTextEditing.insertImage drops as a [image: name]
token + RichImage(contentId) at the caret. Deleting the token drops the image:
onBodyChange reconciles inline attachments against the body's surviving cid:
references. Inline images are tracked in ComposeUiState alongside regular
attachments but kept out of the attachment-chip row.
Domain/persistence: OutgoingAttachment gains contentId/isInline; the shared
draft/outbox attachment JSON carries them (drafts need no migration — an older
draft reads back as a plain attachment). The outbox stages files by index as
before but now also stores per-file {contentId,isInline} metadata in a new
OutboxEntity.attachments column (Room 17 -> 18, MIGRATION_17_18, DEFAULT '' per
the bccAddresses precedent so fresh-install == migrated; 18.json committed). The
send worker pairs each staged file with its metadata by index (with a positional
fallback for messages queued before the column existed).
SMTP (SmtpSender): inline images wrap the body in a multipart/related, each with
a Content-ID matching the HTML's cid: and inline disposition; regular
attachments keep today's multipart/mixed shape.
Graph (GraphSender): inline fileAttachments carry isInline + contentId.
Tests: GreenMail asserts multipart/related with a Content-ID matching the cid;
GraphSenderTest asserts the inline payload; a mapper test proves an inline
image's cid<->file pairing round-trips a draft save/reopen; RichTextEditing
tests cover insertImage (token/RichImage placement + offset shift + html
round-trip); MigrationTest gains migrate17To18. Reader-side cid: rendering stays
out of scope (follow-up).
Closes#77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundle four SIL OFL 1.1 fonts in res/font (no build-time downloads, F-Droid
safe): Inter, Lora, and JetBrains Mono as weight-variable TTFs plus a static
Merriweather Regular cut (its variable font is 4.4 MB) — ~1.5 MB total. Each
family's OFL license text is committed under THIRD_PARTY_LICENSES/, and *.ttf/
*.otf are marked binary in .gitattributes so the bytes commit intact.
Add FontRegistry: display name <-> email-safe CSS stack <-> Compose FontFamily,
with three generic Sans/Serif/Monospace entries that need no bundled file. Each
bundled stack names the family first then falls back to a generic (e.g.
'Lora', Georgia, serif), so a recipient whose client lacks the face still gets
a sensible one. resolveFontFamily maps a stored CSS stack back to a FontFamily
for in-editor rendering, and is now passed into RichTextBodyField from
ComposeScreen so styled runs actually render in their font.
Add FontPicker (ui/compose/format): a toolbar dropdown applying
RichStyle.FontFamily(css) via the generalized applyStyle/clearStyle path, with a
leading "Default" entry that clears it; each menu entry previews itself in its
own face. Appended at the END of the toolbar (with the size/alignment controls),
after the block and link buttons — per the #73/#76 lesson, nothing may shift the
bullet/numbered/quote buttons that the compose E2E taps without scrolling.
Tests: FontRegistryTest (JVM) proves every CSS stack survives an html
round-trip, the resolver maps known/unknown stacks, and bundled stacks end in a
generic fallback; a compile-only FontPickerTest drives the picker in isolation
(default label, current-font label, menu lists every font, picking reports the
css / Default clears).
Closes#72
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a debounced periodic draft save alongside the existing exit-time
save, so an in-progress compose survives a background-kill without going
through the back gesture. Observes the persisted body/recipient/subject/
attachment fields, coalescing rapid keystrokes into one write after a
~1.5s idle window (viewModelScope), and flushes immediately on ON_STOP
from ComposeScreen so the last keystrokes within the window aren't lost.
Prerequisite bug fix: draftId was a nullable val, so
saveOrDeleteDraft()'s `id = draftId ?: UUID.randomUUID()` minted a fresh
id on every call. Harmless when it ran only once at exit, but periodic
autosave would insert a new duplicate draft row per tick. The persist id
is now generated once (persistedDraftId) and reused for every save this
session; a draftPersisted flag drives delete-on-empty and delete-on-send
so an autosaved new draft is never orphaned.
onExit()'s save-or-delete-on-back behavior and the "don't save mid-send"
guard are unchanged; autosave mirrors the same guard (plus a navigated
guard so a post-send tick can't re-create a sent message's draft).
Closes#177
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add setAlignment(content, start, end, align) and alignmentAt(...) ops to
RichTextEditing with paragraph-range bookkeeping (mirroring toggleBlock).
setAlignment marks every line the selection touches, leaves untouched
paragraphs alone, and stores START as "no alignment" (dropping the range) so
an otherwise-plain paragraph stays plaintext-only; CENTER/END become explicit
ranges. It emits the same canonical form RichTextHtml.fromHtml returns — one
merged range per run of adjacent same-aligned lines, and blank paragraphs
anchor no range (the HTML model can't pin text-align to an empty <p>) — so the
model, its HTML, and the editor's ParagraphStyle rendering never drift.
alignmentAt returns the shared alignment (START default for plain paragraphs,
null when mixed), driving a three-state control directly.
Add ParagraphAlignmentControl (start/center/end glyph buttons) and append it —
plus the existing FontSizePicker — at the END of the toolbar, after the block
and link buttons. Per the #73 lesson, nothing may shift the bullet/numbered/
quote buttons rightward or the compose E2E's no-scroll performClick on "•" (and
siblings) misses.
Editor renders alignment via the existing ParagraphStyle(textAlign) path
(applyAlignment routes through it, preserving the selection since alignment
never changes the text).
Tests: setAlignment/alignmentAt covered thoroughly at the JVM layer (caret,
multi-paragraph merge, mid-block split, untouched paragraphs, blank lines,
empty document, mixed selections) plus a serialize->parse round-trip fixpoint
on setAlignment output; applyAlignment covered in RichTextEditorTest; a
compile-only androidTest drives the control in isolation.
Closes#76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persists the font family/size from a sent formatted message to the
settings DataStore (SettingsRepository.setLastFont), taking the
message-wide base style if set, else the last FontFamily/FontSize
span. Brand-new compositions (draftId == null) seed a RichBaseStyle
from the remembered preference so the whole message defaults to it;
resumed drafts and replies/forwards are untouched, and messages with
no remembered font stay plaintext-only.
Closes#78
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The font-size dropdown was inserted before the block-marker buttons, and its
wide "Default"/"N pt" anchor pushed the "•" bullet button past the right edge
of the horizontally-scrolling toolbar on the Pixel 2 E2E device (411dp wide,
minus the compose column's 16dp padding = 379dp usable). ComposeScreenTest's
formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml taps the bullet
without scrolling first, so performClick targeted a center that was clipped
off-screen and the tap silently missed — the line was never marked, failing
all 8 instrumented legs deterministically (expected "• Buy milk", got "Buy milk").
The block-toggle logic was never touched; this was pure toolbar overflow. Move
FontSizePicker to the end of the toolbar (after the link button) so every
pre-existing glyph button keeps the exact position it has on main and the
bullet stays within the initial viewport. Add a comment recording the ordering
constraint for future toolbar tickets.
Also add a JVM unit test (RichTextEditorTest) that drives the same bullet-tap
flow through applyBlock + RichTextHtml.toHtml, pinning "• Buy milk" and
<ul><li>Buy milk</li></ul> so a regression in that block/HTML path is caught
by testDebugUnitTest without an emulator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Inserts a new LicenseScreen ahead of OnboardingWelcomeScreen as the
onboarding graph's start destination: the user must scroll the full
GPL-3.0 text and tap Agree before reaching anything else, or Decline
to exit the app outright. Acceptance is persisted
(SettingsRepository.licenseAccepted) so a user who agrees but exits
before adding an account isn't asked again, and the
NotificationPermissionEffect() request (#151) stays scoped to
OnboardingWelcomeScreen so it never fires on the license screen.
Closes#172
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a preset-size dropdown (10/12/14/18/24pt, plus Default to clear) to the
compose FormattingToolbar via a new FontSizePicker composable, applying
RichStyle.FontSize over the selection through the existing generalized
applyStyle/clearStyle toggle path (no font-size-specific branching needed).
The anchor button shows the selection's current size, or "Default" when
unset/mixed. The rich-text foundation already provided RichStyle.FontSize,
its pt/px-tolerant HTML round-trip, and pt->sp mapping for in-editor
rendering; this ticket wires up the missing UI control.
Closes#73
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New users unfamiliar with app passwords commonly try their regular
account password first and get a confusing auth failure. Add a
disclaimer as supporting text directly under the "App password" field
on AppPasswordSetupScreen (shared by every preset provider), instead
of leaving the warning only in the intro InfoCards above.
Closes#160
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MailMaintenanceGate serializes only the backfill↔prune pair. The other two
pairs — sync↔backfill and sync↔prune — are deliberately ungated (foreground
sync uses its own syncMutex to stay UI-responsive) and rely on a disjoint-by-UID
argument for safety, with no test covering it (issue #53).
Add MailSyncConcurrencyTest: a real MailSyncer and a real MailBackfiller/
MailPruner wired to one shared in-memory message store, with CompletableDeferred
gates (mirroring MailMaintenanceGateTest) that park one actor mid-critical-
section while the other's whole critical section runs. Each interleaving is
driven to the boundary (window edge / count floor) where an overlap would
surface as a lost, duplicated, or wrongly-deleted row:
- sync's windowed reconcile runs while a backfill is parked mid-paging just
below the window (tightest edge: lowestSyncedUid == minWindowUid);
- a backfill's below-window page lands after a concurrent full sync of the
window (stale-boundary ordering);
- a count-retention prune runs while a foreground sync is parked in its fetch;
- a full foreground sync runs while a prune is parked inside its critical
section, before it touches the message table.
All four pass: the disjointness invariant holds unlocked. No production code
changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds MailProvider.AOL as a guided app-password provider (after iCloud, before
Other), closing the coupled pair of #156 (app-password help content) and #154
(IMAP/SMTP presets):
- appPasswordHelpUrl points at AOL's "Create and manage 3rd-party app
passwords" article. No twoFactorHelpUrl: verified against both AOL's
app-password article and its separate two-step-verification article that
neither treats 2FA as a prerequisite for generating an app password (AOL
mirrors Yahoo here, not Gmail/iCloud).
- IMAP imap.aol.com:993 (SSL/TLS); SMTP smtp.aol.com:465 (SSL/TLS) — AOL's
official docs and the Thunderbird ISPDB autoconfig only document implicit
TLS on 465 for submission, with no STARTTLS/587 alternative, so this
follows Yahoo's rationale rather than Gmail/iCloud's STARTTLS preset.
AppPasswordSetupScreen's two exhaustive `when` blocks (providerIntro,
twoFactorHelpLabel) gain an AOL branch; AccountPickerScreen already lists
providers generically via MailProvider.entries. Test coverage mirrors the
Yahoo/iCloud assertions: presets, help URLs, no twoFactorHelpUrl, fromKey,
forImapHost, and brandFor resolving a manually-configured imap.aol.com
account to the AOL brand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the previously-unused ColorSwatchRow into the compose FormattingToolbar
with two new controls: a font-color button applying RichStyle.FontColor and a
highlight button applying RichStyle.Highlight over the selection. Each opens a
ColorPickerDialog built on the shared ColorSwatchRow (~8 font colors; yellow /
green / cyan / pink highlighter markers), with a "no color"/"none" entry that
clears the style outright via a new clearStyle op. Buttons reflect the current
selection's color and carry accessible onClickLabels; swatches carry their own
contentDescriptions.
Closes#74Closes#75
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #148: opening an already-cached message was still slow. Three fixes
on the cached-open critical path (issue #186).
Fix 1 - WebView renders once. The reader resolved cid: inline images AFTER the
first render, so the AndroidView update key (which included inlineImages.keys)
changed and reloaded the whole document a second time for any inline-image email.
ReaderViewModel now resolves inline images and folds them into the SAME state
update as the body, and HtmlBody drops inline images from the reload key, so the
WebView loads exactly once and a late inline-image change never reloads. The
WebView is also destroyed onRelease so it (and its Context) is not leaked.
Pool/pre-warm is left as a TODO (leak-prone; single-render is the dominant win).
Fix 2 - openMessage does no wasted work for a cached, already-read message. Added
a body-less MessageRouting projection (mirrors MessageSummary, no migration);
the routing/flag callers (openMessage's first read, downloadAttachment, setStarred,
deleteMessage, expunge, moveByRole/moveToFolder, buildReplyDraft, prefetchMessage)
route on it, and getById (SELECT *) is reserved for the single read that returns
the body. imapParamsFor (Keystore decrypt + DataStore read) is resolved lazily,
only in the fetch / SEEN-push branches; the cached+read path also skips the
account lookup. De-duped the inlineImages attachment N+1 via a shared
ensureAttachmentFile helper that takes the already-resolved account/folder.
Fix 3 - repository IO off the main thread. openMessage, inlineImages,
downloadedAttachmentParts, and downloadAttachment now run in
withContext(Dispatchers.IO), so their DB/file/crypto work no longer runs on the
Main.immediate viewModelScope during the open animation.
Reader behavior (content, read/SEEN semantics) is unchanged; does not touch the
async SEEN network push handled separately by #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>