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 <noreply@anthropic.com>
This commit is contained in:
1 parent
026f24a64a
commit
cc0adcd94d
5 files changed
+84
-2
No files matched your search
@@ -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");
|
||||
|
||||
@@ -642,7 +642,15 @@ async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise<voi
|
||||
*/
|
||||
async function liveAccounts(req: IncomingMessage): Promise<{ id: string; email: string; token: string }[]> {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
||||
{msg.snippet}
|
||||
{plainSnippet(msg.snippet)}
|
||||
</span>
|
||||
{msg.has_attachments && (
|
||||
<span className="mt-px flex items-center gap-1.5">
|
||||
|
||||
@@ -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 "<p>Hello,</p><p>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<string, string> = {
|
||||
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,</p><p>You" would read "Hello,You".
|
||||
.replace(/<\/(?:p|div|li|tr|h[1-6])>|<br\s*\/?>/gi, " ")
|
||||
.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
||||
.replace(/<[^>]*>/g, "");
|
||||
return decodeEntities(withoutTags).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -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("<p>Hello,</p><p>You are verifying.</p>")).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("<style>p{color:red}</style><p>Hi</p>")).toBe("Hi");
|
||||
});
|
||||
it("keeps an entity it does not know", () => {
|
||||
expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user