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>
273 lines
14 KiB
Markdown
273 lines
14 KiB
Markdown
# 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](#signing-in-and-staying-signed-in)
|
|
- [Several accounts at once](#several-accounts-at-once)
|
|
- [Trusted senders](#trusted-senders)
|
|
- [Conversations](#conversations)
|
|
- [Reading a message](#reading-a-message)
|
|
- [Writing a message](#writing-a-message)
|
|
- [Appearance, the command menu and shortcuts](#appearance-the-command-menu-and-shortcuts)
|
|
- [For developers](#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_at` set in the
|
|
future), so a request already in flight with it does not come back `401`.
|
|
- 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:
|
|
|
|
1. **Their mail is not filed as spam.** If Rspamd's verdict is Junk, delivery
|
|
files the message in the inbox instead.
|
|
2. **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`):
|
|
|
|
1. A message already in the mailbox with the same `Message-ID` (your Sent copy
|
|
and a copy delivered back to you are one conversation).
|
|
2. The message it answers, through `In-Reply-To`, then `References` (nearest
|
|
ancestor first).
|
|
3. A subject beginning with `Re:`, `Fwd:` or a translation (`Res`, `Enc`, `RV`,
|
|
`AW`, `WG`, `TR`, `Antw`), when the headers found nothing.
|
|
4. 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:
|
|
|
|
- `ink` is the colour that sits on the ground (white on dark, near-black on
|
|
light): hover tints and hairlines are `ink` at low opacity, never `white/..`.
|
|
- `fill` is the solid fill of the one main action on a screen (`bg-fill`).
|
|
- `rail` is 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/`](../scripts/brand/README.md); `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`.
|