Commit Graph
100 Commits
Author SHA1 Message Date
lukasandClaude Opus 5.5 f82c7eb444 Loading splash: animated logo over a dark overlay on desktop
index.html carries the overlay markup and styles inline so it paints before
any script; public/splash.js (external, for the CSP) plays the logo
animation from icons/ultimo-splash.html, holds, fades out after ~3.25 s and
removes itself. Skipped at phone sizes (same query as useIsMobile) and in
pop-out compose windows; a click skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:23:56 +01:00
lukasandClaude Opus 5.5 05db376b89 Updated logo, carried to every derived icon
New ultimo-logo and signet SVGs, re-rendered into the PWA icons (any and
maskable), apple-touch icon, favicon, the public and calendar-plugin
signet copies, the BIMI Tiny-PS logo and the system-email logo PNG, each
at its previous size, placement and background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:13:15 +01:00
lukasandClaude Opus 5.5 77e9c25353 Admin: allow changing your own quota
The self-protection guard refused every change to the admin's own account.
It now only refuses blocking and deleting yourself; a quota change is fine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:33 +01:00
lukasandClaude Opus 5.5 a4581ab199 Interface size setting: Default, +10%, +20%
Mailbox display and Settings → Appearance get an interface-size picker,
stored per device in localStorage and applied as CSS zoom on <html>.
Viewport-height layouts divide by --ui-zoom, and code that turns mouse
coordinates or bounding rects into CSS lengths (resizers, compose dock,
emoji picker, editor popups and image handles, swipe-to-delete) divides
by the zoom factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00
lukasandClaude Opus 5.5 d8e6cca66b Spam banner: inset card, red title, plain-language reasons, translated
The banner sits inside the message body's 16px gutter with room below,
"Looks like spam!" is red, and the rspamd symbols are replaced by up to
three human-readable reasons (neutral symbols are hidden). All strings go
through ctx.t with English and Polish catalog entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00
lukasandClaude Opus 5.5 b122276958 Antispam: online checks no longer switch off Bayes; replies teach ham
- The "online reputation checks" rspamd profile used groups_enabled,
  which means ONLY those groups: with the switch on, a message got the
  RBL lookups alone (1 rule instead of 19, score 0.00) and Bayes never
  ran, so every spam lesson was ignored. It now disables nothing.
- A reply or reply-all teaches the answered message as ham (not
  forwards or new mail, not one's own Sent/Drafts, never over an
  explicit spam mark; once per message). message.sent now carries the
  draft's kind and reply_to_message_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:14:05 +02:00
lukasandClaude Opus 5.5 1aa0673720 Phone reading pane: icon-only top bar, roomier header; dark app icon
- Top bar: icons only (names kept for screen readers and as tooltips),
  20% larger, twice the room between them; Archive 2 px smaller
- Subject wraps to two lines; subject-bar icons twice as far apart
- Reply / Translate / Actions on their own row with more room around
  it; the ▼ for the full header can no longer be squeezed out, and the
  full header spans the card's width
- Maskable app icons on the app's dark grey, so Android's launch screen
  shows the logo without a white circle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:59:56 +02:00
lukasandClaude Opus 5.5 21cfaa07ed Translator: languages that fail to download are tried again
The first start tried the default languages once; a DNS failure at that
moment left them missing for good. Languages asked for are now kept as
pending in state.json and retried in the background (30 s, doubling to
15 min) until they are in, across restarts; an install from the admin
panel joins the same queue. A state from before this, with nothing
installed, counts as an unfinished first start. The admin panel shows
what is waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:38 +02:00
lukasandClaude Opus 5.5 972e10876b Translation on Firefox Translations models; new logo
- Email translation moves from LibreTranslate to our own translator
  service (translator/): Mozilla's Firefox Translations models on the
  Bergamot engine, fastText language detection (176 languages). Polish,
  English, German, French, Spanish and Italian on first start (~350 MB
  instead of ~10 GB); Admin panel > Email translation installs and
  removes languages. Built and pushed as the `translator` image.
- Every message is translated paragraph by paragraph as plain text;
  names travel with their sentence and are kept only where the model
  changed them; a sentence start the model copied ("Enclosed") is tried
  again in lower case; list markers are not sent
- The Translate button shows only for installed languages
- New logo: favicon, app/maskable/iPhone icons, notification badge,
  email logo and calendar copy regenerated; BIMI logo rebuilt in SVG
  Tiny PS with its gradients; masks render in browsers
- Launch screen background is the app's dark grey

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:04:04 +02:00
lukasandClaude Opus 5.5 8a1b9979e9 Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:00:31 +02:00
lukasandClaude Opus 5.5 463f96ad57 Invite a friend; handle field shows the domain; system mail fixes
- Invitations: each user can invite two friends by email (sign-up works
  through the link even while registration is closed); admin-editable
  template, subject and sender.
- Invitation sender defaults to the system-mail From address, which the
  SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused
  with 553 when the two differ).
- Sign-in, sign-up and forgot-password fields show the fixed
  @domain after the typed name; the registration status reports it.
- System mail gets a Date header; the invitation's plain-text part keeps
  its paragraphs and line breaks.
- SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name.
- DNS recommendations for the mail domains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:34:31 +02:00
lukasandClaude Opus 5.5 addb2232a1 Security hardening from the assessment; testing fixes
Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:08:06 +02:00
lukasandClaude Opus 5.5 30b806e7da Phone swipe-to-delete survives a refresh or folder change
A swipe's delete waited out the Undo window in a page timer: a refresh lost
it, and changing folder un-hid the row. Pending swipes are now remembered in
the browser and sent on the next load (plus a keepalive request as the page
closes), and swiped rows stay hidden in every folder until sent or undone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:46:19 +02:00
lukasandClaude Opus 5.5 b88855931e M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
  directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)

Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
  swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
  UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings

Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:25:10 +02:00
lukasandClaude Opus 5.5 d45fbf2c44 M6: network and DNS plan for hosted mail on the QNAP
Hosting decided (spec App. D §D.1 option A): Stalwart on the QNAP, receiving and
sending directly; addresses @ultimo.email; mail apps allowed with app
passwords; mail host mail.ultimo.email. docs/mail-server-network.md records the
connection checks (clean IP, outbound 25 open, generic reverse DNS to change),
the router / Synology / QNAP port layout, container ports, TLS certificate,
the DNS records to set at home.pl, moving mail from home.pl, and checks after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:40:26 +02:00
lukasandClaude Opus 5.5 84d2d24c7d Calendar panel: event dots in the mini calendar, bigger arrows on phones
The side panel's mini calendar marks each day with events with a small dot under
the number (white in dark mode, the text colour in light; white on today's blue
circle), following the calendars filter. The dots come from the agenda's own
events — the panel now loads its month's six mini-calendar weeks once for both;
a mini calendar paged to another month fetches that month once.

The month arrows are drawn icons, centred with the today circle (the text
glyphs sat lower); on phones they are 50% larger with 5px more between the
three buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:40:26 +02:00
lukasandClaude Opus 5.5 53fd603e21 Phone: the side panel takes the full screen width
The Calendar / Contacts / conversation side panel on a phone filled 88% of the
width (at most 420px); it now covers the whole screen, still sliding in from
the right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:57:28 +02:00
lukasandClaude Opus 5.5 aa617a3316 Calendar reminders open the event; phones get the Calendar panel
A reminder notification links to its day, event and occurrence
(/p/calendar?date=…&event=…&start=…). The calendar opens on that day with the
event's details showing — the full calendar on a computer; on a phone any
calendar link leads to Mail with the Calendar panel slid in (the big-screen
page never opens there), scrolled to the event, details open.

The panel's agenda kept no scroll position across re-renders: opening an
event's details lower down jumped the list back to the top. It stays put now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:07:40 +02:00
lukasandClaude Opus 5.5 0e3793a4d6 App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS
App passwords (SPEC App. D §D.5): one generated password per app, shown once,
revocable on its own, revoked on a password reset; never accepted by the web
sign-in. Settings → Security → App passwords (migration 0024).

CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with
/.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks,
throttled failures). The calendar and contacts plugins serve their own tables as
collections (Plugin(dav=...)):
- calendars as iCalendar: series with overrides, time zones, all-day, attendees
  and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars;
  subscriptions read-only; writes to upstream-synced calendars flow on upstream;
- address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES;
- what a client stores is served back verbatim until the web app edits it;
- sync tokens / CTags from change logs filled by triggers (calendar0008,
  contacts0002), so every write path counts; trimmed nightly after 90 days.
REPORTs: calendar-query (time range), calendar-/addressbook-multiget,
addressbook-query, sync-collection. nginx and the Vite proxy route /dav/.
Settings shows the server and user name to enter; install guide §5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00
lukasandClaude Opus 5.5 fa552392c8 Calendar reminders as push notifications
A job every minute finds the event reminders that fell due — repeating events
and moved occurrences expanded as the calendar view does — and pushes each
once to every device with notifications on (sdk.push, new: plugins can notify
through the new-mail push delivery). A reminder missed while the worker was down
is still sent within 10 minutes; cancelled events and declined invitations stay
quiet. Title, then "In 15 minutes, 14:00 · Room 2" in the user's zone and clock
format; it stays until dismissed and shows even with the app open; clicking it
opens that day (/p/calendar?date=…, new deep link).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00
lukasandClaude Opus 5.5 9611aedfcd Phone menu: Calendar and Contacts slide in from the right
The phone's left menu gets Calendar and Contacts (above the divider, when the
plugins are installed). Each opens the desktop's side panel on its own, as a
sheet from the right: the calendar panel (mini month, agenda, new event), or
the people panel starting on All contacts — a title instead of the tabs, and no
"In conversation" source without a conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00
lukasandClaude Opus 5.5 f44c7b9e7c Readable white-on-white mail; forwards carry the original's header
A message written in a dark-mode mail app (or pasted from a dark page) can
set all its text white with no background — invisible on the reading pane's
white page. After a message renders, text whose colour is practically the
colour behind it gets the pane's normal text colour, when a good part of the
message (30% of its text) is invisible. Text hidden on purpose — a help
desk's white ticket reference, a newsletter's hidden preview line — stays
hidden; white on dark, coloured or pictured backgrounds is left as designed
(lib/readability.ts, with tests). The check runs before the quote is folded.

A forward quoted the original under the reply's one line ("On …, Name
wrote:"), without the sender's address. It now opens with the usual block:
Forwarded message, From (name and address), Date, Subject, To, Cc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:36:47 +02:00
lukasandClaude Opus 5.5 5bf8fc81f6 Search: all results with a real count; bulk actions on results stay done
Search showed at most the first 50 matching messages ("1–50 of 50"). The
first page now carries the number of matching conversations, further pages
load as the list is paged through (a conversation spanning two appears once;
oldest-first loads them all, up to 40 pages), and the Unread/Pinned tabs filter
on the server, so their count covers every result.

Bulk Delete / Archive / Restore over selected results acts at once: the rows
leave the list (greyed instead when archived from a search, which still finds
them), the search results are refreshed, and the reading pane returns to
"Select a conversation". Each move landing on the server refreshes them again,
and a hidden row only comes back when no move has landed for 30 seconds — a
long batch no longer brings deleted results back.

Settings → Accounts → Notifications: shorter description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:15:06 +02:00
lukasandClaude Opus 5.5 9c08cbb140 Offline: newest 100 conversations; notifications no longer wait silently
Offline access keeps the newest 100 Inbox conversations (the list's first two
pages, saved as the app asks for them) plus recently opened ones, up to 130.

Turning notifications on in a browser that holds the permission request
without showing it (Opera, Chrome's quiet prompts) said nothing and never
finished. After a few seconds it now says how to allow notifications for the
site, and carries on by itself as soon as they are allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 3957dd540a Attachments stored once; nightly clean-up of unused files
Attachments are no longer stored apart from their message (they were kept
twice: base64 inside the raw message and decoded on their own). The indexer
records the MIME part (attachments.part_index, migration 0023); downloads,
zip, inline images, thumbnails and forwarding take it out of the message;
videos stream from a short-lived copy (kept a day, 1 GiB per user). A
background pass on worker start removes the copies stored before, each only
after checking it can be taken out again (spec App. D §D.6 item 1, criterion
corrected to <= 60%).

A nightly job (03:47, after the Trash/Spam purge) removes files nothing
refers to any more — deleted or purged messages, previews of attachments
gone, uploads no draft has used for a week, expired streaming copies. Files
younger than an hour are left alone; a sent message waiting for its Sent copy
is held until its job is done. Account removal keeps files a draft still uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 0514053252 Messages size at once; quoted inline images travel inside the reply
The message frame is set up as soon as its document is parsed instead of at
its load event, which waits for every image (remote ones included): it no
longer sits at its first height until the slowest image arrives.

A reply quoted an original's inline (cid:) images as this server's own URLs,
and they were sent like that — broken for the recipient, and mangled copies
("http:///api/…") made browsers look up a host named "api". Outgoing mail
(and the IMAP Drafts copy) now carries them as inline parts with new
Content-IDs; one that can no longer be had is left out. Shown messages load
such URLs from this server (sanitizer v5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 d0caf76b03 Replies: cursor on the first line, own messages answered to their recipients
A new reply opens with the caret on the empty first line above the quoted
original (on a phone the keyboard comes up with it).

Replying to one of your own messages goes to the people it was sent to
(reply all keeps its Cc), not back to you; your own addresses, under every
account, are never among reply-all recipients; a note sent only to yourself
still answers yourself. The per-message Reply / Reply all button counts
recipients the same way (lib/replyTargets.ts, with tests).

The ThreadView test suite runs again: jsdom gets a stand-in Worker (pdf.js
sets one up at import).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 843b48cd59 Phone: Settings in the menu, sliding Settings layout
The left panel shows Settings on a phone again, with Install app moved above
the divider. On a phone, Settings is a full-width list of sections; the chosen
one slides in from the right over it and back out on ‹ Settings or the phone's
Back (each section is its own URL; opened directly, ‹ Settings goes to the
list). A section's own first heading is hidden there when it repeats the title
in the header. The account rows wrap on a narrow screen instead of running off
it. Desktop Settings is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:12:54 +02:00
lukasandClaude Opus 5.5 fda4cc6f16 Installable app, push notifications about new mail, Install app entry
The webmail is now a PWA: web app manifest, icons (any + maskable, a status-bar
badge), theme-coloured title bar following the chosen theme, and an app-icon
badge with the unread Inbox count. The service worker is always installed;
offline access is on only when it is registered as /sw.js?offline=1, so
turning it on or off is an update that applies at once.

Notifications (Settings → Accounts → Notifications, per device) use Web Push.
Encryption (RFC 8291) and VAPID signing (RFC 8292) are implemented in
app/push/webpush.py — pywebpush needs a newer cryptography than we pin — and
checked against the RFC's worked example. The server makes its key pair on
first use (private half encrypted with MASTER_KEY). New mail found by the live
sync in an Inbox enqueues a job 5 s later that tells the user's devices about
messages still unread there, under 2 hours old and not from the user; more
than three become one "N new messages". A subscription belongs to the session
it was made under: signed out or revoked, nothing more is sent there. Endpoints
are limited to the known push services (no SSRF). Nothing shows while the app
is in front (except on Apple, which requires it). Tested with real pushes
through Google's service in Chromium and Opera.

The mobile drawer gets "Install app": Chrome's own install dialog where the
browser offers one, else short steps for iPhone/iPad, Opera and Firefox, with
"Don't show this again". docs/nas-install.md §5.3 covers installing,
notifications, Public URL and a later Android app (TWA).

Migration 0022: push_subscriptions, app_settings.vapid_*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:52:26 +02:00
lukasandClaude Opus 5.5 0729dbb691 Spec: M6 hosted mailboxes (Stalwart) plan — Appendix D
The M6 placeholder becomes a plan: a Stalwart mailbox for every user at
their handle with the webmail password (SQL directory over users.password_hash,
master-user login, no password copying), added as a permanent account; hosting
options (NAS / NAS + relay / VPS); app passwords for 2FA users; ways to stop
storing mail twice; phases, acceptance criteria and the open decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:52:26 +02:00
lukasandClaude Opus 5.5 9f278626c0 Phone: swipe right to delete, Mailbox display with the dividing lines
On a phone, a conversation dragged to the right shows a red bin behind it;
let go past a third of the row and it goes to Trash, short of that it
springs back. Vertical drags still scroll (touch-action: pan-y), and a swipe
never opens the conversation. "Moved to Trash · Undo" shows for 5 s and the
move is made only after that, like Undo send — so Undo just shows the row
again; leaving the page still deletes. Not in Trash (it would be for good)
and not offline.

The ⋯ menu on a phone gains Mailbox display, holding only "Show a dividing
line between rows" (a phone has no layouts to choose).

The corner toast takes an optional action (label + run) and duration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:27:53 +02:00
lukasandClaude Opus 5.5 1982e8964b Calendar: cancel events for guests, apply organizers' cancellations, invitation settings
Deleting an event the user organizes, with other guests, asks "Would you like
to inform the participants that this event is canceled?" (Yes, inform them and
delete / No, just delete / Cancel; for a recurring one after which occurrences).
Yes sends each guest an iTIP CANCEL from the organizer's mailbox — of one
occurrence via RECURRENCE-ID — or, for "this and following", the shortened
series. Invitations now carry their RRULE, so recurring events repeat for
guests too.

An organizer's cancellation is applied as the email arrives (message.new hook)
or, for older mail, when the card is opened — always, whatever the settings:
only from that event's organizer and not older than the calendar's copy; one
occurrence becomes an EXDATE. The card says "Event was removed from calendar".

Settings → Calendars → Invitations now work, defaulting to "Do not
automatically update my calendar": "Only update events that I have already
added" applies the organizer's changes (keeping the user's answer), "Add new
events…" also adds new invitations, unanswered — from contacts or anyone — to
the chosen calendar, and "mark as read and file" archives such messages. With
no automatic updates, a changed invitation says so on its card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:50:17 +02:00
lukasandClaude Opus 5.5 2337375bb7 Event invitations in Mail, video previews, People tab, matching avatars
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>
2026-10-06 21:50:29 +02:00
lukasandClaude Opus 5.5 fba3c947bf Attachment previews, forwarded attachments, row dividers
Attachments in the reading pane are tiles with a preview: images and a PDF's
first page, rendered on the server (Pillow, pypdfium2) as WebP of at most
480 px, cached beside the blob store and in the browser. Tiles are all one
size — at most 230 px wide, narrower so three still fit in a row — and frame
each preview by its shape: close to the tile's it fills it, very tall / wide /
square ones show whole over a blurred copy, small ones are not enlarged, PDFs
show the top of the page. The name bar (checkbox, type, name, size, download)
slides in on hover and adapts to the tile's width; files without a preview
get a large type icon. Previews of opened conversations are kept offline.

Forward now carries the original's attachments: new compose attachments
sharing the stored blobs (POST /messages/{id}/forward-attachments).

Mailbox display / Settings → Appearance: "Show a dividing line between
rows" (off by default, per device), with a new switch. Settings drop the
"On a phone…" note, and "Language" reads "Spellchecking language".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:15:17 +02:00
lukasandClaude Opus 5.5 bfe011cd2c Fix: IMAP over SSL connected without TLS; never lose a sent message's copy
v1.3.26's IMAP4_SSL subclass put its create_client ahead of aioimaplib's,
which is where the TLS context is made — so port 993 got plain TCP, the
server waited for a handshake, and every SSL account timed out ("Can't
reach the mail server"). The context is now made in our create_client too,
and a test checks an SSL client really gets one (it fails without the fix).

With IMAP down, a send could still be delivered by SMTP while filing its copy
in Sent failed — the send then counted as done and the copy was lost. The
copy is now kept and filed by file_sent_copy_job, retried (arq Retry) for
about 24 hours, then shown in Sent as usual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:24:23 +02:00
lukasandClaude Opus 5.5 609e7c58f9 Unreachable mail servers: send retries in Scheduled, real action retries, account health
Sending: a send the server could not take (unreachable, timeout, 4xx) is no
longer failed at once. It waits in Scheduled ("retrying") and is tried again
after 1, 2, 5, 10, 30 min, then hourly, for 24 hours — on the server, so it
works with the browser closed. A permanent refusal (5xx, wrong password) is
"failed" at once, with the server's reason. GET /outbox lists both;
POST /outbox/{id}/retry (Retry now) and /cancel (Edit, Delete) act on them,
and they are left out of Drafts. Mail delivered but not filed in Sent now
counts as sent, and a conditional claim keeps two jobs from sending one
message twice. The app says when a send failed and when it finally went.

Actions: arq retries a job only on Retry, never on a plain exception, so the
"retry x5, then revert" for flags and moves never happened. Flag, move,
archive, restore, delete and empty-folder jobs now raise Retry while the
server is unreachable (~24 hours of growing gaps), skip a change a later one
replaced, stop at the first connection failure instead of 20 s per message,
and delete/empty keep the rows until the server has the messages gone.

Account health: 3 failed connections in a row mark an account unreachable
(amber in the sidebar and Settings, cleared on the next success); 3 refused
sign-ins stop its syncing as auth_failed until the password is entered
again, so the provider does not lock the account. Sync loops back off 30 s
doubling to 10 min and log one line per expected failure; a refused
connection fails at once with its reason (the connect task is kept), and a
silently dead IDLE connection is noticed within ~11 min instead of ~29.

Also: the empty reading pane is clipped to its width when dragged very
narrow; tests no longer leave live accounts and outbox rows in the database.

Migration 0021: outbox attempts / next_attempt_at / retry_until, accounts
sync_error / sync_error_since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:08:42 +02:00
lukasandClaude Opus 5.5 e1bda37188 Offline compose: write mail offline, Scheduled folder, undo countdown fix
Compose, Reply and Forward work offline. Drafts are kept on the device
(IndexedDB, lib/scheduled.ts), with files attached offline; Send becomes
"Schedule" and puts the message in a new Scheduled folder. When the server
is reachable again, scheduled messages are sent through the normal outbox
(Undo offered for a single one) and offline drafts become server drafts —
files uploaded first, each step recorded so nothing is created or sent
twice, under a Web Lock so two tabs never send the same message. Drafts
written offline show in Drafts as "On this device". Sign-out sends what
is scheduled, then wipes the device's copy.

Also: offline failures from keyboard shortcuts show "Not available
offline"; no recipient suggestions are asked for offline; a reply no
longer quotes an error page when the original is not saved.

The undo countdown counted to the server's deadline on the browser's
clock, so a few seconds of skew showed (18 s instead of 10). /send now
also returns undo_seconds, and the composer counts down from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:11:41 +02:00
lukasandClaude Opus 5.5 c9c24e7e2f Offline access: self-updating worker, quiet console, setting in Accounts
- The page asks the service worker for its version before using it, and
  has the browser fetch a newer one when it is out of date; the app also
  checks for a new sw.js on every start. An older worker left over from a
  previous release silently ignored the page's requests, so saving only
  resumed after offline access was switched off and on.
- Fewer console errors offline: the server probe goes through the worker;
  fonts, the illustration, lazy chunks and the PDF worker (named inside
  the app's scripts and styles) are saved with the app; sender pictures
  are pre-loaded, and an unsaved one is "no picture" (204), not a 503;
  plugin bundles and plugin lookups are saved (per-message ones go with
  their message); POST lookups and live-update reconnects wait for the
  server to be back instead of failing over and over.
- The setting moves from Settings → Security to Settings → Accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:45:37 +02:00
lukasandClaude Opus 5.5 01bfbb37d4 Offline access: save through the service worker, not the page's fetch
Pre-loading relied on the page being controlled by the service worker, so
its requests would pass through it and be saved. A page loaded with a hard
reload (Ctrl+Shift+R) never is: pre-loading then waited, gave up silently,
and nothing new was saved — the app still could not start offline, and
Settings kept showing the old sync time.

The page now asks the worker by message to fetch and save each URL, which
works however the page was loaded. Sign-out reaches the worker through its
registration for the same reason. Thread URLs come from shared helpers in
api.ts, so pre-loading saves exactly what the app asks for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:27:36 +02:00
lukasandClaude Opus 5.5 a60cd1fc33 Offline access: also save what the app needs to start
The app asks who is signed in, the accounts and the folders once, when it
loads — before the service worker exists if offline access was just turned
on. Without a reload those were never saved, so going offline ended on
"Can't reach the server". Pre-loading now fetches them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:17:53 +02:00
lukasandClaude Opus 5.5 a2c52f33a8 Offline access: read recent mail without a connection
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>
2026-10-05 18:54:17 +02:00
lukasandClaude Opus 5.5 139fff826e Deploy: LanguageTool spell checker in production, with optional n-grams
- docker-compose.yml: a `languagetool` service (erikvl87/languagetool:6.8),
  internal only, 1 GB heap, one CPU, a 28 GB memory limit that is room for the
  n-gram page cache (reclaimable, not memory in use), and a low-priority
  warm-up read of the n-gram files at start so first checks hit RAM even on
  mechanical disks. `init` creates $DATA_ROOT/languagetool-ngrams; the app
  containers get LANGUAGETOOL_URL (empty turns the check off).
- `webmail.sh ngrams [langs]` (default en de fr es): deploy/ngrams.py, run in
  the backend image, downloads LanguageTool's n-gram data with resume, checks
  free space first (unpacked is ~2x the zip: ~26 GB for all four), unpacks into
  a temp folder and moves it into place, then recreates the engine to load it.
  languagetool.org refuses Python's default User-Agent, so it sends its own.
- `doctor` reports whether the spell checker answers and which n-gram
  languages are installed.
- docs/nas-install.md and .env.prod.example describe all of it.

Verified locally: German n-grams (1.58 GB -> 3.06 GB) load, and catch what the
rules alone do not ("liegt ihm Schrank" -> im, "Der Patenten" -> Patienten).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:44:36 +02:00
lukasandClaude Opus 5.5 1f2e539eb2 Compose: spelling and grammar check with LanguageTool
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>
2026-10-04 23:51:31 +02:00
lukasandClaude Opus 5.5 b157c5c9bb Compose: a popup for links — Open / Edit / Delete — and for creating them
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>
2026-10-04 23:21:44 +02:00
lukasandClaude Opus 5.5 b1bdae6e59 Reading pane layouts: Side, Bottom (under the list) and Off (single panel)
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>
2026-10-03 18:13:43 +02:00
lukasandClaude Opus 5.5 befa1c2404 Antispam: lessons refused by the engine no longer pass for learned
Found on the NAS, where nothing had been learned in weeks while the UI said
"Marked as spam": the source tree predated v1.3.2, so api/worker-* had no
controller password and every lesson got 401 — and the trail of state that
left behind kept it broken after the password was fixed.

- /learn records a lesson only when rspamd accepted it. Recording refused ones
  made the menu claim "Marked as spam" and its dedup blocked every retry.
- Bootstrap: done-markers move to a new key. The old one may have been written
  by the "-0" bug (a total outage read as a clean run of zero), which left an
  account marked done with nothing taught and never retried; every older
  install re-runs the bootstrap once, harmlessly (rspamd skips what it knows).
- "Learn from my Spam folder now" in Settings > Antispam filtering queues the
  bootstrap for the user's accounts at once, instead of the hourly sweep.
- webmail.sh update now recreates rspamd: it reads the controller password only
  when created, so a changed .env reached the app but never the engine.
- doctor's advice for the 401 names both real causes (a pre-v1.3.2 compose
  file, or an rspamd started with an old password) and the fix for each; the
  old "update v1.3.2" advice could not help.
- docs/nas-install.md: how to refresh the source tree on a tarball install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:25:14 +02:00
lukasandClaude Opus 5.5 165442d897 Logo in system mail; "don't ask for the 2FA code on this device"
System mail: the password-reset email (and the admin panel's test email) now
opens with the Ultimo logo, centered. It is a PNG rendered from
icons/ultimo-logo.svg and embedded in the message (multipart/related, cid:),
so it shows where remote images are blocked and in clients that do not
render SVG. The PNG is listed in package-data so the Docker image carries it.

Trusted devices: a 2FA sign-in offers "Don't ask for the code on this device
in future". Ticked with a correct code, the browser gets its own HttpOnly
trusted-device cookie (token in Redis, 90 days, renewed on use); later
sign-ins to the same account from that browser need only the password. Other
accounts and browsers are unaffected. The trust is revoked on a password
reset, when 2FA is turned off, and when an admin blocks or deletes the user;
signing out keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:02:44 +02:00
lukasandClaude Opus 5.5 d1f6d183e0 Serve the BIMI logo, favicon.ico and an apple-touch icon as real files
ultimo.email is the webmail itself, so any path that was not a real file fell
back to the app's index.html: /bimi/ultimo-logo.svg (the BIMI record's logo)
and /favicon.ico both came back as the webmail page. They now ship in
frontend/public, which nginx serves before the SPA fallback. The BIMI SVG
moves there from frontend/icons, so there is one copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:23:33 +02:00
lukasandClaude Opus 5.5 0ddc0cb001 Admin user management, password reset by email, and the Ultimo Email name
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>
2026-10-01 20:16:27 +02:00
lukasandClaude Opus 5.5 8dc60dbf5c Avatars: a sender without one logged a console error; phone list rows spaced 20px
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>
2026-09-30 21:35:26 +02:00
lukasandClaude Opus 5.5 0ddfc8c658 Calendar panel: a picked mini-calendar day showed nowhere; no new-tab link on a phone
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>
2026-09-30 21:23:10 +02:00
lukasandClaude Opus 5.5 a5b0842e6a Mail (phone): side panel as a drawer sliding in from the right
The reading view's toolbar gains the side-panel toggle on a phone too. It opens
Calendar/People/Attachments as a right-hand drawer over a dimmed backdrop,
closed by its X, the backdrop or Escape, and put away when the conversation
changes. It keeps its own open state so the desktop's remembered panel never
opens on a phone, and it mounts on first use only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:35:29 +02:00
lukasandClaude Opus 5.5 25f7265e55 Mail list: rows glide up into a removed row's place; subjects stopped blinking
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>
2026-09-30 20:16:56 +02:00
lukasandClaude Opus 5.5 8c463921f7 Mail list: a hidden row flickered back before its move landed
The server only queues a thread move, so the refetch right after the request
still carried the row, and un-hiding it then brought it back until the live
update removed it for good. A row now stays hidden until a fetched list no
longer contains it, with a 30 s fallback in case the move never lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:55:18 +02:00
lukasandClaude Opus 5.5 e9c36c47af Mail list: hide a row the moment its archive/delete button is clicked
The hover actions waited for the server before the list reacted. The row now
leaves the list at once and the request runs behind it; a failed request puts
it back. In search the row greys out immediately instead, since it may still
match the query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:48:12 +02:00
lukasandClaude Opus 5.5 6e65664766 Compose: close on Send and offer Undo from a toast; phone-size touch targets
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>
2026-09-30 19:37:54 +02:00
lukasandClaude Opus 5.5 fbca6cb728 Compose: typed text became a recipient without being an address
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>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 caed8543ea Avatars: a cached avatar or miss failed on every later request
The shared Redis client decodes replies as UTF-8 text, but the avatar cache
holds raw image bytes and a b"none" miss marker: an image could not be read
back at all, and a miss came back as str and crashed on .partition(b"\n").
Only a sender's first request worked; after that, a 500. The cache now goes
through a binary-safe client, get_redis_bytes().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 37bb6ef78d Compose: fold the quoted original behind a "•••" pill
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>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 fe3fd94165 Mail: a smartphone layout
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>
2026-09-28 08:09:09 +02:00
lukasandClaude Opus 5.5 cfc89e78b3 Mail list: cut a lone long sender name at 21 characters plus "..."
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>
2026-09-25 20:45:28 +02:00
lukasandClaude Opus 5.5 02011f23f6 Sanitizer: a newsletter's <title> showed as a line above the body
<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>
2026-09-25 20:35:42 +02:00
lukasandClaude Opus 5.5 fcafc6a026 Mail list: fall back to first names when the senders run long
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>
2026-09-25 20:35:42 +02:00
lukasandClaude Opus 5.5 08d428de8a Mail list: name everyone who wrote in the thread, and call the user "me"
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>
2026-09-25 20:17:37 +02:00
lukasandClaude Opus 5.5 901156526c Settings: reopening the signature editor showed the old signature
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>
2026-09-25 19:41:25 +02:00
lukasandClaude Opus 5 e1371c4f84 doctor: the antispam check printed nothing on the install it was for
It hid its own result behind an `elif ... ; fi` with no `else`, gated on
`docker compose ps --status running --services` — a flag older Compose
does not accept. On the QNAP the flag failed, the elif went false, and
the check produced no output whatsoever. A check that goes quiet when
something unexpected happens is the failure it was written to catch.

Drop the gate and classify the probe's own output instead: AUTHORISED,
a 401, a stopped stack, or anything else. Every branch prints, and the
"anything else" branch shows the raw tail rather than swallowing it.
Also say so when antispam is absent from ENABLED_PLUGINS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 22:28:58 +02:00
lukasandClaude Opus 5 31edd4015e Antispam: a dead engine marked the account bootstrapped anyway
`_teach` signalled failure by negating the count taught so far. That
works for every case but the one that matters: when the FIRST message is
refused nothing has been taught yet, and `-0` is `0` — not negative. The
caller's `< 0` test then read a total engine outage as a clean run of
zero, recorded `_bootstrapped` for the account, and the hourly sweep
skipped it from then on. The account most in need of a warm start was
the one guaranteed never to get one.

Which is precisely what happened alongside the missing controller
password: every learn was refused 401, and each account was quietly
retired on its first sweep.

Return None for "the engine stopped accepting these" so failure cannot
be confused with a count, and decline to mark an account whose samples
were all unreadable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 22:22:28 +02:00
lukasandClaude Opus 5 059fa0ade3 Antispam: the app could never authenticate to the learning engine
Rspamd answers on two ports and only one of them asks for a password:
the scan worker takes /checkv2 anonymously, the controller demands
`Password:` for /stat and /learnspam. `webmail.sh init` generates
RSPAMD_CONTROLLER_PASSWORD and docker-compose hands it to the rspamd
container — but the shared x-backend-env block that feeds api and
worker-* never carried it. The plugin reads it from the environment,
found "", and sent no header.

So classification worked and learning never did. Every learn returned
401 and the plugin swallowed it, as it is built to: an engine problem
must not lose mail. Bootstrap logged "engine unavailable", passive
learning from Inbox->Spam moves taught nothing, and the single visible
trace was `"bayes": null` on GET /api/plugins/antispam/stats, on an
install whose verdict counts were ticking up normally.

Verified against a live engine: with no password /stat and /learnspam
both 401 and rspamd.learn() returns False; with it, stats returns and
learn returns True.

  - pass RSPAMD_CONTROLLER_PASSWORD to the app containers, in both the
    production and dev compose files
  - doctor now makes the real call from inside the api container, with
    the password that container actually holds, and says plainly that
    nothing will be learned if it fails
  - a test reads both compose files, because this is a missing line that
    review already let through once

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 22:17:29 +02:00
lukasandClaude Opus 5 9f9e959a05 Compose: a dock of windows that survive the app around them
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
2026-09-21 21:32:41 +02:00
lukasandClaude Opus 5 e4a521ae20 Labels: show the new label after creating it
Creating a label while the section was collapsed hid the thing you had
just made — the row was added, behind a closed chevron.

Open the section on a successful create, and persist that the same way
the chevron does, so it does not snap shut on the next mount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 21:32:22 +02:00
lukasandClaude Opus 5 ea137c32c3 Mail list: a new page starts at the top
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
2026-09-21 21:32:15 +02:00
lukasandClaude Opus 5 c5742f19da Mail list: show the sender only, keep the participant count
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
2026-09-18 18:35:05 +02:00
lukas 428e81efa5 Revert "Mail list: show who wrote, not everyone involved"
This reverts commit 550b8cf503.
2026-09-18 18:31:16 +02:00
lukasandClaude Opus 5 550b8cf503 Mail list: show who wrote, not everyone involved
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
2026-09-18 18:27:42 +02:00
lukasandClaude Opus 5 e58a2f0ef4 Sync: a flag set on the server left the thread counters stale
Reported from the running app: a message showed as unread in the list — bold,
blue dot — while the reading pane showed it as read, and only a manual "mark as
read" reconciled them.

The two views read different things. The pane reads the message's own flags; the
list reads `threads.unread_count`, denormalized from them. `apply_delta` updated
message flags with a bare UPDATE and never recomputed the thread, so a flag set
anywhere but in this app — read on a phone, filed by a server-side rule — moved
one and not the other. Auto-mark-on-read could not fix it either: it sees a
message that is already \Seen and correctly does nothing, leaving the counter
wrong forever.

Expunges had the same hole, which also left `message_count` overstated.

`apply_delta` now collects the affected threads and re-denormalizes them after
the deletes, so a thread emptied by an expunge is removed rather than recounted.

Migration 0018 repairs rows that already drifted — recomputed from the messages,
so it is a no-op on a healthy database. One thread was stale here; installs that
have been syncing longer will have more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-18 18:21:30 +02:00
lukasandClaude Opus 5 de79b4ba07 Antispam: merge the duplicated sdk import
Two separate `from app.plugins.sdk import` lines tripped ruff's import sort
under backend's config but not the repo root's — only backend/ has a ruff
config, so plugins/ are checked with different rules. Merged; the underlying
config gap remains worth closing with a root ruff.toml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-18 17:55:05 +02:00
lukasandClaude Opus 5 8eb699a05f Antispam: bootstrap every account, not just the ones we happen to see go live
Warming Bayes hung on a single event — `account.state_changed` reaching "live".
That covers an account added from now on and nothing else. An account that went
live before this plugin existed never gets one; neither does one whose Junk
folder was empty at the time and filled up later, or one whose owner enabled
spam-folder sync afterwards. A worker restarting at the wrong moment loses it
too. The result was a feature that quietly required someone to run it by hand
per account, which is not a feature.

An hourly sweep now walks every live account and offers each a bootstrap. That
works because the per-account job was already cheap to repeat: it returns at once
for an account already done, and declines — without marking it done — when the
corpus is still too small to learn from. So the sweep simply keeps asking until
there is something worth learning, then does it once.

`s.accounts.live()` is the new SDK surface it needs: cross-user by design,
because a sweep runs for the instance rather than a request. Ids and state only,
so reading anything still goes through the user-scoped accessors.

One failing account cannot stop the sweep — the mailbox that raises is logged and
the rest still get their turn.

docs/plugins.md now says this in general terms next to `JobSpec`: a state-change
hook is one-shot, so pair it with a sweep when the work must eventually happen
for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 21:04:38 +02:00
lukasandClaude Opus 5 566f02a3f4 Deploy: the rspamd healthcheck probed with a tool the image does not have
`wget -qO- .../ping | grep -qi pong` exits 127 forever: the rspamd image ships
neither wget nor curl. The container therefore reported "unhealthy" from the
moment it started while answering /ping perfectly — and `webmail.sh up` waits on
health, so a working engine failed the bring-up.

`rspamc stat` instead, which ships with the engine and needs no password from
inside the container.

Worth noting what this did NOT break: nothing declares depends_on: rspamd and
the health gate deliberately ignores it, so mail kept flowing throughout. The
cost was a bring-up that looked failed and an alarming status in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:57:28 +02:00
lukasandClaude Opus 5 2251c7ca6d Deploy: set_env_value could not add a key, and said it had
`sed "s|^KEY=.*|KEY=value|"` only rewrites a line that already exists. A setting
introduced by a later release is absent from an older .env, so the substitution
matched nothing, reported success, and the value vanished — while
`ensure_secrets` printed "RSPAMD_CONTROLLER_PASSWORD was empty — generated one".

The v1.1.0 upgrade then ran with a blank controller password: every compose call
warned, the engine came up unauthenticated, and learning would have failed
against it. Classification was unaffected, which is exactly what made it quiet.

Now appends when the key is absent — ensuring the file ends in a newline first,
so the key cannot be glued onto the last line of a hand-edited .env — and
verifies the value is present afterwards rather than trusting sed's exit status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:52:21 +02:00
lukasandClaude Opus 5 b54ccbe653 Deploy: a release that adds a service must actually start it
Three things stood between v1.1.0 and a working anti-spam engine on an existing
install, each silent:

`update` rolled only worker-sync, worker-jobs, api and web. A release that adds
a service — this one adds two — would report success while never creating it.
It now converges the rest with `up -d --no-recreate`, which creates what is
absent and leaves the rolled services alone.

.env.prod.example pinned ENABLED_PLUGINS to the four plugins that existed when it
was written. The entrypoint's default includes antispam, but an explicit value in
.env wins, so migrate would skip the plugin's tables and every antispam request
would fail at runtime rather than at install. The template is fixed for new
installs; `doctor` now warns existing ones, comparing .env against the
entrypoint's default so there is still one list, not two.

And the runbook now documents what the engine costs (~150-300 MB), that nothing
depends on it by design, that `init` must be re-run for the controller password,
and that its Bayes database is derived data like the search index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:44:25 +02:00
lukasandClaude Opus 5 5aa133e18c Antispam: plugin docs, the learning acceptance test, and a bootstrap that refuses bad corpora
docs/plugins.md described an SDK that no longer exists. It now covers
messages.raw/sample, actions.move_to_special, the envelope's account_id /
mailbox_special_use / account_state, the message.changed transition payload, the
message.actions.menu slot, Slot.title and .wm-menu-item — with the reasons, not
just the signatures. M3's acceptance is that a plugin can be written from this
file alone, and it had drifted.

Acceptance 4 (§C.8): a test that teaches 20 spam + 20 ham to a real engine and
checks the classifier generalises to held-out mail, keeps ham under the banner
threshold, and does not leak between users. It skips when no engine is reachable.
It does NOT assert the literal move_threshold — a synthetic fixture cannot reach
12.0 once the SMTP-time symbols are zeroed, which is the whole point of the
tuning. Recorded as OQ-8 rather than quietly asserting something weaker.

Bayes ignores training until min_learns, stock 200. For a personal mailbox that
means every correction appears to be ignored for months, so it is now 20.

The bootstrap would have made this filter worse. Accounts do not sync their
provider's spam folder by default, so the real corpus here is 3 junk beside 1,294
aged inbox — teaching both as found trains "everything is ham". It now refuses
below 10 spam samples (leaving the account unmarked so a later run can do it
properly) and otherwise caps ham at 3x the spam count.

Run against the live account, it correctly declined:
  "only 3 spam samples (need 10); enable spam-folder sync to build a corpus"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:34:50 +02:00
lukasandClaude Opus 5 76beb9d527 Antispam: make the reputation switch real, limited to Spamhaus and DNSWL
The "Allow online reputation checks" setting did nothing. It saved a value, the
plugin sent a Settings-ID header, and the engine ignored it — the profiles that
header named were never written. Three separate faults had to be fixed before
one checkbox changed one score:

  - the profiles did not exist
  - a settings block is only selectable by header if it declares an `id`
  - a local.d file holds the CONTENTS of a module block, not the block; the
    outer `settings { }` wrapper nested them a level too deep and rspamd logged
    "loaded 0 elements of settings"

Proven rather than assumed, using Spamhaus's documented dbltest.com:
  webmail_offline -> no reputation symbols
  webmail_network -> DBL_SPAM fires

Which lookups exist at all is narrowed in rbl.conf to the three chosen:
Spamhaus ZEN (sending IP), Spamhaus DBL (domains in links), DNSWL (an
allow-list, which can only subtract score). Everything else is off — commercial
sender-reputation businesses, and anything that sends a fingerprint of the
message body to a third party. NiX Spam was dropped after checking: its zone
returns SERVFAIL from every public resolver, so it could only ever have added
failing lookups.

The URL blocklists are keyed by quoted uppercase names deeper in the stock
config and survived a first pass of this work; the comment says how to verify.

Help text now names the services and says what they learn, rather than vaguely
warning about "third-party servers".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:20:58 +02:00
lukasandClaude Opus 5 d5df1654ba Antispam: "Mark as not spam" must survive having marked it as spam
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
2026-09-17 19:51:31 +02:00
lukasandClaude Opus 5 8e3252679a Clear the reading panel when its last message leaves
"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
2026-09-17 19:47:42 +02:00
lukasandClaude Opus 5 c76b186b10 Antispam: "Mark as Spam" belongs in the Actions menu
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
2026-09-17 19:36:29 +02:00
lukasandClaude Opus 5 e420126145 Antispam: one button, and only where it means something
Spam / Not spam appeared on every message in every thread. "Not spam" on an
inbox message is nonsense — it was never filed as spam — and a pair of buttons
everywhere is noise that trains nobody.

The slot now shows a single action chosen by where the message actually is:
"Spam" in the inbox or archive, "Not spam" in the spam folder, nothing at all in
Sent, Drafts or Trash, where no move is a verdict about spam. A message already
taught says so instead of offering the same lesson again.

That needs the message's folder, which the slot was never told. Rather than a
core change to the slot props, /verdict/{id} becomes /context/{id} and returns
the folder alongside the verdict — one round-trip, answered for any message
including ones never classified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 19:01:46 +02:00
lukasandClaude Opus 5 149c11581e Antispam C2: learn from what the user does
Explicit teaching shipped with C1 (the Spam / Not spam buttons). This adds the
two halves that need no buttons at all.

Passive learning (§C.5). Moving mail into Spam says "this is spam" more honestly
than any button, because the user is doing it for their own reasons rather than
to train a filter; moving it back out says the opposite. Only those two
transitions teach — Spam to Trash is someone tidying up, Inbox to Archive says
nothing about spam. Teaching the same message the same thing twice is skipped,
since users move things back and forth, but reversing a lesson is allowed. If
the engine is down nothing is recorded, so a later move can teach it again.

This needed the transition, not just the destination: `message.changed` carried
only where a message landed, so `move_job` now also publishes
previous_mailbox_id and both special_use values.

Provider bootstrap (§C.5). An account going live enqueues a job that teaches
Bayes from mail the user already owns — up to 500 messages from the provider's
Junk folder as spam, and up to 500 Inbox messages older than 30 days as ham.
This is the constructive use of the history that classification deliberately
refuses to act on: the same backfill, read instead of judged. It sleeps between
messages so live classification never queues behind training, stops rather than
grinding if the engine disappears, and marks the account done in KV so it runs
once.

That needed `PluginMessages.sample`: the SDK could fetch a message by id but not
enumerate a folder. Ids only — bodies are pulled one at a time, so a 500-message
sample never materialises 500 bodies.

Verified against the running engine: teaching moved its `learned` counter 0 -> 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 08:36:51 +02:00
lukasandClaude Opus 5 8fc97e2f27 Plugin UI kit: stacked cards need air between them
`.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
2026-09-17 08:27:42 +02:00
lukasandClaude Opus 5 6414504f6e Settings: a section per plugin, and call the folder Spam
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
2026-09-17 08:19:11 +02:00
lukasandClaude Opus 5 fe471dbf01 Antispam: mirror the engine in dev, and never file a contact as spam
Dev compose gains rspamd + redis-rspamd, matching production. The dev API and
worker run on the host rather than in the compose network, so the ports are
published on loopback and RSPAMD_URL points at 127.0.0.1 — documented in
.env.example.

Contacts now cap a verdict at "banner": mail from someone in the user's address
book is never moved to Junk. Capped rather than exempted, because the way this
fails in practice is a contact's account being compromised and spamming its own
address book — the warning still has value even when the move would be wrong.
An explicit deny rule still wins; that is the user overruling us deliberately.

The lookup reads the contacts plugin's table directly and forgives its absence:
no contacts plugin means no contacts to check, not an error. Addresses are
stored as the user typed them, so both sides are lower-cased rather than relying
on the jsonb containment index, and secondary addresses in the emails array
count as well as the primary. It runs only when the verdict would otherwise be a
move, so the common path pays nothing.

Also fixes a test that asserted stats degrade "without an engine" by relying on
no engine being present — true until this commit. It now simulates the absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 08:09:24 +02:00
lukasandClaude Opus 5 dee1983b84 Antispam: never classify during backfill
message.new fires for imported history as well as new arrivals, and EventBus
awaits hooks inline. As written, adding an account would have scored every
message in its backfill — filing years-old inbox mail into Junk on the server,
and adding ~70 ms of serialized HTTP per message to the backfill loop (twelve
minutes on a 10k-message account). It is also when the filter is least able to
judge, because Bayes has learned nothing yet.

The envelope now carries account_state, and the hook returns unless it is
"live". The backfill window belongs to the C2 learning bootstrap (§C.5), which
reads that history as training data instead of acting on it.

Tests pin the gating: backfilling accounts are not even read from the blob
store, non-inbox folders are ignored, a live inbox arrival still classifies, and
an unreachable engine still delivers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 07:59:15 +02:00
lukasandClaude Opus 5 c3ae613bf4 Antispam C1: the API and the three UI surfaces
Completes phase C1 — until now the plugin classified mail that nobody could see
or steer.

Router (§C.6): settings with merge semantics, allow/deny rules, a per-message
verdict lookup, learn, and stats. Two behaviours worth naming. A banner
threshold above the move threshold is silently capped rather than refused,
because a banner past the move score can never be seen — everything that far up
is already in Junk. And /learn moves the message even when the engine is
unreachable: teaching is best-effort, but filing is what the user actually
asked for.

UI: settings.page (thresholds in plain language, per-account toggles, the two
lists, the privacy switch, activity); message.banner for the uncertain middle,
offering Move to Junk / Not spam / Always trust sender; message.actions with
Spam / Not spam. The banner mounts on every message but renders only for a
`banner` verdict — a spam warning on ordinary mail is worse than no warning.

Bundle serving is allow-listed by filename, so /ui/../hook.py is a 404 rather
than a file read.

Tests cover the defaults, merge semantics, the threshold cap, rule
normalisation and idempotency, stats degrading to empty when Rspamd is absent,
and RLS isolation: one user cannot see another's rules or settings, nor delete a
rule whose id they know.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 22:21:26 +02:00
lukasandClaude Opus 5 95ca1b49f6 Antispam plugin (M7 phase C1): classify, file, and stay out of the way
Second-pass spam filtering per SPEC Appendix C. The engine is Rspamd in its own
container; this plugin is glue and policy, never a spam engine of its own.

Classification (§C.4) hooks message.new, acts only on inbox arrivals of enabled
accounts, and runs deny list -> allow list -> engine -> thresholds. Every
failure path ends in "deliver normally, unclassified": an unreachable engine, a
missing blob, an account with no Junk folder, a failed verdict write. Nothing
here may delay or lose mail, so no service declares depends_on: rspamd.

Two containers, started with everything else rather than behind a profile:
rspamd, and a redis of its own. The separate redis is not incidental — the app's
redis holds sync leases and arq queues under noeviction, and an ever-growing
Bayes corpus must never be able to fail a job enqueue. This one takes RDB
snapshots and evicts, because its data is derived and retrainable.

Network checks (RBL/SURBL/fuzzy) are off: they leak message-derived hashes and
URLs to third parties, which a self-hosted server's users did not ask for. The
engine makes no outbound connections in the default configuration.

The controller password is generated into .env like MASTER_KEY. It is internal;
humans will reach Rspamd through the admin panel, authenticated as the ordinary
administrator, rather than typing it.

local.d/groups.conf zeroes six SMTP-time symbols — DATE_IN_PAST,
PREVIOUSLY_DELIVERED, HFILTER_HOSTNAME_UNKNOWN, RCVD_NO_TLS_LAST, ARC_NA,
DMARC_NA — and greylisting is disabled. Measured against 398 real delivered
messages, those checks were firing on 44-71% of ordinary mail and pushing 11.3%
of it over the banner threshold; scoring mail at rest for facts true of every
delivered message is simply the wrong question. After: median 3.09 -> 0.70,
p95 7.00 -> 3.40, banner rate 11.3% -> 1.0%. The spec's 12.0/6.0 thresholds were
never the problem and are unchanged. Symbols are zeroed rather than removed so
verdicts stay explainable, and they matter again at the M6 edge (§C.7).

Still to come in C1: the §C.6 router and UI slots. C2 adds the learning loop —
until Bayes is trained, genuine scams and legitimate bulk mail overlap in the
5-8 band and no threshold separates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 22:14:43 +02:00
lukasandClaude Opus 5 428ec58c71 Plugin SDK: raw message bytes, per-message move, mailbox in the envelope
Three additive surfaces the antispam plugin (M7, Appendix C) needs and the
frozen §11 API did not have. None of them changes a core mail path.

`actions.move_to_special` moves ONE message into an account's folder by
special-use, wrapping the same optimistic-update + move_job path the core
endpoint uses. The existing `trash` moves a whole thread, which is right for
Delete and wrong for a spam verdict: one spam reply must not drag a legitimate
conversation into Junk. It returns False rather than raising when the account
has no such folder — an account whose server lacks a Junk folder must still
receive its mail.

`messages.raw` returns the stored RFC822 bytes. Spam scoring needs headers and
body; the SDK could only hand out one header at a time.

`envelope` now also carries account_id and mailbox_special_use. Appendix C must
classify only inbox arrivals, and neither the message.new payload nor the
envelope previously said where a message landed.

docs/open-questions.md gains OQ-6 (why these are additive rather than a spec
violation of "zero core diffs") and OQ-7 (why the engine is reached with plain
httpx instead of the SSRF-guarded sdk.http).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 22:14:24 +02:00
lukasandClaude Opus 5 348daf8ad7 Deploy: up died on QuTS hero's older bash
`compose "${profile[@]}" up -d "${build[@]}"` aborts with "profile[@]:
unbound variable" on bash 4.3 and older, which treats expanding an EMPTY
array under `set -u` as unbound. DSM ships a newer bash, so this only
surfaced on QNAP. The ${arr[@]+"${arr[@]}"} form expands to nothing when
empty and is identical otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 21:06:17 +02:00
lukasandClaude Opus 5 64ef3893d3 Deploy: add pull, the verb a first install from a registry needed
`up` only builds images that are missing locally, and `update` refuses to
run without an existing database to back up — so a fresh install that is
meant to fetch prebuilt images had no command that did it. `pull` fills the
gap and fails loudly if REGISTRY_PREFIX is still the local default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 20:35:29 +02:00
lukasandClaude Opus 5 8fce86559e Admin panel on its own port, and a registration gate
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
2026-09-16 20:34:08 +02:00
lukasandClaude Opus 5 cc63efa968 Docs: the real DSM fix is bridge-nf-call-iptables=0, not an iptables rule
Confirmed on the operator's NAS. The earlier advice (insert an ACCEPT rule
ahead of DSM's chain) does not hold: DSM reloads its firewall periodically,
flushing and rebuilding the chains. You can watch FORWARD_FIREWALL's DROP
counter reset to zero and the inserted rule vanish with it, having matched no
packets. That is also why the symptom looked intermittent — 60/40 success on
repeated connections, with RELATED,ESTABLISHED keeping existing flows alive.

The fix is one level down: net.bridge.bridge-nf-call-iptables=0 takes
same-bridge traffic out of iptables entirely, so DSM's reloading cannot affect
it. Persisted with a Boot-up Triggered Task.

Section 8 now also answers "is that safe", accurately: the flag affects only
packets bridged within one network. Traffic between different Docker networks
is routed, so DOCKER-ISOLATION still applies; inbound traffic to published
ports is still routed and still firewalled; INPUT, which protects the NAS
itself, is untouched. The one real caveat is that it is host-wide, so VMs
sharing a bridge lose same-bridge filtering too.

The migrate failure message points at the sysctl now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 21:21:22 +02:00
lukasandClaude Opus 5 d29c46c229 Docs: DSM firewall blocks container-to-container traffic
Diagnosed on a real Synology: with the DSM firewall enabled, iptables reads

    -A FORWARD -j FORWARD_FIREWALL      <- DSM, first, default deny
    -A FORWARD -j DEFAULT_FORWARD       <- Docker's ACCEPT rules, never reached

and bridge-nf-call-iptables=1 sends container-to-container packets through
FORWARD. A new connection to postgres:5432 matches no DSM allow rule and is
dropped before Docker's own "-i docker-xxx -o docker-xxx -j ACCEPT" is
consulted. RELATED,ESTABLISHED is accepted, which is why the port could test
open from a throwaway container while every new connection timed out — the
symptom looked intermittent and pointed nowhere near the firewall.

- docs §8: the diagnosis, the one-line fix
  (iptables -I FORWARD 1 -i docker+ -o docker+ -j ACCEPT), a boot-up Triggered
  Task so it survives reboots and firewall reloads, and a verification command.
- The migrate failure message now names this cause and the fix, since that is
  the error an operator actually sees.

Nothing in the stack is at fault here; this is host firewall configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 21:10:37 +02:00
lukasandClaude Opus 5 5c59eecff2 Deploy: make the database wait say why it is failing
On a Synology first run, migrate exited with "postgres:5432 never accepted a
connection" after the full 180s — and that was the entire diagnostic output,
despite the network being demonstrably fine afterwards (postgres resolved and
port 5432 was open from a throwaway container on the same network).

The retry loop caught OSError and discarded it. It now prints the first
failure immediately and repeats every 30s, with the hostname's resolved
address alongside, and includes the last error in the final message. A
DNS failure, a refused connection and a silently dropped packet each produce
a different line instead of the same useless one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 20:48:29 +02:00