AI inbox features
Purpose. The AI a mailbox owner sees while reading mail. Thread summary is the first; explain-this-email, smart replies and compose assist follow on the same rail. Every one of them goes through AiService.run()/runStream(), so the three privacy gates and the metadata-only log apply whether or not the feature remembers they exist (ai-privacy).
| Control | Route | Shape |
|---|---|---|
| Summarise thread | POST /ai/summarise |
{folder, uids} → text/event-stream |
| Is this safe? | POST /ai/explain |
{folder, uid} → {verdict, summary, signals[], advice, usedSpamSignals} |
| Suggest replies | POST /ai/replies |
{folder, uids} → {replies[]} |
| Compose assist | POST /ai/compose |
{action: draft | rewrite | translate | grammar, …} → text/event-stream |
What streams and what does not follows from the shape of the answer, not from how long it takes. Summary and compose assist produce free text a reader watches appear. Explain and smart replies produce structured JSON the UI renders whole — dribbling that into a browser would only mean parsing it twice, and three reply chips arriving one character at a time would be worse than three arriving at once.
Explain-this-email combines two sources that fail differently. The model reads the message; the spam filter’s own verdict is read back off the delivered headers and passed in outside the fence, because it is the one input a sender cannot write. Rspamd scans on the way in, so by the time anyone asks, that work is done — re-scanning would cost a second pass and could disagree with the decision that actually placed the message.
usedSpamSignals says whether there was anything to read, and the UI shows it. Hiding that would let a content-only guess pass for a filtered judgement, and the two are worth very different amounts.
Rspamd writes the header for internet mail, and only for that. docker/rspamd/local.d/milter_headers.conf sets extended_spam_headers = true, which adds X-Spamd-Result (score, threshold and every symbol that fired), X-Rspamd-Queue-Id, X-Rspamd-Server and X-Rspamd-Action to a message as it is delivered. The module’s skip_local and skip_authenticated defaults are kept: mail from a mynetworks host or a SASL-authenticated user carries none of these, because both can be outbound — a LAN relay, or a user’s own submission — and a verdict with the rspamd host name in it should not travel to another server. So an explanation of a message that a colleague on the same server sent you is content-only, and says so through usedSpamSignals; one from the internet has the filter’s verdict beside it. The parser also handles the reduced X-Spam-Score / X-Spam-Status / X-Spam-Flag forms and the header’s absence. CI proves the stamping with a scan from a public address (scripts/ci/rspamd-headers.sh) because the SMTP smoke cannot: its “external” sender connects from the docker bridge, which is a local address.
A verdict is advice, not a filtering decision, and the UI says so next to the badge. unclear is a real answer — it is also what an unparseable model response becomes, because the one failure that could get someone phished is a reassuring badge on a message nobody actually assessed.
Smart replies never send. A chosen chip fills the compose box through ComposeOpen.body, above the signature and the quote, and there is deliberately no path that sends one. That is the mitigation for a finding in ai-prompt-library: gemma3:4b can be talked into suggesting text by an instruction hidden in the email it is replying to. A person reading the draft holds regardless of which model the customer configured; a prompt does not. An empty list is a normal answer and says so — better none than an invented one.
Compose assist is one route with four actions, because they are one feature from the writer’s point of view and share every gate, log field and response shape. It streams: all four produce free text a writer watches appear, and a rewrite of a long draft is the slowest thing the inbox asks a model to do.
Rewrite, translate and grammar send the draft itself and nothing else: there is nothing to look up server-side, because the text does not exist anywhere yet. Draft, when you are replying, also reads the message being replied to from the mailbox and sends it, fenced like the reading features’ mail, so the draft can answer it. Every draft, a new message too, also sends the mailbox’s display name, so it can sign off. It is the writer’s own words rather than a stranger’s, which is why the compose prompts do not fence it — they still sanitise it, because a draft is quite often pasted out of an email.
Every action replaces the body, so every action offers Undo. Losing a half-written message to a rewrite that came back worse is the most annoying thing this feature could do. Tokens accumulate into a buffer and the body is set from that, rather than appended to — appending would double the text, and only on a model that streams in more than one chunk.
Rewrite and translate stay disabled until a tone or a language is chosen: both prompts take that choice as an instruction, and firing them empty would spend a call to produce the draft back again.
A live pass changed the rewrite prompt. Against
gemma3:4b, compose-draft produced a message containing[date]and the rewrite replaced it with an invented “October 26th” — despite the prompt already saying “Preserve every fact, name, date, number and commitment exactly.” A fabricated date in a message the writer is about to send is the worst thing compose assist could quietly do, so compose-rewrite v2 names the placeholder case explicitly. Three runs kept[date]afterwards, andeval.test.tsnow asserts it.
The streaming rail
Section titled “The streaming rail”This is the part worth reading before adding the next feature, because everything else reuses it.
The client sends identifiers, never content. Threading is a client-side concern — threads.ts groups by In-Reply-To/References — so the browser knows which uids form a conversation and sends those. The bodies are read server-side from the caller’s own mailbox. A client therefore cannot put words in the model’s mouth, inflate the token bill with text that was never in a message, or ask about mail that is not its own. The uid cap (50) is what keeps one request from becoming an unbounded read.
It is deliberately not EventSource, even though the webmail push feed (/mail/events) is. That API cannot POST a body, so it could not say which thread to summarise, and it reconnects on its own — which for a one-shot generation the customer pays for would silently start a second one. Instead the client POSTs, reads the response body, and aborts the request to cancel. The abort closes the socket; the server turns req.raw.on('close') into an AbortSignal, which the provider layer already understands. So “user pressed stop” and “provider call aborted” are the same event travelling down one path.
The gates are checked before the response is committed. Once SSE headers are written, a refusal can no longer be an HTTP status — it has to arrive as an event, and a client that asked for something it was not allowed to have would see 200 OK followed by an error. So the route checks availability up front and answers with a real status: 429 ai_daily_limit for a used-up day, 402 license_degraded for a lapsed licence, and 403 for every other closed gate. prepare() inside runStream checks again and remains the chokepoint; the early check only decides what the client sees. A test asserts the refusal is not text/event-stream, because this is the kind of thing that silently regresses.
A provider failure is an event, for the same reason in reverse: by the time a model fails, the headers left long ago. The client renders it through the error taxonomy rather than pasting the provider’s words — billing becomes “the account has no credit left, an administrator needs to top it up”, which is something the reader can act on.
Events. {type:'text',text} repeatedly, then exactly one {type:'done'}, or {type:'error',kind,message}. Frames are separated by a blank line and arrive split across chunks — the reader buffers, and a test feeds it a stream deliberately split mid-event, because a reader that assumed one chunk per frame would drop half of every summary and look like a model problem.
Two conventions this had to match, both learned the hard way and both invisible until something breaks:
- URLs are absolute (
${origin}${API_BASE}), as the typed client builds them. A relative URL is not parseable outside a browser. fetchis called asglobalThis.fetch, resolved per call, for the reasonclient.tsalready documents: a barefetchbinds to something a test cannot replace.
What the reader sees
Section titled “What the reader sees”- Labelled on the output, not only the button. The summary carries “AI-generated summary · may be wrong” next to the text itself, because the text outlives the click — it gets read, screenshotted and forwarded long after.
- Silence when the closed gate is not theirs.
GET /ai/availabilitynames the reason AI is closed, one of six:not_configured,licence_lapsed,domain_disabled,not_opted_in,daily_limitandadmin_disabled.not_opted_inis shown when the reader can act on it (canOptIn), and the prompt says where: Settings → AI features (User settings (webmail));daily_limitis shown too (below). Every other reason renders nothing at all: an administrator’s decision is not this person’s problem, and a control that only ever says “unavailable” is worse than no control. - A used-up day says when it comes back. Past the mailbox’s daily AI limit, the panel says so and when requests return, in the reader’s own time (AI privacy controls → The daily limit).
- Cancel keeps what arrived. Stopping is a choice, not a failure: the partial summary stays on screen, no error appears, and the button offers to run again.
Limits & edge cases
Section titled “Limits & edge cases”- Each mailbox has a daily AI request limit (200 by default; per-domain and per-mailbox overrides,
0for none — AI privacy controls → The daily limit). Within it, a thread request carries at most 50 messages and a draft at most 20,000 characters. There is no token or spend cap: watch the call log’s token counts, or set limits at the provider. - The inbox features need the IMAP bridge: with it off they answer
503 mail_bridge_unavailable. - A cancelled call logs no token count. The row records
outcome: 'cancelled', but usage is only reported at thedoneevent, which by definition never arrives — so a cancelled Anthropic call is billed and the log shows zeros. Surfacing partial usage would mean threading it out of every adapter mid-stream; worth doing if the numbers start mattering, but the honest state today is that they are missing. - The thread comes from the folder’s newest 50 messages. The panel reads them itself, apart from whatever the list has paged in, so a conversation reaching further back than that is summarised only in part. The open message alone is the fallback, which is correct rather than defensive: a one-message thread is a thread.
- No retry on a failed stream. The user presses the button again. An automatic retry would double the bill for a failure they can see.
- Summaries are not cached. Asking twice costs twice. Prompt caching makes the input cheap on Anthropic (the thread is a cacheable prefix), but the output is generated afresh.
- Explanations are content-only for local and authenticated senders. Rspamd’s extended headers are on, and the explain path reads the symbols out of them — but
skip_localandskip_authenticatedstay true, so a message from one of your own mailboxes carries no verdict to explain. The feature says what it is missing rather than inventing it. - Every feature has now run against all three transports (2026-08-31): Anthropic
claude-haiku-4-5, OpenAIgpt-4o-miniand Ollama. Thread summary and compose assist were additionally driven in a real browser againstgemma3:4b, including cancel. - A drafted body may arrive with its own sign-off, and the composer adds a signature underneath.
compose-draftsays to sign off “only if a sign-off is warranted”;claude-haiku-4-5judged it unwarranted andgpt-4o-miniadded “Best, Alice”. On a mailbox with a configured signature that reads as a duplicate. The instruction is deliberate rather than an oversight, so this is recorded as a model difference rather than prompted away — but it is the obvious thing to revisit if it annoys anyone. - Compose assist is verified in a real browser against
gemma3:4b: draft-from-instruction and rewrite-with-tone both streamed into the body. Translate and fix-grammar are covered by unit and API tests only. - General instructions do not survive a small model; specific ones do. The rewrite hardening is one data point, but the pattern matches the injection finding: naming the exact failure works where a principle does not.