An opt-in per device (Settings → Security → Offline access) installs a
service worker that keeps the newest 50 Inbox conversations, and the ones
opened recently, for reading when the server cannot be reached. While
online everything still comes from the server; the saved copy is only the
fallback, marked X-WM-Offline so the app knows it is offline.
Offline, an amber cloud next to the theme toggle says so and when mail was
last saved; actions that need the server are greyed out and say "Not
available offline"; conversations and folders not saved here are faded or
say so. A probe notices the server coming back, shows "Back online" and
refreshes everything. Saved mail is wiped on sign-out, on any 401, and when
another user signs in. nginx never caches /sw.js, so releases reach it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by
`make up`) checks what the user writes in the compose window, through
POST /api/spellcheck — the browser never talks to the engine directly.
- Marks are CSS highlights over the text, never written into the message: red
wavy for spelling, blue for grammar/style. Clicking one shows the message,
up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore.
The popup, like the link popup, opens above its anchor when there is no
room below.
- Checked after a pause in typing; the signature and quoted original are
skipped; marks on words just edited drop at once; a check overtaken by a
newer one, by turning checking off, or by closing the window is discarded.
- With "auto", text is checked in chunks of lines, each in its own detected
language — LanguageTool settles on one language per request, so a mixed
Polish/English message had every English word flagged as a Polish typo.
- Settings > Appearance: on/off and language (Auto-detect or any of the
engine's languages); the compose window's More menu overrides it for one
message. The browser's own spellcheck steps aside while ours runs, and comes
back when it is off or the server has no engine (503 from the endpoint).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a link in the message shows "Open the link: <URL> | Edit | Delete"
just below it. Open opens http(s)/mailto links in a new tab (a pasted
javascript: link is shown but never run); Edit swaps in an inline URL field;
Delete removes the link and keeps its words. Click elsewhere, Escape or
typing closes it — and Escape stops there instead of also minimising the
compose window.
The toolbar's link button uses the same floating field instead of the
browser's prompt: under the selected text (kept highlighted with a CSS
highlight while the field has focus), at the caret with the address as the
text, or — with the caret inside a link — editing that link rather than
nesting a new one. A bare "example.com" becomes https://example.com.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Choose in Settings > Appearance, or the list's ⋯ menu > Mailbox display (a
popover with three preview cards; picking one applies it and closes it).
Stored per device, like the theme; phones keep their own single panel.
- Side: the existing three columns.
- Bottom: the list above, the reading pane below, 45/55 until the 3px divider
is dragged (height remembered). The pane's top bar takes the list filter
bar's colour; the empty-pane picture shrinks to fit.
- Off: one panel. The list, until a conversation opens in its place with a
Back button; bulk actions move to a bar above the list. This reuses the
phone's list/pane switching with desktop styling.
- In Bottom and Off the side-panel toggle sits beside the search box (Off also
keeps it in the conversation toolbar, since the list is hidden there).
- An expanded composer covers the reading pane in every arrangement: the lower
pane under the list, the whole panel with the pane off.
Resizer gains a horizontal orientation; layoutDock takes the pane height.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Branding: the page titles and app name are now "Ultimo Email".
Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
only, nothing listed until something is typed). A user opens a window to
change their quota, block/unblock them, or delete them; block and delete ask
for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
message and revokes every session; a new user.signed_out event sends the
user's open tabs to the sign-in screen at once. Any "not signed in" answer
now does the same, not only /auth/me. Deleting removes mail accounts (sync
stops), plugin rows (cleared inside the user's RLS context, since plugin
tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
password (vault-encrypted, never sent back), From address and the public
address reset links point to, plus a "Send test" that reports the server's
error.
Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
answers "If this account exists you will receive an email to your recovery
email address"; the mail goes out after the response, so neither the reply
nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
page to set a new password. With 2-step verification on, the code is checked
before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
confirmed with the current password, which must be typed — autofill and
paste are refused (TypedPasswordInput).
Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A missing avatar was a 404, which the browser reports as an error for every
such sender on every page. It is now an empty 204: the <img> still fails to
decode it and the initials show through, but nothing is logged, and the miss
carries the same one-day browser cache as a hit so it stops being re-fetched.
On a phone the mail list's rows sit 20px apart instead of 7px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In Mail's side panel the agenda already lists the whole month and the mini
calendar never marked a selection, so clicking a day in the current month
changed nothing on screen. The day is now highlighted and the agenda scrolls to
it (TODAY scrolls back); a day in another month flips both to that month.
On a phone the panel drops the "Open calendar in new tab" footer: the host
passes newTabLink: false to the embedded calendar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thread-labels query is keyed by the visible ids, so every row leaving the
list dropped the label chips until they reloaded and the subjects jumped left
and back. Keep the previous labels meanwhile. Rows now animate their position,
so the ones below a removed row slide up over it (off under reduced motion).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking Send now closes the compose window right away. The undo offer moved
out of the composer into a toast: bottom right on a desktop, a small "Undo"
pill with the countdown bottom left on a phone, where sending also returns to
the message list. An undone send reopens the draft in a composer. The
popped-out compose window keeps its inline undo bar, since closing it would
lose the offer.
Also: drop the "Reply" label from the From row; on a phone show the Send
button as its icon alone, enlarge the Send/Attach/Close icons by half, put the
logo beside the menu button, make the reading view's back arrow bolder, and
make the list's pagination bar half again as big.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blurring the To/Cc/Bcc field (or Enter, or a comma) committed whatever was
typed — a name like "John" became a recipient that could never be
delivered. Only valid addresses ("a@x.com" or "Name <a@x.com>") now become
chips; anything else stays in the input, marked red, to fix or complete
from the suggestions. A pasted list keeps just its invalid parts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reply or forward now opens with the quote hidden, as the reading pane
shows it; the pill toggles it. The pill lives in the editor but is stripped
from everything reported out, and the quote is hidden by a class on the
editor rather than in its markup, so drafts and sent mail carry the full
quote. Only a quote the pill directly precedes is hidden, so deleting the
pill cannot strand it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Below 768px (or a touch screen under 500px tall) the mail view becomes one
column at a time: the list full screen, with a menu button beside the search
box that slides the sidebar in as a 70%-wide drawer (tap outside, pick a
folder or press Esc to close), and a floating compose button. Opening a
conversation or a draft shows the reading pane full screen with a back
arrow; composers, including reply/reply-all/forward, open full screen.
List rows stack sender/date, subject and snippet; tapping an avatar selects
a row and the bulk actions sit above the list. The app switcher, Settings
link, select-all box, resizers and right panel are hidden on a phone, and
the composer header drops the Attach label and pop-out at phone width.
Everything is gated on useIsMobile(); the desktop layout renders as before
(checked by screenshot comparison at 1440x900).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single sender showed in full (or, past 25 characters, as a first word).
Past 22 characters it now shows the first 21 and "...". Several senders
keep the full-names / first-words presentation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Past 25 characters the joined sender column truncated mid-name. It now
shows each sender's first word instead ("Alice, me, Bob"), a bare address
by its local part, and the user's own accounts still as "me".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sender column showed participants[0], and participants are rebuilt
newest message first — so replying to a thread put the user in that slot
and the list named them instead of the person who wrote in.
Thread summaries (list and search) now carry `senders`: the distinct From
addresses of the thread's visible messages, oldest first, with the same
Trash/Spam visibility as the message count. The list joins them and shows
the user's own account addresses as "me" ("me, Alice" for a thread the
user started), and the avatar prefers the other side of the conversation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking Compose with a window already open cleared what you had typed.
MailLayout bumped a React key on every open to force a fresh editor, and
switching folders ran an effect that closed the composer outright. Both
were deliberate when only one composer could exist at a time.
Underneath them sat a sharper constraint: RichTextEditor seeds its
contentEditable exactly once, so ANY remount silently discards the body.
Every part of this change is shaped by keeping a composer mounted.
Windows now live in one ordered list, each with a stable id used as its
React key, rendered by ComposeDock as a single keyed array — open windows
and collapsed title bars together. Splitting the open one into a child
slot of its own would hand its state to whichever window took its place,
which is exactly what happened the first time around.
- A minimize control, first of the four window icons, collapses a
window to a title bar showing its subject (or "New message"). The
subtree is hidden, never unmounted.
- Clicking Compose parks the open windows and appends a new one, so
each click pushes the previous window one place left.
- Restoring a window touches only that window; several can be open at
once. When they stop fitting, the least recently reached-for ones
are DRAWN as bars — state untouched, so they spring back open when
room returns.
- Expanding matches the reading pane, measured live, and moves the
window to the end of the row: the row is right-anchored, so a bar
after it would shove an expanded window off the pane and across the
list and sidebar.
- A window is given its height rather than sizing to its content. Left
to itself it grew with the body until the top ran off the screen,
and the `flex-1` body never became scrollable because there was no
leftover space to divide.
- Leaving a folder un-expands a window instead of closing it.
- The dock row is pointer-events-none — its box spans the tallest
window in it, and as a bare div it shielded the mail list beside it.
- Drafts opened from the Drafts folder now load in the reading pane,
keyed by draft id, with the open one marked in the list.
Also fixes a blank draft saved for every composer opened: the signature
effect guarded itself with a "first run" flag, which StrictMode's double
invocation spends on the first pass. The second pass rewrote the
signature of an untouched composer, marking it dirty. Keyed on the
account instead, so it is idempotent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
Paging while scrolled down kept the old scroll position, so clicking
next dropped you into the middle of the following fifty with nothing to
signal you had moved.
The list is virtualized, so there is no natural anchor to reset — the
scroll container has to be told. MessageList now takes the page number
and scrolls back to the top when it changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
The first column listed every participant, so an ordinary email read
"Sender, You". It now shows the sender — `participants[0]`, which is the newest
message's from address because the threader adds each message's sender ahead of
its recipients.
The +N badge beside the avatar stays: the name is narrowed, the "how many people
are in this" signal is not.
Nine lines in one component. An earlier attempt at this added a denormalized
`threads.senders` column and a backfill migration so that a thread with several
writers could list them all; that was more machinery than the request warranted
and is reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
The first column listed a thread's `participants`, which the threader builds from
from + to + cc — so every ordinary email read "Sender, You", and the +N badge
beside the avatar counted the user themselves.
`participants` cannot simply be narrowed: the subject-fallback join matches an
incoming message's recipients against it (threader._by_subject), so dropping
recipients would change how mail is threaded. Hence a separate `threads.senders`,
holding only from addresses, newest writer first — which is also the right thing
for a conversation where several people replied.
Migration 0019 backfills it, so existing threads do not wait to be touched again.
The list falls back to `participants` if a row somehow has no senders, rather
than rendering an empty column.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
A button pinned to every message header is a persistent control; marking spam is
a verb you reach for occasionally, like Reply or Delete. It now lives in the
Actions menu, between Labels and Delete.
That needed a slot inside the dropdown, which the catalog had no name for:
`message.actions.menu`. The host wraps it so any click closes the menu, leaving
a bundle to do nothing but act.
The UI kit gains `.wm-menu-item`, @applying the exact utilities ThreadView's own
menu items use — the same trick the sidebar shell already relies on, so a plugin
verb is indistinguishable from a core one rather than approximately similar.
Labels follow the folder as before: "Mark as Spam" in the inbox or archive,
"Mark as not spam" in the spam folder, nothing in Sent/Drafts/Trash. A message
already taught shows that, disabled, instead of offering the lesson again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
- Backfill: show the sidebar spinner + "Preparing…" banner during the brief window
after account creation when state=backfilling but total is not yet counted
(total===0), so a freshly-added account shows progress immediately instead of
looking done until a refresh. The stuck-at-100% guard is preserved.
- Settings: a centered spinner overlay ("Removing account…") covers the screen from
confirm until the (slow) server cascade + accounts refetch complete, so the row
no longer appears frozen before it disappears.
- Add-account wizard: the manual password hint now includes Outlook and gains a
"Help" link opening a popup with per-provider direct links to enable 2-step
verification and generate the app password (Gmail, Outlook, Yahoo, iCloud).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Sender avatars (privacy-safe, server-fetched, cached):
- New GET /api/avatar resolves Gravatar (d=404) then the sender domain's favicon
under the SSRF guard, Redis-cached (hit+miss); gated by SENDER_AVATARS_ENABLED.
GuardedResponse gains raw bytes. Shown in the mail list, reading pane, People
panel, and Contacts (overlay that falls back to initials on a miss).
PDF preview:
- Password-protected PDFs now prompt in-place and retry (pdf.js PasswordException),
with a clear error for other load failures instead of a blank overlay. The
password is remembered per attachment until the thread is left; clicking the
field no longer dismisses the overlay.
Contacts:
- Autosave reply recipients: message.sent now carries to/cc (name+email); the
contacts plugin subscribes and adds any non-contact recipients to the Autosaved
group. Contact avatars show the gravatar/favicon via /api/avatar.
Mail views / retention:
- Clicking an account shows all its mail except Trash/Spam (was inbox-only).
- Daily cron autopurge_trash_spam permanently deletes Trash/Spam older than
TRASH_RETENTION_DAYS (default 30; 0 disables) — server EXPUNGE + local rows.
UI polish:
- Ultimo signet logo in the Mail/Calendar/Contacts sidebar header and collapsed
rail; consistent 11px top margin and vertically-aligned header row across states
(fixed-size flex icon buttons); SVG favicon on the browser tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Gmail sync (SPEC: Gmail sync):
- Provider fetches X-GM-LABELS when X-GM-EXT-1 is advertised; parses custom labels
(system labels dropped, mUTF-7 decoded) onto RemoteMessage.
- Engine syncs only system folders + All Mail for Gmail, skips every label folder,
and dedupes the All Mail pass by Message-ID — kills the INBOX/All-Mail duplication.
- Custom labels ride the message.new event; the labels plugin subscribes and maps
them to chips as "<name> [Gmail]" (per-message + thread aggregate + known set),
so labels fill in gradually with the sync window. Non-ASCII labels decode right.
Labels self-healing (message-driven reconcile):
- Per-message keys are the source of truth; t: aggregates and _labels are derived.
Reconcile drops labels of deleted messages, regroups survivors by their current
thread (fixing JWZ thread merges), rewrites changed t: keys, and rebuilds _labels
from live messages — keeping user labels, dropping orphaned Gmail ones.
- New SDK reads: sdk.threads.existing / sdk.messages.threads_of. Sidebar refreshes
live via a plugin.labels.changed WS event on new labels.
Account deletion: prune threads left empty after the cascade (they were user-scoped,
so only messages cascaded) + de-list the account from surviving cross-account threads.
Sync SPAM: opt-in per-account sync_spam (migration 0015, default off) — the engine
skips the Junk folder in backfill and poll unless enabled; wizard checkbox added.
Robustness / UX:
- WebSocket auto-reconnects with backoff and resyncs on (re)connect, and account
add/delete invalidates threads/labels — no more hard refresh for live updates.
- Backfill banner + sidebar ring also require done < total, so a completed account
can't leave a stuck "X of X".
- Scrub lone UTF-16 surrogates from parsed headers/bodies so bad messages no longer
crash the backfill on Postgres text/JSONB.
- Ultimo signet logo in the Mail/Calendar/Contacts sidebar header (and at the top of
the collapsed rail), via the shared plugin-ui.css + MailLayout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
- Panel resize handles: replace the tall bar with a small centered pill/capsule
(elevated fill + border, accent on hover) across Mail, Calendar and Contacts.
- Calendar month/week/3-day views: brighten a day block on hover.
- Calendar month grid: shade the current month's days theme-aware — a subtle dark
overlay in light mode (they were only distinguished in dark mode before), white
overlay in dark mode.
- Calendar event form: replace the native <input type=date> (whose order follows the
browser locale) with a custom field driven by the 24h/12h preference — an editable
text box (typed dd/mm/yyyy for 24h) backed by a hidden ISO input, plus a popup
month picker. Applies to the start date and the recurrence "Ends on date".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Labels:
- Moved labelling out of the "+label" message slot into Mail: label chips on the
thread subject bar (removable ✕) and before list subjects, and a Labels submenu
in the per-message Actions menu (multi-select checkboxes + "Create new…").
Per-message storage kept; the reading pane writes a thread-union aggregate so the
list (no message ids) can show chips.
- Sidebar LABELS section rewritten: aligned monocolor tag rows, collapsible
(collapsed by default), inline "+" create popover, hover "−" delete with a confirm
popover, and click-to-filter the mail list by label.
- Backend: batch message/thread label reads, thread aggregate, create + delete
(delete unhooks from all mail), and GET /labels/{name}/threads. Core GET /threads
gained an `ids` param to power the label view. Removed the plugin-pages list from
the Mail sidebar.
Plain-text mail now renders in Lato (self-hosted at /fonts, @font-face'd into the
sandboxed message iframe) instead of the monospace <pre> default.
Calendar right-panel (embed mode): restored the "…" view-options menu, pinned the
toolbar + mini-calendar (only the agenda scrolls), plain "All calendars" button
(inline resets), and the event popover now closes on any outside click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
New slide-out right panel in Mail (hidden by default, toggle in the reading pane,
resizable, persisted). Three tabs:
- Calendar: mounts the calendar plugin bundle in a new embed:"panel" mode, so the
agenda, mini-month, event popover, event form and search results are the calendar
app's OWN code — future changes propagate to both. Events click → real popover;
New event / Edit / "Invite all" open the shared event form in the panel; search
shows the calendar's results panel; All-calendars filter + open-in-new-tab.
- People: conversation senders / contact books (All/Recent/VIPs/Autosaved) drilling
into a contact detail (Call/Message/Compose/Invite, related mail, find-all);
"Compose to all" / "Invite all to new event".
- Attachments: every attachment in the thread, grouped by sender, with download and
clickable previews (reuses the mail AttachmentPreview overlay for supported types).
Calendar bundle: mount(el, ctx, props) reads embed/newEventGuests; renderEmbed() +
panelBar/panelFoot; embed-aware agenda (TODAY); form/popover repositioned for the
narrow panel; guest deep-link accepts comma-separated emails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Mail:
- New "More" (⋯) menu in the list sorting bar with "Mark all as read": POST
/threads/mark-read marks every unread thread in the current view (unified /
folder:<special> / account:<uuid>) as \Seen, pushes + publishes updates.
- Message iframe: no longer shows a spurious vertical scrollbar (display:block,
getBoundingClientRect+ceil sizing, overflow-y:hidden on the doc) and has
rounded corners.
- Deep links from Contacts: /mail?compose=1&to= opens a maximized prefilled
composer; /mail?q= prefills the search box.
Contacts:
- Detail pane shows all populated fields (work subtitle, phones, address, urls,
nickname, birthday). Company name is used as the display name when there's no
personal name (existing rows too). Editable "Add notes".
- Selection: checkbox reveals on avatar hover only; Groups/More bulk menus in
select mode; "N contacts selected" panel; filled yellow VIP star in list +
detail; pagination bottom bar; Lato pinned on form controls.
- Groups: "+" on the GROUPS header and "Create new" in the group menus (bulk
add/remove via POST /groups/{id}/add|remove, ownership-guarded).
- Rounded Mail-style list rows; removed the ⋯ button from the search bar;
Compose→Mail, Find-all-conversations→Mail search; Message button removed;
delete confirmation; resizable sidebar + list/detail split.
Calendar: the right panel (agenda / search) is now resizable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Calendar plugin (webmail_plugin_calendar, non-removable core plugin):
- Local calendars/events CRUD with RLS isolation; recurrence expanded at
query time (DST-correct), EXDATE + detached-override merge.
- Calendar app bundle: month/week/day/3-day views, mini-month, event form
(color, show-as, timezone combobox, repeat, reminders, guests w/ recipient
autocomplete, Jitsi video call), event popover with RSVP badges, resizable/
collapsible left panel + theme toggle, overlap layout.
- iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML +
text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token)
records accepted/declined/tentative per attendee.
- "This and following" edit + scoped delete (this / this-and-following), with
a delete confirmation dialog for recurring events.
Core:
- Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations
section; date order follows time format (en-GB vs en-US) across Mail.
- Plugin SDK: mail (send from a user's account), vault (envelope encrypt),
multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed
PluginPage.
Mail HTML rendering:
- Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like,
strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so
already-indexed mail re-sanitizes on view.
- Attachments: a Content-Disposition: attachment part is a real attachment
even when it also carries a Content-ID (fixes hidden invite.ics).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frontend:
- Full-screen attachment preview overlay (AttachmentPreview) for image/pdf/txt
plus office docs: .docx via docx-preview (rendered live into the sandboxed
iframe so header/footer VML images survive), .xlsx via xlsx-preview, and
.odt/.ods/.odp/.odg via a JSZip+DOMParser ODF walker (officeConvert).
- Type-aware attachment icons (green spreadsheet, blue document, red PDF,
image/archive); new document/image/spreadsheet/print SVGs.
- Composer: drag-drop attachments (body drop inserts images inline, else
attaches); inserted images are selectable/resizable/deletable (RichTextEditor).
- Undo-send: "Sending…" banner with live "N seconds to Undo" countdown.
- Search result list: pin/archive/delete update correctly; grey-out only for
search hits; scoped "Search in Trash/Spam".
Backend:
- Search: prefix (search-as-you-type) matching, email-address search, and
address-first result ordering; Trash/Junk excluded with scoped folder search.
- Attachments: RFC 6266 Content-Disposition for non-ASCII filenames; one-time
inline image loading.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
Search (PG backend):
- '@' terms match address substrings (from/to/cc), so a partial email like
"dyrektor@wfdif" resolves and narrows predictably as you type
- index each address split into parts, so a bare domain/local word ("wfdif")
finds the address (indexed as one lexeme otherwise); dotted terms fall back to
an address substring
- rank sender/recipient name+address matches above body/subject-only ones
(addr_hit tier, then recency); cursor extended to keep keyset pagination correct
- scripts/reindex_address_parts.py backfills address tokens into existing rows
Inline images:
- extract attachments via a full MIME walk instead of shallow iter_attachments(),
which missed inline images nested in mixed>related>alternative (cid: rendered
broken); has_attachments counts only real (non-cid) attachments
- scripts/backfill_inline_attachments.py stores previously-missed inline images
Folder-name i18n (SPEC §12 M2):
- accent/punctuation-insensitive, multi-delimiter (/ \ .) special-use inference
across ~13 languages; curated names (no substring false positives)
Link security (reading panel):
- links never navigate the message iframe: a parent-side click interceptor opens
http/https in a new tab (noopener) and mailto in the composer (recipient
pre-filled); all other schemes blocked
Backend 292 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Search:
- Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7
- Search-as-you-type: debounced query + Postgres prefix matching, built from
the DB tokenizer so full email addresses match (email stays one lexeme)
- Saved searches API + sidebar virtual mailboxes; save affordance in the box
Account sync windows (SPEC §12 M2):
- Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only
- Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run)
- Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid
- migration 0012: accounts.sync_window + backfill_total/backfill_done
Backfill progress UX:
- Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that
fills with the account colour; driven by sync.progress over WS
Thread list pagination:
- Server-side offset + accurate grand total, with filter/order in SQL; working
prev/next across all synced mail
Reading panel:
- "Load images" one-off now actually renders images (?images=1) without trusting
the sender; envelope carries images_allowed so a trusted sender shows no prompt
Threading:
- Subject fallback no longer merges messages that declare an explicit (different)
parent; true siblings still group via a shared-parent reference match. Fixes
unrelated auto-replies with the same subject landing in one thread
Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Backend:
- Collapse identical cross-account deliveries in a thread: new messages.delivery_id
(migration 0011) preserves the real Message-ID even when message_id_hdr is
synthesized (§7.1); get_thread groups by it and reports also_in_account_ids; the
list count counts distinct deliveries. Backfill script for existing rows.
Frontend:
- Bundle Lato (SIL OFL) as the UI font; unread mail-list rows use Black weight.
- Mail-list selection: hover-avatar checkbox, tri-state "select all", bulk
Archive/Pin/Delete (Restore/Delete in Archive/Trash/Spam) with a count in the
reading pane; unread blue dot; "also delivered to" note in the thread.
- Spacing/size tuning: sender-column width, icon gaps, avatar size, bar heights,
and a round of -1px font sizes across the sidebar, mail list, and reading pane.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Backend:
- Restore returns each message to its original folder (e.g. Sent/Archive) instead
of always the Inbox: new messages.restore_mailbox_id (migration 0010) +
restore_thread_job.
- Drafts: expunge reconciliation for the Drafts poll (provider.list_uids /
_reconcile_drafts) clears phantom "remote" drafts; send + discard drop the
mirrored DB row; adopting a remote draft carries its real attachments into the
compose flow.
- Thread detail/list are folder-aware: individually trashed/spam messages are
hidden outside their folder, Trash/Spam show only their own messages, and the
mail-list message count matches the view.
Frontend:
- Thread reading panel: fix newest-first card overlap/spacing (expand-all and
single expand), hide Reply/Forward on trashed messages, close the Actions menu
on message-body (iframe) clicks, scroll to the inline composer, and prompt
Save draft / Discard on the X close.
- Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP
mirrors, and close the From/More dropdowns on outside click.
- Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on
Sign out.
- Mail-standard printing in a new window with reliable page margins (lib/print.ts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Rework the conversation view to match the new dark-theme mockups:
- Subject bar gains a print button and an expand/collapse-all toggle (default
action expands all; collapsing leaves only the newest message open).
- Collapsed messages are compact single-line cards (40px avatar, colored sender
name, snippet, relative date) that overlap into a stack; alignment follows the
sort order (top edge for oldest-first, bottom edge for newest-first) and the
always-open newest card overlaps its neighbour in both orders.
- Expanded messages show sender+address (whole header row toggles collapse), a
detail disclosure (From/To/Cc/Subject/Date/Size), the message location
(Inbox/Sent), a read/unread circle (per-message flag toggle), a quick
Reply/Reply-all, and an Actions menu (reply/forward/print/delete). The
Reply/Reply all/Forward bar now sits inside the newest card above a divider.
- Message body: the quoted original is hidden by default with a "•••" toggle
injected after the "On … wrote:" attribution; the iframe now collapses-then-
measures so it shrinks back when the quote is hidden.
- Avatars use the sender's IMAP-account colour when the address matches one of
our accounts, else a stable per-sender hue; two initials for multi-word names.
- Brighter dark-theme border token; new print/expand-all/collapse-all icons.
Backend: add size_bytes to the message envelope (shown in message details).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend
- Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll
(Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX.
- Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr.
- Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge);
inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID.
- Per-user storage quota (5 GiB default) and manual "sync all folders" action.
- Account editing (display/color/server/password) and per-account HTML signature.
- Account view scoped to that account's inbox; new migrations 0006–0009.
Frontend
- Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh.
- Aligned + widened mail-list margins; reading-pane bars fixed height.
- Rich-text composer: color palette, highlight, image; attachments; expand/pop-out.
- Signatures: auto-insert for new mail, replace on From change, reply insert.
- Restore/Delete actions per folder; drafts view merges local + remote, date-sorted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>