# 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_`, 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`; `