#34: fix the mail counters, the reader pane, and the state between surfaces

The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.

Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.

Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.

Two defects found while testing the above:

  - The admin sign-in checked the password and the second factor and never the
    role, so any account that had enrolled a factor from its own settings could
    obtain an admin-audience session.
  - Session tokens carried nothing that varied below one second, so signing in
    twice within the same second minted the identical token and collided with
    the unique index on web_sessions.refresh_token_hash. A correct password
    answered 500, and a double-clicked sign-in was enough to do it. Tokens now
    carry a jti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-18 14:51:56 -03:00
1 parent e2ea58a4c9
commit 8b69521c76
25 files changed
+2276 -380

No files matched your search

@@ -2,6 +2,7 @@ package usecase
import (
"bytes"
"encoding/base64"
"io"
"strings"
@@ -15,6 +16,12 @@ import (
// an out-of-memory kill.
const maxRenderedPartBytes = 2 << 20 // 2 MiB
// maxInlineImageBytes bounds one inline image. Inline parts travel to the
// browser as data: URIs inside the JSON body, so an unbounded one would be
// held in memory here, base64-expanded on the wire and held again in the tab.
// Past this size the reader shows the alt text, which beats a stalled pane.
const maxInlineImageBytes = 2 << 20 // 2 MiB
// RenderedMessage is a message prepared for display: headers the reader shows
// and the two body flavours, already decoded from their transfer encoding.
type RenderedMessage struct {
@@ -27,6 +34,15 @@ type RenderedMessage struct {
Text string `json:"text"`
HTML string `json:"html"`
Attachments []string `json:"attachments"`
// InlineImages maps a part's Content-ID - without the angle brackets, the
// way the HTML body spells it in src="cid:..." - to a self-contained
// data: URI.
//
// Without this the reader had nothing to resolve cid: against, so every
// message written by a normal mail client displayed broken images. They
// are inlined rather than served from an endpoint so that the reader pane
// stays a sandboxed frame with no network identity of its own.
InlineImages map[string]string `json:"inline_images"`
}
// RenderMessage turns raw RFC 5322 bytes into what the reader pane needs.
@@ -37,7 +53,10 @@ type RenderedMessage struct {
// still yields its raw text rather than an error - unreadable formatting beats
// an inbox that refuses to open a message.
func RenderMessage(raw []byte, uid uint32) RenderedMessage {
out := RenderedMessage{UID: uid, To: []string{}, Cc: []string{}, Attachments: []string{}}
out := RenderedMessage{
UID: uid, To: []string{}, Cc: []string{},
Attachments: []string{}, InlineImages: map[string]string{},
}
entity, err := message.Read(bytes.NewReader(raw))
if err != nil {
@@ -89,8 +108,25 @@ func collectParts(entity *message.Entity, out *RenderedMessage) {
mediaType = "text/plain"
}
if disposition, params, err := entity.Header.ContentDisposition(); err == nil && disposition == "attachment" {
name := params["filename"]
disposition, dispParams, dispErr := entity.Header.ContentDisposition()
if dispErr != nil {
disposition = ""
}
// An inline image is part of the body, not a file to download: it is
// resolved into the HTML and deliberately left out of the attachment list,
// or every message with a logo in its signature grows a download chip.
if isInlineImageCandidate(disposition, mediaType, entity.Header.Get("Content-ID")) {
if collectInlineImage(entity, mediaType, out) {
return
}
}
// "inline" counts as an attachment for anything that is not an inline
// image: a client that attaches a PDF without saying "attachment" still
// sent a file the reader has to be able to fetch.
if disposition == "attachment" || (disposition == "inline" && dispParams["filename"] != "") {
name := dispParams["filename"]
if name == "" {
name = "attachment"
}
@@ -111,6 +147,42 @@ func collectParts(entity *message.Entity, out *RenderedMessage) {
}
}
// isInlineImageCandidate reports whether this part is an image the body can
// reference. A Content-ID is what makes it referenceable; image/svg+xml is
// excluded because an SVG is a document that carries script, and it is the one
// image type a data: URI must never smuggle into the reader pane.
func isInlineImageCandidate(disposition, mediaType, contentID string) bool {
if contentID == "" && disposition != "inline" {
return false
}
if !strings.HasPrefix(strings.ToLower(mediaType), "image/") {
return false
}
return !strings.EqualFold(mediaType, "image/svg+xml")
}
// collectInlineImage records one part as a data: URI, and reports whether it
// took ownership of the part. An image too large to inline is claimed as well:
// falling through would list it as an attachment, which is not what the sender
// meant by embedding it in the body.
func collectInlineImage(entity *message.Entity, mediaType string, out *RenderedMessage) bool {
contentID := strings.Trim(strings.TrimSpace(entity.Header.Get("Content-ID")), "<>")
if contentID == "" {
return false
}
// One byte past the limit is read so that an oversized part is recognised
// as oversized rather than silently truncated into a corrupt image.
body, err := io.ReadAll(io.LimitReader(entity.Body, maxInlineImageBytes+1))
if err != nil || len(body) > maxInlineImageBytes {
return true
}
out.InlineImages[contentID] = "data:" + strings.ToLower(mediaType) + ";base64," +
base64.StdEncoding.EncodeToString(body)
return true
}
// ExtractAttachment walks the MIME tree and returns bytes and content-type for a named attachment.
func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, string) {
if multipart := entity.MultipartReader(); multipart != nil {
@@ -1,6 +1,11 @@
package usecase
import "testing"
import (
"bytes"
"testing"
"github.com/emersion/go-message"
)
func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) {
raw := "From: Alice <alice@remote.test>\r\n" +
@@ -57,3 +62,110 @@ func TestRenderMessage_FallsBackToRawOnParseFailure(t *testing.T) {
t.Error("a malformed message produced no body at all")
}
}
// An inline image is referenced from the HTML body as cid:<Content-ID>. Neither
// the part nor its identifier used to leave this function, so every message
// written by a normal mail client rendered with broken image placeholders and
// no way for the reader to resolve them.
func TestRenderMessage_ReturnsInlineImagesKeyedByContentID(t *testing.T) {
// "iVBORw0KGgo=" is the leading bytes of a PNG, base64 encoded.
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<p><img src=\"cid:logo@x\"></p>\r\n" +
"--B\r\nContent-Type: image/png\r\n" +
"Content-ID: <logo@x>\r\n" +
"Content-Disposition: inline; filename=\"logo.png\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\niVBORw0KGgo=\r\n--B--\r\n"
got := RenderMessage([]byte(raw), 1)
// Keyed without the angle brackets, which is how the HTML spells it.
src, ok := got.InlineImages["logo@x"]
if !ok {
t.Fatalf("InlineImages = %v, want an entry for logo@x", got.InlineImages)
}
if src != "data:image/png;base64,iVBORw0KGgo=" {
t.Errorf("InlineImages[logo@x] = %q", src)
}
}
// An inline image already shown in the body is not a file the reader has to
// download; listing it as an attachment puts a chip under every signature.
func TestRenderMessage_DoesNotListInlineImagesAsAttachments(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<img src=\"cid:sig\">\r\n" +
"--B\r\nContent-Type: image/gif\r\n" +
"Content-ID: <sig>\r\nContent-Disposition: inline; filename=\"sig.gif\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\nR0lGOD==\r\n--B--\r\n"
if got := RenderMessage([]byte(raw), 1); len(got.Attachments) != 0 {
t.Errorf("Attachments = %v, want none", got.Attachments)
}
}
// A file sent inline that is not an image - a PDF a client attached without a
// disposition of "attachment" - is still something the reader must be able to
// download, so it belongs in the attachment list.
func TestRenderMessage_ListsNonImageInlinePartsAsAttachments(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" +
"--B\r\nContent-Type: application/pdf; name=\"report.pdf\"\r\n" +
"Content-Disposition: inline; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
got := RenderMessage([]byte(raw), 1)
if len(got.Attachments) != 1 || got.Attachments[0] != "report.pdf" {
t.Errorf("Attachments = %v, want [report.pdf]", got.Attachments)
}
}
// An image big enough to matter is not worth inlining into a JSON response
// that the browser holds in memory; the reader shows the alt text instead.
func TestRenderMessage_SkipsOversizedInlineImages(t *testing.T) {
huge := make([]byte, maxInlineImageBytes+1024)
for i := range huge {
huge[i] = 'A'
}
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<img src=\"cid:big\">\r\n" +
"--B\r\nContent-Type: image/png\r\nContent-ID: <big>\r\n\r\n" + string(huge) + "\r\n--B--\r\n"
if got := RenderMessage([]byte(raw), 1); len(got.InlineImages) != 0 {
t.Errorf("InlineImages has %d entries, want none", len(got.InlineImages))
}
}
// image/svg+xml is a document: it carries script and is the one image type a
// data: URI must never smuggle onto the reader pane.
func TestRenderMessage_RefusesToInlineSvg(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/related; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/html\r\n\r\n<img src=\"cid:x\">\r\n" +
"--B\r\nContent-Type: image/svg+xml\r\nContent-ID: <x>\r\n\r\n<svg onload=\"alert(1)\"/>\r\n--B--\r\n"
if got := RenderMessage([]byte(raw), 1); len(got.InlineImages) != 0 {
t.Errorf("InlineImages = %v, want svg refused", got.InlineImages)
}
}
// The chip the reader draws for an inline PDF links at the download endpoint,
// which resolves the name through here. Matching only on a disposition of
// "attachment" made every one of those chips 404.
func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) {
raw := "From: a@b.test\r\nSubject: s\r\n" +
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
"--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" +
"--B\r\nContent-Type: application/pdf\r\n" +
"Content-Disposition: inline; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
entity, err := message.Read(bytes.NewReader([]byte(raw)))
if err != nil {
t.Fatalf("parse: %v", err)
}
data, ctype := ExtractAttachment(entity, "report.pdf")
if string(data) != "PDFDATA" || ctype != "application/pdf" {
t.Errorf("ExtractAttachment = %q/%q", data, ctype)
}
}