Invitations received by email (iMIP): above the message body, a card with
when, what, where (or "Join video call"), the organizer, what else is in the
calendar then (a conflict, else the previous event that day), and Yes / No /
Maybe. Yes and Maybe put the event in the calendar (or update it), No takes
it out — and is still shown as the answer given; each sends the organizer
the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes.
A cancellation offers "Remove from calendar". It is the calendar plugin's
message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK
gains the user's own accounts and settings. An Outlook-style invitation sent
only as text/calendar is now kept as invite.ics too.
Calendar: clicking an empty day in the side panel's agenda opens a new event
on that date; a video-call link can be removed (✕ beside Copy); the page
guests see after answering, and our invitations' footer, say Ultimo Calendar
(with the logo).
Attachments: videos (mp4, mov, webm, …) play in the full-screen preview,
paused until played; attachments are now served from disk with HTTP Range,
so a video starts at once and can be seeked.
People tab: everyone in the thread, the user included, A–Z, under the name
any message gives them. Initials circles share one colour rule in the list,
the reading pane and the People tab. The contact view drops its duplicate
"Message" button. A general corner toast (webmail:toast) joins Undo's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
<title> is not an allowed tag, so nh3 unwrapped it and kept its text —
"mailer", "Email template" or the subject rendered before the content.
It is now dropped with its content, both in the sanitized HTML and in the
text derived from HTML-only mail (snippet and search). SANITIZER_VERSION
goes to 4 so stored bodies re-sanitize from the raw blob on next render.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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>
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
- concurrency.loop_semaphore: per-event-loop semaphores (safe across pytest loops
and process restarts via a WeakKeyDictionary keyed by the running loop).
- provider: holds a per-account (≤4) and per-host (PER_HOST_CONN_CAP) slot for the
whole lifetime of an IMAP socket, released on close; SMTP send takes a per-host
slot for its duration. A worker can no longer open hundreds of sockets to one
host/account.
- indexer: parsing goes through a PARSE_CONCURRENCY semaphore, and a message over
STREAM_PARSE_MB is parsed from its blob file (parse_message_file) instead of an
in-memory tree, so peak RAM ~= PARSE_CONCURRENCY x STREAM_PARSE_MB regardless of
mailbox sizes. Backfill parallelism stays bounded by the bulk queue only.
- config: PER_HOST_CONN_CAP, PARSE_CONCURRENCY, STREAM_PARSE_MB.
- tests: per-account cap blocks the 5th connection / distinct accounts independent;
file-vs-bytes parse equivalence + large-message streaming routing.
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>