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:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 15:23:40 -03:00
1 parent 026f24a64a
commit cc0adcd94d
5 files changed
+84 -2

No files matched your search

@@ -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">
+37
View File
@@ -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 "&#39;". 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();
}
+22
View File
@@ -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&#39;ll email you &amp; &quot;more&quot;&nbsp;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");
});
});