# npm Publish Changes

## v1.1.75 — 2026-05-17

Setup screen: note that a Gmail account is required for now

Welcome/setup form now shows a highlighted note that a Gmail or Google
Workspace account is required for now, with other providers coming. CLI
-setup prompt updated to "Gmail required for now".

## v1.1.76 — 2026-05-18

Require Node.js >= 24.2.0 and enforce it at install + launch

Added "engines" to package.json and a hard version guard in both
postinstall.ts (fails the install) and bin/mailx.ts (fails at launch).
Floor is 24.2.0 because node:sqlite must work flag-free (Node 24) and
the code targets import.meta.main (Node 24.2). engines alone only warns;
the guards stop an unsupported runtime with a clear message instead of a
cryptic crash. Also: setup screen note that a Gmail account is needed.

## v1.1.77 — 2026-05-18

User dictionary: store as plain CSV on the server

The cloud-mirrored spell-check user dictionary moves from userdict.jsonc
to userdict.csv — a single-column, one-word-per-line list that is
trivially hand-editable. `#` comment lines and blanks are ignored;
spreadsheet-exported rows load via their first field. loadUserDict reads
the legacy userdict.jsonc once when no CSV exists, and saveUserDict
unions in both the cloud CSV and the legacy cloud JSON, so words added
before the switch are not lost.

## v1.1.78 — 2026-05-18

User dictionary: drop dead JSON-migration code, read cloud-first

There were no pre-existing dictionaries to convert, so the legacy
userdict.jsonc fallback in loadUserDict/saveUserDict is removed. loadUserDict
is now async and reads the cloud userdict.csv first (so a fresh machine
picks up prior words), falling back to the local cache. Service
getUserDict/addUserDictWord/removeUserDictWord await accordingly.

## v1.1.79 — 2026-05-18

Fix user-dictionary persistence; correct contacts.md

User dictionary never reached the server: mailxapi.js (the desktop IPC
bridge) had no getUserDict/addUserDictWord methods, so api-client's
optional-chained calls silently no-op'd — added words lived only in
localStorage. Added the four bridge methods, a bulk addUserDictWords
RPC, and a load-time reconcile in spellcheck.ts that pushes any
localStorage-only words up to userdict.csv so accumulated words are
recovered.

contacts.md: removed the stale `discovered` documentation — since Q150
the discovered set is a per-device local cache and is not written to or
synced via contacts.jsonc. Documented the cloud file as preferred +
denylist + groups (+ preserved hand-added keys).

## v1.1.80 — 2026-05-18

Fix: opening an attachment did nothing on desktop

Clicking an attachment chip fired a programmatic <a download> click,
which msger's WebView2 silently drops — so nothing happened and the
failure was swallowed by a console-only catch.

New service method openAttachment saves the attachment to
~/.rmfmail/attachments/ and opens it with the OS default app (start /
open / xdg-open), the same in-process pattern as openInWord. The viewer
now calls it on desktop, keeps the native-bridge path for Android, and
falls back to the blob download only for a real browser / --server
mode. Failures now surface via an alert instead of vanishing.

## v1.1.81 — 2026-05-18

Fix spurious attachment-open error + blocking alert that stalled sync

Three faults behind "failed to open" appearing after the file had
actually opened in Acrobat:

1. The openAttachment IPC call used the default 30s ceiling. A cold
   parse worker (14-25s) plus a 1.7 MB PDF blew past it, so the client
   rejected even though the service succeeded. getAttachment/
   openAttachment now get a 120s ceiling in mailxapi.js.

2. On that spurious rejection the viewer ran the browser-only blob
   fallback, whose atob() threw "string not correctly encoded". The
   fallback now runs ONLY when openAttachment returns undefined (real
   browser / --server mode); a genuine IPC failure is reported, not
   retried via blob.

3. The failure was shown with window.alert() — a modal that freezes the
   WebView event loop, stalling IPC event delivery so sync looked
   stopped. Replaced with a non-blocking banner via a mailx-alert
   CustomEvent handled by app.ts showAlert().

## v1.1.82 — 2026-05-18

UX: larger viewer action icons + bigger, bolder flag star

Message-viewer toolbar glyphs (reply / reply-all / forward / delete /
flag / mark-unread) bumped to 1.4rem with more padding for an easier
target; the "← Inbox" text label stays normal-sized.

Message-list flag star enlarged from 0.9em to 1.45em, vertically
centered, with a gold hover preview and a stronger solid-gold colour
when flagged. Flag grid column widened 1.2em -> 1.7em to fit it.

## v1.1.83 — 2026-05-18

Viewer flag button: use a star glyph, matching the list star

The toolbar Flag button showed a ⚑ while the message-list row uses
★/☆ for the same flag state. Changed the toolbar glyph to ★ so the
two are visibly the same action.

## v1.1.84 — 2026-05-18

Viewer Flag button reflects flagged state, like the list star

The toolbar Flag button is now ☆ in the default colour when the viewed
message is unflagged and a gold ★ when flagged — same glyph + colour
behaviour as the message-list row star. updateFlagButton() runs on
mailx-message-shown and after every toggle (including the error revert).

## v1.1.85 — 2026-05-18

Flag star: single named colour token; drop unneeded VS-15 churn

v1.1.84 already rendered both flag stars the same gold, so the
variation-selector / font-variant-emoji changes were reverted — they
fixed nothing and put invisible characters in source.

Instead the gold/amber flag colour is now a documented token,
--color-flag-star (oklch hue 60), in variables.css. Both the
message-list row star (.ml-row.flagged .ml-flag) and the viewer toolbar
Flag button (#btn-flag.flagged) reference it, so they can't drift, and
the colour is named rather than a duplicated magic number. Star glyphs
restored to plain literal ★/☆.

## v1.1.86 — 2026-05-18

Fix: update banner wiped by next sync; raise server-search timeout

Update banner: the "update available" notice shares the alert-banner
element, and the sync-success path calls hideAlert() unconditionally —
so the banner the auto-update poller raised was cleared within seconds
by the next sync and the user never saw it (the daemon log confirms the
poller has been detecting every release). The banner is now tagged
dataset.key="update-available"; hideAlert() leaves that key alone unless
forced, and the X dismiss button passes force:true.

Server search: a "Server"-checkbox search goes out to IMAP/Gmail and
can exceed the default 30s IPC ceiling, surfacing as "mailxapi timeout:
searchMessages". searchMessages now gets a 120s ceiling like the
attachment actions.

## v1.1.87 — 2026-05-18

Compose autocomplete at caret; instant discard; visible attachments

- Recipient autocomplete now matches the token the cursor is in, via
  tokenSpanAtCaret(), instead of always the last token — typing a name
  before an existing comma matched the wrong recipient. replaceCaretToken
  replaces only that token and leaves following recipients intact.

- Compose Discard / X: the IMAP draft delete is now fire-and-forget so
  the window closes instantly. It used to await the server round-trip,
  leaving the window up for seconds ("eventually it did").

- Attachment chip: clearly tinted background + accent border so it's not
  easy to miss; clicking shows "Opening…" for >=600ms and ignores
  re-clicks while in flight (the open hands off to the OS viewer with no
  visible change in mailx, so it was being clicked repeatedly).

## v1.1.88 — 2026-05-18

Fix: highlighted row with an empty viewer (selection/preview desync)

A list row could stay highlighted while the viewer showed "Select a
message to read". releaseFocus() only un-selected the tracked focusedRow,
so a stray .selected (multi-select remnant, or a row that kept the class
through an incremental rerender) was left highlighted after the viewer
had been cleared.

releaseFocus() now strips every .selected row. focusRow() also sweeps
stray .selected in single-select mode before highlighting its row, so
the list can never show a highlight that doesn't match the viewer.

## v1.1.89 — 2026-05-18

Spellcheck: disable the native checker so only nspell runs

The compose editor had BOTH the native Chromium/WebView2 spellchecker and
mailx's own nspell checker active — double red underlines everywhere
("why so much red twiddle") and right-click popping the native
suggestion menu instead of ours. wireSpellcheck now forces
spellcheck="false" on the editor body (with a MutationObserver to keep
it off), so nspell is the single source of squiggles and suggestions.

## v1.1.90 — 2026-05-18

Search history records live (no-Enter) searches too

Search history only recorded on Enter, so a search typed and run
live ("Hoddie") never appeared in the history dropdown. Every executed
search is now recorded; recordSearchHistory() prunes any entry that is a
prefix of the new query, collapsing the keystroke progression ("Hod",
"Hodd") into the final term so it lands in history exactly once.

## v1.1.91 — 2026-05-18

Fix: typing after a URL no longer swallows text into the link

In the TinyMCE editor, typing or pasting a URL auto-linked it, but the
caret stayed inside the <a>, so every following character was appended
into the link text — a whole sentence turned into one link.

rmf-tiny's adapter now has a keypress handler: when a printable key is
pressed with the caret collapsed at the trailing edge of an <a>, the
caret hops to just after the <a> first, so the new text lands outside
the link. Covers autolink, pasted URLs, and hand-made links.
(client/lib/rmf-tiny.js rebuilt from the adapter source.)

## v1.1.92 — 2026-05-18

Fix: viewer showed wrong body (header/body mismatch on search results)

The viewer's same-message guard keyed on (uid, folderId). uid is only
unique within a folder, so a cross-folder search could surface two
different messages with the same uid; when the result's folderId was
omitted the guard mis-fired — it refreshed the header to the clicked
message but kept the previously-shown message's body in the iframe. The
viewer looked "scrambled": header said one message, body showed another.

The guard now keys on the stable `uuid` whenever both sides have one,
falling back to (uid, folderId) only for uuid-less envelopes (thread
popup). The local store/.eml files were never corrupt — this was purely
a viewer identity-check bug.

## v1.1.93 — 2026-05-18

Search: index full message bodies, not just the preview snippet

Body-text search only ever covered messages the user had opened. At sync
time the FTS body_text was seeded with the short `preview` snippet; the
full text was backfilled only when LocalStore.getMessage parsed a body
on view — and even that backfill keyed on the legacy messages.folder_id
column via a (account,folder,uid) lookup that could miss the row, so a
word buried in an opened message's body still wasn't found (Bob:
"mcdade" in a message body, search found nothing).

Two fixes:
- updateFtsBody(rowId, text) keys on the FTS rowid (= messages.id)
  directly — no fragile folder lookup. store.ts passes envelope.id.
- The IMAP sync path now indexes the full body into FTS right after
  upsertMessage. extractPreview already parsed `source`, so the body
  text is free — every synced message is body-searchable without the
  user opening it first.

Existing already-synced messages stay preview-only until re-synced
(mailx -repair re-indexes them).

## v1.1.94 — 2026-05-19

Tabs: keep the strip visible with one tab so "+" is discoverable

The view-tab strip was hidden until a 2nd tab existed — but with it
hidden there was no hint that tabs exist or that Ctrl+T creates one
(Bob: "there needs to be some indicator that ^t creates a tab"). The
strip now stays visible with a single tab, showing that tab plus the
"+" button; its tooltip names the Ctrl+T shortcut. Hidden only before
the very first tab is seeded.

## v1.1.95 — 2026-05-19

Compose window opens centred near the top, not lower-right

The floating compose/reply overlay was anchored to the bottom-right
corner. It now opens horizontally centred with a small top margin. The
title bar still drags it anywhere; this only changes initial placement.

## v1.1.96 — 2026-05-19

Alarm/snooze: log decisions to the daemon log so snooze is verifiable

On review the snooze keying is actually correct — snoozeItem stores under
item.uuid, which IS the full per-occurrence key (uuid:startMs@offset for
calendar, uuid:dueMs for tasks) that collectDueAlarms reads back as
snoozed[key]. They match.

What was missing was visibility: the [alarm] decisions used plain
console.log, which from the WebView never reaches the daemon log file —
so "is snooze working?" couldn't be answered after the fact. Added an
alog() helper that mirrors to the daemon log via logClientEvent, a
"snooze saved" line (key + minutes + untilMs), and routed the existing
fire-decision / dismiss / unknown-result logs through it. Next snooze is
now traceable: the "snooze saved" line and the following "fire decision"
line confirm the snooze took and for how long.

## v1.1.97 — 2026-05-19

Reply scrolls to top; Ctrl+A no longer hijacked in text fields

- Reply/forward: setCursor(0) now scrolls the editor viewport to the top
  (editor.getWin().scrollTo(0,0)), so the caret above the quoted block is
  the line you're looking at instead of being left scrolled down inside
  the quote.

- Ctrl+A: the message-list select-all guard used `.closest(".message-list,
  .ml-body, body)` — `body` always matches, so Ctrl+A in the search box
  (or any input) selected every message instead of the box's text. Now it
  returns early when focus is in an INPUT/TEXTAREA/SELECT/contenteditable,
  so Ctrl+A selects (and lets you clear) the search field normally.

- docs/rmf-tiny.md: marked implemented; added a running "Known TinyMCE
  quirks" log (caret/scroll, link-extend, spellcheck, <p>-to-<br>).

## v1.1.98 — 2026-05-19

Signature now applied to replies/forwards, not just new mail

The new-format signature (acct.sig.text) was gated on "new mail only",
so anyone using it got no signature on a reply or forward — only the
legacy acct.signature field covered replies. Both formats now apply to
new + reply + forward alike: above the quoted block on a reply/forward,
at the end on new mail. Drafts still skip it (already in the saved body).

## v1.1.99 — 2026-05-19

Fix duplicate drafts (unserialized autosave) + shift-click range select

Duplicate drafts: enqueueDraftPush fired imapManager.saveDraft immediately
on every autosave (~every 2 s). An IMAP search-delete-append cycle takes
longer than that, so pushes overlapped — each one's "find the stale copy"
ran before a sibling's "append", neither saw the other's new copy, and
the Drafts folder filled with dozens of duplicates. enqueueDraftPush now
serializes per draftId: one push in flight at a time, and while it runs
only the latest queued content is kept (intermediate autosaves are
superseded — fewer IMAP ops too).

Shift-click range select: selectRange now sets `multi-select-on` for a
multi-row range. Without it, the focusRow() call right after (drives the
viewer off the clicked row) ran its single-select sweep and wiped every
row in the range except the clicked one — a regression from the v1.1.88
highlight-desync fix.

## v1.1.130 — 2026-05-23

feat: -send <file> CLI + general-outbox auto-routing; draft UI fixes

- rmfmail -send <file> [-account <id>]: enqueue a .ltr/.eml from disk for
  sending. With -account, drops into outbox/<id>/; without, drops into the
  general ~/.rmfmail/outbox/ and the daemon's outbox sweep auto-routes by
  parsing the file's From: header (exact match → known-provider domain →
  first non-known-provider "override" account). Bcc preserved as-is;
  existing SMTP/IMAP-Outbox paths handle the strip.
- Draft UI: clicking a Drafts row no longer auto-pops compose — the
  existing "Edit Draft" button (and double-click) are the only entry
  points. On Send, the draft row drops from the list immediately (was a
  2 s debounce). New "Sent HH:MM" / "Send failed: …" status confirmation.
- CLI: -send / -account added to knownFlags. Top-of-file Usage comment and
  unknown-flag error string rewritten to be complete and consistent —
  single-dash is canonical, double-dash still accepted as a synonym.
  README "Command Line" section refreshed in lockstep.

## v1.1.131 — 2026-05-23

fix: -repair uses node:sqlite, not better-sqlite3

better-sqlite3 is not a project dependency — the daemon uses node:sqlite
(built-in) via mailx-store. -repair was crashing with ERR_MODULE_NOT_FOUND
on first run. Swap the dynamic import to node:sqlite's DatabaseSync, and
move the WAL setting from .pragma() (better-sqlite3-specific) to
.exec("PRAGMA …") (works on both).

## v1.1.132 — 2026-05-24

feat: multi-level undo, viewer fate-sharing, ★ filter + ★ bulk-flag

- Multi-level undo (Ctrl+Z). Replaces single-slot lastDeleted / lastMoved
  with a LIFO undoStack. Each user-driven destructive action (delete,
  move, bulk-flag) pushes a typed UndoOp; Ctrl+Z pops and reverses via
  existing service IPC. Bounded at 50 entries, 10 min per-entry TTL,
  checked at pop time. No redo (undoing an undo isn't itself undoable).
- Viewer/list fate-sharing: when loadMessages produces an empty result OR
  the focused row's message isn't in the freshly-loaded set (filter
  toggle, sync drop, navigation), clearViewer() runs so the preview pane
  doesn't keep showing a stale message that the list says doesn't exist.
- Search-bar ★ filter button (id=btn-filter-flagged). Mirrors the
  buried-in-View-menu opt-flagged checkbox so the flagged-only filter is
  reachable in one click. Pressed/active state via .active CSS marker.
- Toolbar ★ bulk-flag button (id=btn-tb-flag) next to trash/spam.
  Operates on the message-list selection (falls back to viewer focus when
  empty). Smart toggle: if any selected row is unflagged → flag all;
  if all flagged → unflag all. Pushes a single undo entry capturing each
  row's prior state so Ctrl+Z restores the exact mix.

## v1.1.134 — 2026-05-24

fix: -repair handles FTS5 schema drift + uses correct folder columns

Symptom: "rmfmail -repair" crashed with "no such column: T.to_text"
on Bob's bobma DB. Root cause is a long-standing FTS5 schema drift —
the messages_fts table is contentless (content=messages, content_rowid=id)
and declares to_text/cc_text, but the live messages schema has
to_json/cc_json. DELETE FROM messages_fts tries to materialize each
row's content via the broken linkage and SQL-error'd before any work
could land.

Fix:
- Use the FTS5 "delete-all" idiom (INSERT … VALUES('delete-all'))
  to clear the index without touching the broken content lookup. Falls
  back to DROP + CREATE on whatever exotic failure could survive that.
- folders.total / .unread → .total_count / .unread_count (the live
  column names; the old names never existed on this schema).
- Reset highest_modseq to NULL so QRESYNC re-bootstraps; otherwise
  the post-repair sync trusts a stale ModSeq and skips folders that
  are actually empty locally.

This unblocks the stale-folder_id repair path: bobma's Added2.issues
has 552 ghost local rows tagged to a small folder but carrying
INBOX-sized UIDs. Server FETCH on those UIDs in the wrong folder
returns zero, the prefetch loop logs "0/N bodies" every tick, and
the preview pane shows "Fetching body from server..." forever. Repair
wipes the metadata, re-fetches envelopes, and folder_id gets rebuilt
from the server's actual UID lists.

## v1.1.135 — 2026-05-24

feat: elapsed-time wait indicator + autocomplete + invalid-addr UX

- Body-fetch placeholder now renders an elapsed-time counter that ticks
  every second ("Fetching body from server… (12s)"). After 30 s the
  counter flips red+bold so a real wedge is visually distinct from a
  normal slow Dovecot read. Cleared automatically when bodyAvailable
  fires, generation advances, or the placeholder leaves the DOM.
- Compose address autocomplete: clicking a "Frankston, Bob" suggestion
  against a `bob` token used to produce `bob, "Frankston, Bob" <addr>`
  (the original token preserved instead of replaced) in some edge cases.
  Fix is in replaceCaretToken — strip the trailing comma/whitespace from
  the pre-token segment, re-add a clean ", " separator. tokenSpanAtCaret
  also now skips leading whitespace inside the span so the separator
  belongs to "before", not the token itself.
- "Invalid address" send-block now uses high-contrast white-on-red
  styling (was muted red text on light grey, easy to miss). Both
  classList wiring and CSS updated: the .compose-status-error class
  gets a saturated red background, white bold text, padding/radius.
  Message text bumped to "Invalid address: '...' — Send blocked" so
  the user knows it's the gating condition, not a transient note.

## v1.1.136 — 2026-05-24

feat: periodic Sent sweep + body-fetch wallclock timeout

- Periodic Sent sweep (`ImapManager.startSentSweep()`): for every IMAP-
  path account, every 5 minutes, walks local Sent rows cached in the
  last hour and reconciles against the server's actual Sent folder by
  Message-ID:
    - Found at same UID → no-op.
    - Found at a different UID → rebind the local row's uid in place
      (mailx-store new `updateMessageUid` helper).
    - Not on server + .eml on disk → IMAP APPEND from disk (no SMTP).
    - Not on server + no .eml → leave; set-diff GC handles it.
  Gmail accounts skipped (Sent is API-driven, no optimistic-UID race).
  Fixes the "I do not see the message in sent" silent loss when the
  Outbox-drain sieve appends at a different UID than mailx predicted.

- Body-fetch wallclock timeout in the reconciler. 90 s Promise.race
  around fetchMessageBody so a wedged fetch (TCP half-open, parser
  stuck on an incomplete literal, server stalled mid-FETCH) can't
  pin inFlightFetches forever. Without this, one stuck UID drained
  the concurrency budget and every subsequent click sat behind it
  for the lifetime of the daemon. On timeout, the existing transient-
  error path requeues with attempt budget (3 tries) so the user's
  fetch eventually completes or surfaces as bodyFetchError. Pairs
  with the elapsed-time wait indicator (1.1.135) — the indicator
  surfaces a wedge, the timeout breaks one.

## v1.1.137 — 2026-05-24

fix: defensive IMAP-command-leak detection at body-store boundary

When iflow-direct's response parser desyncs on a literal byte count, the
"body" it returns can begin with one of mailx's own pipelined IMAP
commands (e.g. `A24624 UID FETCH 52 (UID FLAGS ENVELOPE ...)`). Until
now those bytes were silently written to disk as the message body and
rendered as broken (the a39…eml corruption from 2026-05-23).

mailx-store/file-store.putMessage now sniffs the first ≤512 bytes for a
tag+UPPERCASE-command pattern and throws rather than persist. The
reconciler treats the rejection as transient so the next fetch (on a
freshly-acquired connection) re-attempts cleanly, within the 3-attempt
body-fetch budget.

This doesn't FIX the iflow desync itself — it catches the leak at the
disk boundary so corrupt bodies never persist and the wedged message
gets retried instead of staying broken until the next -repair. Pairs
with the elapsed-time wait indicator (1.1.135) which surfaces the
in-progress retry to the user.

