README: what the webmail now does, the architecture roles, a short guide with the shortcuts, testing with a real database, and an honest list of what is not finished. New docs/WEBMAIL.md as the reference for each feature and for the colour system. OPERATIONS: sessions and trusted senders. DEPLOYMENT: the two-step release as API calls, and what to expect when upgrading. MAILBOX-CORE: the conversation invariants and migrations 0015 to 0018. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
14 KiB
The webmail
What the webmail at /user/* does, how each part behaves, and where it lives in
the code. The README has the overview; this is the reference.
- Signing in, and staying signed in
- Several accounts at once
- Trusted senders
- Conversations
- Reading a message
- Writing a message
- Appearance, the command menu and shortcuts
- For developers
Signing in, and staying signed in
A webmail session lasts 30 days and is renewed while it is used: once a
session token is a day old, the next request swaps it for a fresh one, so a
mailbox opened every few days never asks for the password again. The admin
console is deliberately different: 8 hours, fixed, with a step-up second
factor for destructive actions. The two surfaces keep separate cookies
(lm_user_session, lm_admin_session) and separate token audiences.
The lifetimes are constants in services/auth/src/accounts.ts
(USER_SESSION_TTL_SECONDS, ADMIN_SESSION_TTL_SECONDS, RENEW_AFTER_SECONDS).
They are not environment variables on purpose: a lifetime that can be set to
"never" is a setting that will be.
How renewal works, because it explains what you see in the sessions list:
- A token carries its own expiry and cannot be extended, so renewal issues a
new token and a new row in
web_sessions. - The old token is retired after a two-minute grace (
revoked_atset in the future), so a request already in flight with it does not come back401. - Only the cookie is renewed. A bearer token has nowhere to receive a new one.
- Signing out, changing the password or resetting it revokes immediately, with no grace. "Sign out other devices" in Settings revokes every other session.
Sessions that began before 30-day sessions existed keep their 8-hour expiry; they are replaced by 30-day ones the next time the person signs in.
Several accounts at once
Each webmail sign-in is also filed under its own cookie,
lm_acct_<mailbox id>, next to lm_user_session. lm_user_session is always
the active account, which is why the Go service, the Next middleware and every
route that existed before accounts needed no change.
GET /api/v1/auth/accounts |
The accounts signed in on this browser whose sessions are still good, and which one is active. A session that predates account cookies is listed too, and filed on the spot. |
POST /api/v1/auth/switch { "id" } |
Makes one of them active. Refuses an id that is not signed in on this browser. |
POST /api/v1/auth/logout { "all"?: true } |
Signs out the active account only; the next one becomes active. all: true signs out everything on this browser. |
Add an account from the menu at the foot of the sidebar ("Add another account"): it opens the normal sign-in, and the new account joins the others. Switching reloads the page, so nothing from the previous account stays in memory. Each account adds about half a kilobyte of cookie; a handful is fine, and a reverse proxy with a very small header limit is the only thing that would notice more.
Trusted senders
One decision, two effects. Trusting a sender means:
- Their mail is not filed as spam. If Rspamd's verdict is Junk, delivery files the message in the inbox instead.
- Their remote images load without asking.
The list is kept on the server, per mailbox, in trusted_senders
(migration 0016): it follows the account to every browser. What an earlier
version remembered in one browser's storage is moved up the first time the
webmail loads, and then cleared.
How to use it: the shield pill beside the sender's name trusts or untrusts, and in the Spam folder a banner offers Not spam, which moves the message to the inbox and trusts the sender in one step.
The rescue has a condition, and it is the point. A From header is the easiest field to forge, so trusting an address would otherwise hand a spammer a way in by writing a contact's address on their mail. Delivery only rescues a message when it proves where it came from:
- DMARC passed, or
- SPF passed for the very domain written in From (the envelope sender's domain equals the From domain).
Anything else stays in Junk. Also:
- Only the Junk verdict is rescued. A message the filter rejects, a virus and a greylist deferral are not affected.
- The mailbox's own Sieve rules run after the rescue and have the last word.
- A failed lookup leaves the message in Junk.
- The list holds at most 5000 addresses per mailbox.
- Rspamd itself does not learn from "Not spam": the sender stops being condemned, but similar mail from strangers is still judged as before.
API: GET, PUT and DELETE /api/v1/user/trusted-senders.
Code: services/protocols/internal/application/usecase/trusted_delivery.go.
Conversations
On by default; one switch in Settings → Language turns it off for the account
(conversation_view in preferences).
The list shows one row per conversation: who wrote, in order and with "me" for you, the newest message's subject and preview, the number of messages, an unread dot, and a reply arrow when you have answered. Opening one stacks its messages; the newest and any unread ones start open, the rest are a line each. The reply is written under the conversation, in the same pane, so the messages stay in view.
How messages are grouped, in this order, at delivery (resolveThread in
conversation_thread.go):
- A message already in the mailbox with the same
Message-ID(your Sent copy and a copy delivered back to you are one conversation). - The message it answers, through
In-Reply-To, thenReferences(nearest ancestor first). - A subject beginning with
Re:,Fwd:or a translation (Res,Enc,RV,AW,WG,TR,Antw), when the headers found nothing. - Otherwise a conversation of its own.
thread_id NULL means "its own conversation" (COALESCE(thread_id, id)), so
mail from before threading needs no rewrite and a reply simply adopts it.
Migration 0017 grouped older mail by subject, only where a reply is in the
mailbox; two unrelated messages that merely share a subject stay apart. The
subject key exists twice, in Go for delivery and in SQL (lm_subject_key) for
the backfill, and a test holds the two equal.
What counts as "answered": the last message someone else wrote has the
\Answered flag (set when you reply from the webmail), or a message of
yours came after it - which is how a reply written in another mail program
shows up, since it leaves no flag.
A conversation seen from one folder includes its messages in the others, but not Trash, Junk or Drafts (a reply still being written, or one deleted, is not part of the thread you are reading) unless that is the folder you are in. Delete and Move on a conversation act on its messages that sit in the open folder.
API: GET /api/v1/mail/threads?folder=&q=&limit=&offset= and
GET /api/v1/mail/thread/{id}?folder=. A server without conversations answers
501 and the webmail falls back to the plain list.
Reading a message
The body is drawn in a sandboxed frame (allow-scripts, no
allow-same-origin): an opaque origin that cannot read a cookie or touch the
page, running only the few lines that report its height, open links in a new
tab and fold quoted history. The HTML is sanitised first (DOMPurify; no script,
form, iframe, link, base; <style> kept but stripped of @import and the
legacy script-in-CSS forms).
Two ways of drawing it, chosen by looksDesigned in src/lib/emailBody.ts:
| Looks like | Drawn | |
|---|---|---|
| Written | A letter, a reply, a notice: no coloured backgrounds, no bgcolor, fewer than three tables. Plain white is not design (Outlook paints it everywhere). |
In the app's own colours, on the page: light text on dark, dark on light, links in the accent. Text too dark for a dark ground is lifted and white backdrops are cleared. "View as sent" shows the original. |
| Designed | A newsletter, a receipt: any coloured or image background, or a layout of tables. | On the white sheet it was drawn for, with rounded corners and a hairline. |
A reply's quoted history is folded behind a ··· button, as in Gmail, and opens in a quieter tone.
Remote images are blocked by default: a unique URL per recipient tells the
sender the message was opened, when and from roughly where. A bar offers to load
them for this message; trusting the sender loads them always. Images embedded in
the message (cid: parts) are shown, because they make no request.
Attachments are listed with a type tag and downloaded from
/api/v1/mail/message/{uid}/attachment/{name}.
Writing a message
One composer, used twice: the full page at /user/compose and the inline reply
under a conversation (src/components/Composer.tsx).
- Drafts autosave 2.5 seconds after you stop typing and are replaced, not duplicated. Discard removes the stored draft for good (it does not go to Trash) and stops a save still in flight.
- Undo send: after Send the message waits ten seconds; Undo cancels it. Leaving the screen with a send pending sends it once rather than losing it.
- The signature from Settings is inserted into a new message.
- Attachments are not sent yet. The picker lists the names and says so; the server has no upload endpoint.
Appearance, the command menu and shortcuts
Two themes, dark by default. Choose Dark, Light or System from the menu
at the foot of the sidebar, in the admin console's sidebar, or on either
sign-in page. The choice is stored per browser (a laptop in a bright office
and a phone in bed are different answers). A script in <head> applies it
before the first paint, so there is no flash of the wrong theme.
Command menu: Ctrl K / Cmd K. Folders, Compose, Settings, the admin
console, switching accounts, adding one, appearance and sign out, all by typing.
Text that matches no command offers to search the mail for it, so Enter is never
a dead key. It is also reachable from the "Command menu" row in the sidebar.
| Key | |
|---|---|
Ctrl K / Cmd K |
Open or close the command menu |
c |
Write a new message |
/ |
Go to the search field |
Up / Down, Enter, Esc |
Move in the menu, run, close |
c and / do nothing while you are typing in a field.
Install as an app. The webmail serves a web manifest and icons, so a browser can install it into its own window (over HTTPS, from the browser's install control). The tab icon, home-screen icon and install icons are all the same mark.
Fonts (Geist) are served by the app itself: opening your mail makes no request to a font host.
For developers
Where things are
| Sessions, accounts, trusted-senders API | services/auth/src/router.ts, accounts.ts, repository.ts |
| Conversation reads, answered logic | services/protocols/.../infrastructure/postgres/conversations.go |
| Conversation assignment at delivery | .../postgres/conversation_thread.go |
| Spam rescue for trusted senders | .../application/usecase/trusted_delivery.go |
| Reader, thread reader, composer | services/webmail/src/components/MessageReader.tsx, ThreadReader.tsx, Composer.tsx |
| Message document and mode | services/webmail/src/lib/emailBody.ts, renderMessage.ts, sanitizer.ts |
| Command menu | services/webmail/src/components/CommandPalette.tsx |
| Theme | services/webmail/src/lib/theme.ts, src/app/globals.css, tailwind.config.js |
The colour system
Every colour is a CSS variable defined once per theme in globals.css
([data-theme='dark'] and [data-theme='light']). The Tailwind scales the
screens already use - slate (neutrals), indigo (the accent), amber
(warning), rose (danger), emerald (done, trusted, answered) - read those
variables, so a theme is a change of values and never a change of classes.
The ramps run the same direction in both themes: 100 is the text you read,
950 the ground it sits on, 400 the quieter text, 700 a border. That is why
text-slate-400 is correct in light and in dark. Three more tokens:
inkis the colour that sits on the ground (white on dark, near-black on light): hover tints and hairlines areinkat low opacity, neverwhite/...fillis the solid fill of the one main action on a screen (bg-fill).railis the sidebar's ground.
To add a colour, add the variable in both blocks. theme-integrity.test.ts
fails if the two blocks define different variables, if a component tints with
white/.., or if the boot script stops defaulting to dark.
The mark
A lambda built as two square-cut strokes of one weight on a flat ultramarine
tile with continuous corners. The geometry and the PNG/ICO renderer are in
scripts/brand/; Logo.tsx carries the same path
inline. Change the number in mark.py, never the SVG.
Adding text
Strings live in services/webmail/messages/{en,pt-BR,es}.json and are read with
t("section.key"). A test requires pt-BR.json and es.json to have exactly
the keys of en.json, so a string added in one language and forgotten in
another fails the build.
Testing it
services/webmail has no browser test runner; the suites that guard this are
file-level and logic-level (vitest), and the screens were checked in a real
browser. To look at the reader or a theme change locally without the mail
services, point AUTH_SERVICE_URL and PROTOCOLS_SERVICE_URL at a small stub
that serves the JSON shapes above and run next dev.