From cc0adcd94d8d3993d2749d17d95ddcc08dc171b7 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 15:23:40 -0300 Subject: [PATCH] fix(auth,webmail): list a pre-accounts session in the switcher; preview as plain text A browser signed in before account cookies existed has only lm_user_session, so the switcher showed it as loading forever. The active session now always counts and is filed on first listing. Message previews drop the HTML and entities the server stored. Co-Authored-By: Claude Sonnet 5.5 --- services/auth/src/router.test.ts | 7 ++++ services/auth/src/router.ts | 17 ++++++++- .../app/(user)/user/mail/[folder]/page.tsx | 3 +- services/webmail/src/lib/snippet.ts | 37 +++++++++++++++++++ services/webmail/src/snippet.test.ts | 22 +++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 services/webmail/src/lib/snippet.ts create mode 100644 services/webmail/src/snippet.test.ts diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index 42b51c5..dcd7f7b 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -1260,6 +1260,13 @@ describeDb("moving between the surfaces", () => { expect(unknown.status).toBe(404); }, 60000); + it("lists a session that predates account cookies and files it", async () => { + const a = await signIn("multi1"); + const listed = await call("/api/v1/auth/accounts", `lm_user_session=${a.token}`); + expect(listed.body.accounts).toEqual([{ id: a.id, email: emailOf("multi1"), active: true }]); + expect(valueOf(listed.cookies, `lm_acct_${a.id}`)).toBe(a.token); + }, 60000); + it("will not switch to a token that belongs to another account's cookie", async () => { const a = await signIn("multi1"); const b = await signIn("multi2"); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 6a92ec2..ad10cd0 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -642,7 +642,15 @@ async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise { const live: { id: string; email: string; token: string }[] = []; - for (const { id, token } of accountTokens(req.headers.cookie)) { + // The active session counts even without an account cookie of its own: a + // browser signed in before accounts existed has only lm_user_session. + const filed = accountTokens(req.headers.cookie); + const activeToken = activeUserToken(req); + const activeClaims = activeToken ? verifyJwt(activeToken) : null; + if (activeClaims && !filed.some((a) => a.id === activeClaims.sub.toLowerCase())) { + filed.push({ id: activeClaims.sub.toLowerCase(), token: activeToken }); + } + for (const { id, token } of filed) { const payload = verifyJwt(token); if (!payload || !isSurfaceAuthorized(payload, "user") || payload.sub.toLowerCase() !== id) continue; if (await repo.isSessionRevoked(sha256(token))) continue; @@ -662,6 +670,13 @@ async function listAccounts(req: IncomingMessage, res: ServerResponse): Promise< email, active: active?.sub.toLowerCase() === id, })); + // Files the active session under its account so a later switch away and back + // finds it, which a session from before accounts existed would not. + if (active && !accountTokens(req.headers.cookie).some((a) => a.id === active.sub.toLowerCase())) { + res.setHeader("Set-Cookie", [ + sessionCookie(accountCookieName(active.sub), activeUserToken(req), USER_SESSION_TTL_SECONDS, isSecureRequest()), + ]); + } sendJson(res, 200, { accounts }); } diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index b812fbf..32c69ba 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -28,6 +28,7 @@ import { type ListFilter, } from "../../../../../lib/mailCounts"; import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; +import { plainSnippet } from "../../../../../lib/snippet"; import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages"; import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { Button } from "../../../../../components/ui/Button"; @@ -545,7 +546,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {msg.subject || t("mail.noSubject")} - {msg.snippet} + {plainSnippet(msg.snippet)} {msg.has_attachments && ( diff --git a/services/webmail/src/lib/snippet.ts b/services/webmail/src/lib/snippet.ts new file mode 100644 index 0000000..fd62ddf --- /dev/null +++ b/services/webmail/src/lib/snippet.ts @@ -0,0 +1,37 @@ +/** + * The one-line preview under a subject, as plain text. + * + * The server stores the opening of the body as it arrived, so a message with + * only an HTML part previewed as "

Hello,

You are verifying..." and a + * lone apostrophe as "'". Cleaning it where it is shown also fixes the + * mail already stored, which a change at delivery would not reach. + */ + +const NAMED: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", +}; + +function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body: string) => { + if (body[0] === "#") { + const code = body[1].toLowerCase() === "x" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole; + } + return NAMED[body.toLowerCase()] ?? whole; + }); +} + +export function plainSnippet(raw: string | null | undefined): string { + if (!raw) return ""; + const withoutTags = raw + // A block boundary is a space, or "Hello,

You" would read "Hello,You". + .replace(/<\/(?:p|div|li|tr|h[1-6])>|/gi, " ") + .replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ") + .replace(/<[^>]*>/g, ""); + return decodeEntities(withoutTags).replace(/\s+/g, " ").trim(); +} diff --git a/services/webmail/src/snippet.test.ts b/services/webmail/src/snippet.test.ts new file mode 100644 index 0000000..366e19b --- /dev/null +++ b/services/webmail/src/snippet.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { plainSnippet } from "./lib/snippet.js"; + +describe("plainSnippet", () => { + it("drops tags and keeps the words apart", () => { + expect(plainSnippet("

Hello,

You are verifying.

")).toBe("Hello, You are verifying."); + }); + it("decodes the entities a stored preview carries", () => { + expect(plainSnippet("we'll email you & "more" soon")).toBe(`we'll email you & "more" soon`); + }); + it("leaves plain text alone and survives nothing", () => { + expect(plainSnippet("Just words")).toBe("Just words"); + expect(plainSnippet(null)).toBe(""); + expect(plainSnippet("")).toBe(""); + }); + it("drops a stylesheet instead of previewing its rules", () => { + expect(plainSnippet("

Hi

")).toBe("Hi"); + }); + it("keeps an entity it does not know", () => { + expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b"); + }); +});