#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:
1 parent
e2ea58a4c9
commit
8b69521c76
25 files changed
+2276
-380
No files matched your search
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Turns a message's sanitized HTML into something a frame can actually show.
|
||||
*
|
||||
* Two things were missing and both looked like "images are broken": a message
|
||||
* written by any normal mail client references its own pictures as
|
||||
* src="cid:...", which resolves against nothing in a browser; and the frame was
|
||||
* handed a bare fragment with no document around it, so mail written for a
|
||||
* white page rendered as dark text on a dark surface with no width limit.
|
||||
*/
|
||||
|
||||
/** The parts the API returns alongside the body, keyed by Content-ID. */
|
||||
export type InlineImages = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Replaces src="cid:x" with the data: URI the server delivered for that part.
|
||||
*
|
||||
* A reference with no matching part loses its source entirely rather than
|
||||
* keeping a cid: the browser cannot fetch: an unresolvable src draws a
|
||||
* broken-image icon, while no src at all falls back to the alt text.
|
||||
*/
|
||||
export function resolveInlineImages(html: string, images: InlineImages): string {
|
||||
if (!html) return "";
|
||||
|
||||
return html.replace(
|
||||
/(<img\b[^>]*?)\ssrc\s*=\s*("|')cid:([^"']*)\2/gi,
|
||||
(_match, head: string, _quote: string, reference: string) => {
|
||||
// Clients disagree about whether the reference keeps the angle brackets
|
||||
// the Content-ID header carries, so both spellings resolve.
|
||||
const key = decodeURIComponent(reference.trim()).replace(/^<|>$/g, "");
|
||||
const resolved = images[key];
|
||||
return resolved ? `${head} src="${resolved}"` : head;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a message body in the document the reader frame renders.
|
||||
*
|
||||
* The frame is sandboxed with an opaque origin, so it cannot inherit anything
|
||||
* from the app: every style, the light surface mail assumes, and the height
|
||||
* report the host page sizes the frame from all have to travel inside it.
|
||||
*/
|
||||
export function buildReaderDocument(bodyHtml: string): string {
|
||||
return `<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Every link leaves this document: it has an opaque origin, so navigating in
|
||||
place strands the reader on a blank pane with no way back. Declared here
|
||||
rather than only in script so it holds even if script does not run. -->
|
||||
<base target="_blank">
|
||||
<!-- Any remote resource the reader opted into must not carry the message's URL
|
||||
with it, and this holds without script. -->
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<style>
|
||||
html { background:#ffffff; }
|
||||
body {
|
||||
margin:0; padding:20px; background:#ffffff; color:#1e293b;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size:14px; line-height:1.6;
|
||||
/* Mail is written against desktop widths. Without these a newsletter
|
||||
pushes a horizontal scrollbar across its own content. */
|
||||
overflow-wrap:anywhere; word-break:break-word;
|
||||
}
|
||||
img, video, table, pre { max-width:100% !important; }
|
||||
img { height:auto; }
|
||||
table { border-collapse:collapse; }
|
||||
/* A layout table with a fixed pixel width is the usual overflow culprit. */
|
||||
table[width] { width:auto !important; max-width:100% !important; }
|
||||
blockquote {
|
||||
margin:0 0 0 4px; padding-left:12px;
|
||||
border-left:3px solid #cbd5e1; color:#475569;
|
||||
}
|
||||
a { color:#4f46e5; }
|
||||
pre { white-space:pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
<script>
|
||||
// <base> already sends the click to a new tab; this adds the rel that keeps
|
||||
// the destination from learning where the click came from.
|
||||
for (const link of document.querySelectorAll("a[href]")) {
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
// The host sizes the frame from this rather than guessing: a fixed height
|
||||
// either clips a long message or leaves a slab of empty white under a short
|
||||
// one, and a sandboxed frame cannot be measured from outside.
|
||||
const report = () => parent.postMessage(
|
||||
{ type: "lm:reader-height", height: document.documentElement.scrollHeight }, "*");
|
||||
report();
|
||||
window.addEventListener("load", report);
|
||||
if (typeof ResizeObserver === "function") new ResizeObserver(report).observe(document.body);
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The arithmetic behind every number the mail screens display.
|
||||
*
|
||||
* It lives here, apart from the components, because each of these rules was
|
||||
* got wrong inside a JSX expression where nothing could test it: the sidebar
|
||||
* resolved a folder by a role that folders do not always carry, the list header
|
||||
* printed the length of the page it had loaded as though it were the size of
|
||||
* the folder, and marking a message read moved no counter anywhere.
|
||||
*/
|
||||
|
||||
/** A folder as /api/v1/mail/folders returns it. */
|
||||
export interface FolderSummary {
|
||||
special_use: string;
|
||||
name: string;
|
||||
unread_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/** The fields of a message row these counts depend on. */
|
||||
export interface MessageSummary {
|
||||
uid: number;
|
||||
seen: boolean;
|
||||
has_attachments: boolean;
|
||||
}
|
||||
|
||||
export interface FolderMetrics {
|
||||
unread: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type ListFilter = "unread" | "attachment" | null;
|
||||
|
||||
/** Clamps a counter to something displayable: no negatives, no NaN. */
|
||||
function counter(value: unknown): number {
|
||||
const numeric = typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
return Math.max(0, Math.trunc(numeric));
|
||||
}
|
||||
|
||||
/**
|
||||
* The counts for one folder, addressed the way the URL addresses it.
|
||||
*
|
||||
* A folder is matched on its special-use role or, failing that, its name: a
|
||||
* folder an IMAP client created has no role, and matching only on the role left
|
||||
* every one of them without a badge. The name comparison is case-insensitive
|
||||
* because IMAP spells the inbox INBOX and the route spells it inbox.
|
||||
*/
|
||||
export function folderMetrics(
|
||||
folders: FolderSummary[] | undefined | null,
|
||||
route: string,
|
||||
): FolderMetrics {
|
||||
const wanted = route.trim().toLowerCase();
|
||||
const match = (folders ?? []).find(
|
||||
(f) => f.special_use?.toLowerCase() === wanted || f.name?.toLowerCase() === wanted,
|
||||
);
|
||||
if (!match) return { unread: 0, total: 0 };
|
||||
return { unread: counter(match.unread_count), total: counter(match.total_count) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The number a sidebar folder shows, or 0 for no badge.
|
||||
*
|
||||
* Drafts is the exception: nothing ever marks a draft read, so its unread count
|
||||
* is permanently zero and the badge never appeared - while the number that
|
||||
* matters there, how many unfinished messages are waiting, was already on hand.
|
||||
*/
|
||||
export function badgeCount(folders: FolderSummary[] | undefined | null, route: string): number {
|
||||
const { unread, total } = folderMetrics(folders, route);
|
||||
return route.trim().toLowerCase() === "drafts" ? total : unread;
|
||||
}
|
||||
|
||||
/** What is on the page that has been loaded, as opposed to what is in the folder. */
|
||||
export function messageCounts(messages: MessageSummary[]): {
|
||||
loaded: number;
|
||||
unread: number;
|
||||
withAttachments: number;
|
||||
} {
|
||||
return {
|
||||
loaded: messages.length,
|
||||
unread: messages.filter((m) => !m.seen).length,
|
||||
withAttachments: messages.filter((m) => m.has_attachments).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterMessages<T extends MessageSummary>(messages: T[], filter: ListFilter): T[] {
|
||||
if (filter === "unread") return messages.filter((m) => !m.seen);
|
||||
if (filter === "attachment") return messages.filter((m) => m.has_attachments);
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The count beside the list heading, and whether it describes the folder.
|
||||
*
|
||||
* Two numbers were being conflated. The size of the folder comes from the
|
||||
* folder list; the number of rows on screen is whatever the API returned for
|
||||
* this page, capped at 50 - so a large folder advertised 50 messages. During a
|
||||
* search neither the folder total nor a cap is meaningful: what the reader
|
||||
* wants is how many matched.
|
||||
*
|
||||
* The folder counter is denormalised and can lag, so it is never allowed to
|
||||
* claim fewer messages than are already visible.
|
||||
*/
|
||||
export function listHeaderCount(input: {
|
||||
folder: FolderMetrics;
|
||||
loaded: number;
|
||||
searching: boolean;
|
||||
}): { count: number; isFolderTotal: boolean } {
|
||||
if (input.searching) return { count: counter(input.loaded), isFolderTotal: false };
|
||||
return {
|
||||
count: Math.max(counter(input.folder.total), counter(input.loaded)),
|
||||
isFolderTotal: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The list with one message's read flag changed.
|
||||
*
|
||||
* A new array, because React reads this state: mutating in place left the row
|
||||
* bold until some unrelated change forced a render.
|
||||
*/
|
||||
export function applySeen<T extends MessageSummary>(messages: T[], uid: number, seen: boolean): T[] {
|
||||
return messages.map((m) => (m.uid === uid ? { ...m, seen } : m));
|
||||
}
|
||||
@@ -10,6 +10,29 @@ import DOMPurify from "dompurify";
|
||||
* a regex cannot parse HTML. DOMPurify parses, which is why it is the only
|
||||
* thing here.
|
||||
*/
|
||||
/**
|
||||
* The data: URIs a message body may carry: raster images only.
|
||||
*
|
||||
* DOMPurify allows data: on an image source from its own built-in list, which
|
||||
* includes image/svg+xml, and that list is consulted instead of
|
||||
* ALLOWED_URI_REGEXP - so tightening the regexp alone does not exclude an SVG.
|
||||
* The hook below is what actually refuses it, and an SVG has to be refused: it
|
||||
* is a document that carries script, not a picture.
|
||||
*/
|
||||
const ALLOWED_DATA_IMAGE = /^data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);/i;
|
||||
|
||||
let hookInstalled = false;
|
||||
|
||||
function installDataUriHook(): void {
|
||||
if (hookInstalled) return;
|
||||
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||
if (/^\s*data:/i.test(data.attrValue) && !ALLOWED_DATA_IMAGE.test(data.attrValue.trim())) {
|
||||
data.keepAttr = false;
|
||||
}
|
||||
});
|
||||
hookInstalled = true;
|
||||
}
|
||||
|
||||
export function sanitizeEmailHtml(rawHtml: string): string {
|
||||
if (!rawHtml) return "";
|
||||
|
||||
@@ -20,6 +43,8 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
installDataUriHook();
|
||||
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
|
||||
@@ -37,8 +62,12 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "link", "meta", "base"],
|
||||
FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// data: is how an anchor smuggles a document onto this origin.
|
||||
ALLOWED_URI_REGEXP: /^(?:https?|mailto|cid|tel):/i,
|
||||
// data: is how an anchor smuggles a document onto this origin, so only the
|
||||
// raster image types are let through - which is how this app delivers a
|
||||
// message's own inline parts, and rejecting them stripped the src from
|
||||
// every embedded picture. image/svg+xml is deliberately absent: an SVG is
|
||||
// a document that carries script.
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);)/i,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Which session a page needs before it can usefully render.
|
||||
*
|
||||
* Kept out of the middleware so it can be tested: as two inline prefix checks
|
||||
* it could not express the case that matters, that /admin/step-up is reached
|
||||
* *with* a webmail session and *without* an admin one. Gating it on an admin
|
||||
* session made the screen that mints admin sessions unreachable.
|
||||
*
|
||||
* This is not the security boundary. Every /api/v1/* call is verified by the
|
||||
* service that owns the signing key; this only avoids serving a shell that
|
||||
* cannot work, and sends the visitor somewhere useful instead.
|
||||
*/
|
||||
|
||||
export type GatedSurface = "user" | "admin";
|
||||
|
||||
/** Paths inside a surface that must stay reachable without its session. */
|
||||
const OPEN_PATHS = new Set(["/user/login", "/admin/login"]);
|
||||
|
||||
/**
|
||||
* Paths that live under one surface but are gated on another.
|
||||
*
|
||||
* The step-up trades a webmail session for an admin one, so requiring the
|
||||
* admin session it produces would be circular.
|
||||
*/
|
||||
const CROSS_GATED: Record<string, GatedSurface> = {
|
||||
"/admin/step-up": "user",
|
||||
};
|
||||
|
||||
/** True when `pathname` is inside `area` as a path segment, not as a prefix. */
|
||||
function inArea(pathname: string, area: string): boolean {
|
||||
return pathname === area || pathname.startsWith(`${area}/`);
|
||||
}
|
||||
|
||||
export function surfaceGateFor(pathname: string): GatedSurface | null {
|
||||
if (OPEN_PATHS.has(pathname)) return null;
|
||||
if (CROSS_GATED[pathname]) return CROSS_GATED[pathname];
|
||||
if (inArea(pathname, "/admin")) return "admin";
|
||||
if (inArea(pathname, "/user")) return "user";
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useMailEvents } from "./useMailEvents";
|
||||
import type { FolderSummary } from "./mailCounts";
|
||||
|
||||
/**
|
||||
* The folder list, kept current.
|
||||
*
|
||||
* It used to be fetched once when the sidebar mounted, which froze every unread
|
||||
* badge at whatever it was when the tab was opened: mail arriving did not raise
|
||||
* it, and reading a message did not lower it. Two things move these counters,
|
||||
* so both have to reach here - the server's push for anything that happened
|
||||
* elsewhere, and an in-page announcement for what this tab just did.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fired on window when this tab changes something a counter is derived from.
|
||||
*
|
||||
* A DOM event rather than shared React state because the two are in different
|
||||
* subtrees: the reader is inside the page, the badges are in the layout around
|
||||
* it, and neither renders the other.
|
||||
*/
|
||||
export const MAIL_STATE_CHANGED = "lm:mail-state-changed";
|
||||
|
||||
/** Announces that this tab moved a counter, so the badges re-read them. */
|
||||
export function notifyMailStateChanged(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new Event(MAIL_STATE_CHANGED));
|
||||
}
|
||||
|
||||
export function useFolders(): { folders: FolderSummary[]; reload: () => void } {
|
||||
const [folders, setFolders] = useState<FolderSummary[]>([]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void fetch("/api/v1/mail/folders", { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
// Only a real list replaces the current one: overwriting with [] on a
|
||||
// failed request would blank every badge on a transient error.
|
||||
if (Array.isArray(data)) setFolders(data as FolderSummary[]);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
// Any push means something changed in the mailbox; which folder it landed in
|
||||
// is not worth deducing here when re-reading the counters is one small call.
|
||||
useMailEvents(useCallback(() => reload(), [reload]));
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(MAIL_STATE_CHANGED, reload);
|
||||
return () => window.removeEventListener(MAIL_STATE_CHANGED, reload);
|
||||
}, [reload]);
|
||||
|
||||
return { folders, reload };
|
||||
}
|
||||
Reference in new issue
Block a user