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>
Saving invalidated only the ["accounts"] list, not the ["account", id]
detail the editors read. Reopening rendered the stale cached detail, and
the rich-text editor seeds its content only once on mount, so the refetch
that followed never reached the screen. Both the signature and the
account editors now write the PATCH response into the detail cache.
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
In the spam folder the menu showed a disabled "Marked as spam" for any message
the user had marked, hiding the one action that corrects the mistake. The
already-taught check ran before the folder check, so it suppressed both
directions rather than the one already taken.
The folder now decides what is on offer first, and only the lesson already
taught is suppressed — never its opposite. A message in the spam folder always
offers "Mark as not spam", whatever it was taught.
Reported from the running app: "Klaudia, czy wpisujemy Cię na listę obecnych?"
(folder=junk, learned=spam) showed the disabled state, while two provider-filed
messages beside it with no verdict row behaved correctly.
Adds a vitest for the bundle's decision table. It is small but has now been
wrong twice, and the mount contract makes these bundles directly drivable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
"Mark as Spam" moved the message but left it on screen. Two causes, both fixed
generally rather than for this one action.
`move_job` published message.changed without a thread_id, so the WS handler
invalidated the message list but never the thread the user was reading — the
panel had no reason to refetch. It now names the thread.
And a thread whose last visible message has gone should not linger. MailLayout
closes the panel when the open thread comes back empty, whoever emptied it —
core action or plugin.
`move_to_special` now also publishes the move immediately rather than waiting
for the IMAP job, since a plugin acting on the user's behalf has no client-side
handle to update optimistically the way the core endpoint's caller does.
That exposed an ordering bug: the optimistic event reaches the passive-learning
hook, which would re-teach the lesson /learn had just taught. /learn now records
`learned` before moving, so the hook's dedup stands down — and records it with
an upsert, because provider-filed spam has no verdict row of ours to update.
One real mistake on the way: the event first reported the destination folder as
the source, which would have made every move look like a no-op to anything
reading the pair.
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
`.wm-card` had no margin, so consecutive cards sat border-to-border and read as
one box — visible in antispam's settings (score / accounts / allow / refuse) and
in the filters plugin for the same reason.
Adjacent-sibling rather than a blanket margin: a card standing alone, like the
antispam banner inside a message, should not gain spacing its host did not ask
for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
Plugin settings pages were stacked at the bottom of Settings -> Accounts, which
put antispam's thresholds and lists under a heading about mail accounts. Each
`settings.page` now gets its own entry in the Settings nav: "Antispam filtering"
and "Mail filters".
Slot gains an optional `title` for this — the host needs a label when it gives a
slot an area of its own, and falls back to the plugin name. PluginSlot gains an
optional `plugin` filter so a host can render one plugin's bundle rather than
every binding for a slot name.
Wording: the app calls that folder Spam (i18n `nav.spam`), so the plugin's copy
now does too — "filed into Spam", "Move to Spam", "marked as spam". The IMAP
special-use stays `junk`, which is what the protocol calls it; only what the
user reads changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
Three strands that only make sense together, so they land together.
Registration gate (migration 0016). POST /auth/register was open to anyone
who could reach the port. Now the first account on an empty instance is
always allowed, becomes the administrator, and closes registration behind
itself; after that it takes an admin reopening it. The decision is made
under a row lock on app_settings so two simultaneous first signups cannot
both see an empty table and both claim administrator. Existing installs
keep their oldest account as admin and start closed.
Instance default quota (migration 0017). users.quota_bytes is snapshotted
at creation from app_settings.default_quota_bytes, so changing the default
affects later accounts only and never resizes a mailbox that exists.
The panel is a separate app, not a page in the mail client. admin.html is
its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the
vendor chunk), with its own administrator sign-in. One nginx now serves two
listeners: :80 is the mail client and 404s both /admin.html and
/api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT
and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not
process isolation — CurrentAdmin still guards every admin route — so
ADMIN_PORT must be restricted to the LAN, which the runbook now covers for
both DSM and QNAP.
Also QNAP: deploy/webmail.sh finds Container Station's docker under
/share/*/.qpkg/container-station/bin, which is off an SSH session's PATH,
before anything shells out to it. docs/nas-install.md gains a QuTS hero
runbook (§2.6), splits the DSM firewall section into container-to-container
(§8.1) and the egress case that makes account-add time out while
autodiscovery appears to work (§8.2), and scopes both to Synology.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
Committed on request as part of "commit everything". This looks like an
editor save-a-copy artefact from 2026-07-26 rather than an intended asset:
nothing references the filename, and it differs from the document.svg it sits
beside (1426 vs 420 bytes). Safe to revert this commit alone if so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
CalDAV account connect (Settings → Calendars) with RFC 6764 discovery,
per-calendar vaulted credentials, and full two-way sync verified live
against NextCloud:
- caldav.py: discovery, calendar-query REPORT (pull), PUT/DELETE with
quoted ETags (If-Match/If-None-Match), iCloud partition-redirect
handling, ICS build/parse; clear error codes (auth/private-host/
unreachable) surfaced to the connect UI.
- sync.py: ctag-gated pull + push of local changes; dirty rows protected
from being clobbered; 15-min cron + immediate enqueue on every
create/edit/delete so changes reach the server in ~1s.
- Dirty tracking via a BEFORE INSERT/UPDATE trigger (migration 0007),
suppressed during sync with a txn-local GUC; deletes tombstone then
DELETE server-side. Writability now driven by server privileges.
- migration 0006: event uniqueness NULLS NOT DISTINCT — fixes duplicate
events on every full re-pull (NULL recurrence_id never matched ON CONFLICT).
- 5 CalDAV parsing unit tests.
Calendar UI (page.js): all-day events now render in Day/3-days/Week views
(were month-only). They share the timed-event column layout so overlapping
events sit side-by-side, fill the day column vertically with a sticky label,
and are matched by start day (fixes UTC-midnight events spilling into the
next local day).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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
Implements SPEC v1.5 §6.2.1–6.2.3 so Gmail (and Yahoo/iCloud/Outlook-personal)
onboard via a provider app password on the existing plain-password sync path —
no OAuth, no new sync code. Gmail was oauth2-only with OAuth unimplemented, so it
was previously un-addable.
Backend:
- presets.yaml: each preset declares an ordered `auth_strategy`; Gmail becomes
`auth: password` + `[app_password, oauth2]`. App-password providers ship a
data-driven `help:` block (deep_link, app_name_hint, i18n step keys, docs_url).
- discovery/types: CandidateConfig carries `auth_strategy` + `HelpBlock`; presets
load them and autoconfig candidates derive a default from the detected auth.
- schemas/api: CandidateOut exposes auth_strategy + help; DiscoverResponse adds
`google_oauth_enabled`.
- config: GOOGLE_OAUTH_ENABLED flag (default false) gates the OAuth fallback.
Frontend:
- ProviderCredentialPanel (wizard step 1.5): deep-link popup (user gesture),
rendered steps, single paste field that strips whitespace, instant verify
(auto POST /accounts/test at 16 chars → Connected ✓ / mapped error), and the
auth_failed recovery branch with a gated "Sign in with Google instead" button.
- AccountWizard routes app-password providers to the panel, then a details step.
- i18n keys (en) for the panel + provider steps.
Honors the §6.2.2 prohibitions: no page automation, no real-password capture,
no scraping — deep-link + guide + paste + test only. Adds the v1.5 spec.
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
Contacts plugin:
- Detail pane now renders ALL populated fields (job/company subtitle, every email,
phones, addresses, websites, nickname, birthday, notes) — not just the email.
- Company fallback for the display name: personal name → company → email, applied at
read time so existing contacts whose name_display holds only the email now show the
company without a re-save; name_display no longer stores emails going forward.
- Search box in the list top bar (like Calendar): debounced, matches name/email/company,
keeps focus + caret across re-renders.
- Mail-style selection: hovering a row's avatar reveals a square checkbox; once anything
is checked every row shows one (select mode), the bar "select all" matches and aligns
with the row checkboxes, and the detail pane shows "N contact(s) selected".
- Delete contact now confirms via a modal.
- Empty reading pane shows contacts-illustration.svg (served from the plugin's ui/ dir),
mirroring Mail's email-illustration empty state.
Calendar:
- Invite from a contact opens /p/calendar?guest=<email>, which pops a new-event form with
that guest already added, then strips the param.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
Calendar settings (Settings > Calendars):
- Reminders are now {method, minutes}: Add-reminder opens a boxed form with a
method dropdown (Show an alert / Email me) + time dropdown, bell icons.
Backend normalizes the legacy list[int] transparently.
- New Invitations section (auto-add emailed events): policy radios, "Add new
events from" (My contacts / Anyone), "Add to calendar", mark-read+file toggle.
Persisted to user settings (inbound iMIP processing still deferred).
- Time zone options show GMT codes, e.g. "Central European Summer Time (GMT+2)".
- Default calendar now defaults to "Personal" instead of a generic placeholder.
- Colour picker is a circular-swatch-only dropdown (sized so swatches don't overlap).
- Delete calendar / remove subscription now confirm via a modal.
- Create form: dropped the breadcrumb; heading stays "Create calendar" even after
a Publish toggle auto-creates the calendar.
Publish links: replaced the 244-char vault-sealed token with a 16-char random
slug (secrets.token_urlsafe). New sdk.public_session() (privileged, non-RLS) lets
the public /public/{token} feed resolve the slug directly with explicit filters.
Settings shell: "<- Mail" is now a context-aware "<- Back" via ?from=; Calendar
opens Settings with from=/p/calendar so Back returns to the calendar.
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>