diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index bc7f22e..f96f547 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -321,9 +321,15 @@ "ccLabel": "Cc", "bccLabel": "Bcc", "hideImages": "Hide images", - "alwaysFromSender": "Always from this sender", + "alwaysFromSender": "Trust this sender", "forgetSender": "Stop trusting sender", - "moveTo": "Move to" + "moveTo": "Move to", + "notSpam": "Not spam", + "junkNotice": "This message is in Spam. Marking it as not spam moves it to the inbox and trusts this sender, so their next messages arrive there too.", + "trustSender": "Trust sender", + "trustedSender": "Trusted sender", + "trustSenderHint": "Never file this sender's mail as spam, and always show their images", + "trustedSenderHint": "Not filed as spam, images always shown. Click to stop trusting." }, "settings": { "actionDiscard": "Discard", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 5156653..2e48d66 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -321,9 +321,15 @@ "ccLabel": "Cc", "bccLabel": "Cco", "hideImages": "Ocultar imágenes", - "alwaysFromSender": "Siempre de este remitente", + "alwaysFromSender": "Confiar en este remitente", "forgetSender": "Dejar de confiar", - "moveTo": "Mover a" + "moveTo": "Mover a", + "notSpam": "No es spam", + "junkNotice": "Este mensaje está en Spam. Al marcarlo como no es spam, pasa a la bandeja de entrada y este remitente se vuelve de confianza, así que sus próximos mensajes llegarán allí también.", + "trustSender": "Confiar en el remitente", + "trustedSender": "Remitente de confianza", + "trustSenderHint": "No enviar nunca el correo de este remitente a spam y mostrar siempre sus imágenes", + "trustedSenderHint": "No va a spam y sus imágenes siempre se muestran. Haz clic para dejar de confiar." }, "settings": { "actionDiscard": "Descartar", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 3ae4f05..04e55e0 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -321,9 +321,15 @@ "ccLabel": "Cc", "bccLabel": "Cco", "hideImages": "Ocultar imagens", - "alwaysFromSender": "Sempre deste remetente", + "alwaysFromSender": "Confiar neste remetente", "forgetSender": "Deixar de confiar", - "moveTo": "Mover para" + "moveTo": "Mover para", + "notSpam": "Não é spam", + "junkNotice": "Esta mensagem está no Spam. Ao marcar como não é spam, ela vai para a caixa de entrada e este remetente passa a ser confiável, então as próximas mensagens dele também chegam lá.", + "trustSender": "Confiar no remetente", + "trustedSender": "Remetente confiável", + "trustSenderHint": "Nunca mandar o e-mail deste remetente para o spam e sempre mostrar as imagens dele", + "trustedSenderHint": "Não vai para o spam e as imagens sempre aparecem. Clique para deixar de confiar." }, "settings": { "actionDiscard": "Descartar", 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 32c69ba..3fc8d20 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -29,7 +29,7 @@ import { } from "../../../../../lib/mailCounts"; import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; import { plainSnippet } from "../../../../../lib/snippet"; -import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages"; +import { useTrustedSenders } from "../../../../../lib/trustedSenders"; import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { Button } from "../../../../../components/ui/Button"; import { @@ -122,9 +122,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const [messages, setMessages] = useState([]); const [selected, setSelected] = useState(null); const [loadRemoteImages, setLoadRemoteImages] = useState(false); - // Whether this sender has a standing decision, so the banner can offer to - // take it back rather than only to grant it. - const [rememberSender, setRememberSender] = useState(false); + // The senders this account trusts, kept on the server so the decision + // follows the account to every browser and also reaches the spam filter. + const trustedSenders = useTrustedSenders(); const [searchQuery, setSearchQuery] = useState(""); const [activeFilter, setActiveFilter] = useState(null); const [loading, setLoading] = useState(true); @@ -333,7 +333,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s return; } setLoadRemoteImages(false); - setRememberSender(false); setReaderHeight(420); try { const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, { @@ -342,10 +341,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s if (!res.ok) throw new Error(t("errors.serverError")); const message = await res.json(); setSelected(message); - // A sender the reader has already allowed does not get asked again. - const trusted = isSenderTrusted(message.from ?? ""); - setLoadRemoteImages(trusted); - setRememberSender(trusted); // Fetching the message is what marks it read on the server, so this only // brings the list and the badges into line with what already happened. void setSeen(uid, true, { persist: false }); @@ -362,6 +357,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s searching: searchQuery.trim().length > 0, }); const selectedRow = messages.find((m) => m.uid === selected?.uid); + const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false; /** * The document the reader frame renders. @@ -376,9 +372,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const sanitized = sanitizeEmailHtml(selected.html); if (!sanitized) return null; const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {}); - const body = loadRemoteImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline); + // A trusted sender's images load without asking; anyone else's wait for + // the reader's choice, one message at a time. + const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? ""); + const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline); return buildReaderDocument(body); - }, [selected, loadRemoteImages]); + }, [selected, loadRemoteImages, trustedSenders]); // Whether anything was actually held back, so the privacy banner is not shown // over a message that has no remote content at all. @@ -651,25 +650,29 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s document={readerDocument} frameHeight={readerHeight} hasBlockedContent={hasBlockedContent} - imagesLoaded={loadRemoteImages} - senderRemembered={rememberSender} + imagesLoaded={loadRemoteImages || senderTrusted} + senderTrusted={senderTrusted} + isJunk={folder.toLowerCase() === "junk"} onClose={() => setSelected(null)} onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))} onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })} onDelete={() => void deleteMessage(selected.uid)} onMove={(target) => void moveMessage(selected.uid, target)} onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)} - onToggleRemember={() => { - if (rememberSender) { - revokeSender(selected.from); - setRememberSender(false); + onToggleTrusted={() => { + if (senderTrusted) { + void trustedSenders.revoke(selected.from); setLoadRemoteImages(false); } else { - trustSender(selected.from); - setRememberSender(true); - setLoadRemoteImages(true); + void trustedSenders.trust(selected.from); } }} + onNotSpam={() => { + // Two steps, one decision: trusting is what makes the next + // reply reach the inbox, moving this one is what is seen now. + void trustedSenders.trust(selected.from); + void moveMessage(selected.uid, "INBOX"); + }} /> ) : ( void; onToggleSeen: () => void; onToggleFlagged: () => void; onDelete: () => void; onMove: (target: string) => void; onToggleImages: () => void; - onToggleRemember: () => void; + onToggleTrusted: () => void; + onNotSpam: () => void; } /** The sender's name and address, from a header like `Jenny `. */ @@ -324,6 +330,22 @@ export function MessageReader(props: Props) { <{address}> )} +
+ +
{visibleTo.length > 0 && ( <> @@ -361,18 +383,35 @@ export function MessageReader(props: Props) { error. Two separate decisions - look now, or stop asking about this sender - so a reader can see one message without granting anything permanently. */} - {(props.hasBlockedContent || props.senderRemembered) && ( + {props.isJunk && ( +
+ + + {t("mail.junkNotice")} + + +
+ )} + + {props.hasBlockedContent && (
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")} - - + {/* A trusted sender's images are shown for as long as they are + trusted, so hiding them here would do nothing. */} + {!props.senderTrusted && ( + + )}
)} diff --git a/services/webmail/src/lib/remoteImages.ts b/services/webmail/src/lib/remoteImages.ts index d71d776..2fb7989 100644 --- a/services/webmail/src/lib/remoteImages.ts +++ b/services/webmail/src/lib/remoteImages.ts @@ -43,7 +43,7 @@ export function senderKey(from: string): string { * reader to fail to render. Failing closed means falling back to blocked, * which is the safe direction. */ -function readTrusted(): string[] { +export function readTrusted(): string[] { try { const raw = window.localStorage.getItem(TRUSTED_SENDERS_KEY); if (!raw) return []; @@ -86,3 +86,12 @@ export function revokeSender(from: string): void { if (!key) return; writeTrusted(readTrusted().filter((entry) => entry !== key)); } + +/** Forgets the browser's own copy, once it has been moved to the server. */ +export function clearLocalTrusted(): void { + try { + window.localStorage.removeItem(TRUSTED_SENDERS_KEY); + } catch { + // Nothing to clear. + } +} diff --git a/services/webmail/src/lib/snippet.ts b/services/webmail/src/lib/snippet.ts index fd62ddf..8a30f1e 100644 --- a/services/webmail/src/lib/snippet.ts +++ b/services/webmail/src/lib/snippet.ts @@ -32,6 +32,9 @@ export function plainSnippet(raw: string | null | undefined): string { // 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, ""); + .replace(/<[^>]*>/g, "") + // The server cuts the preview at a fixed length, which can leave the start + // of a tag with no end ("...open]*$/, ""); return decodeEntities(withoutTags).replace(/\s+/g, " ").trim(); } diff --git a/services/webmail/src/lib/trustedSenders.ts b/services/webmail/src/lib/trustedSenders.ts new file mode 100644 index 0000000..f7b04d2 --- /dev/null +++ b/services/webmail/src/lib/trustedSenders.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { senderKey, readTrusted, clearLocalTrusted } from "./remoteImages"; + +/** + * The senders the signed-in account trusts, kept on the server. + * + * One decision does both jobs: mail from a trusted sender is not filed as + * spam, and its remote images load without asking. It used to be a list in one + * browser's storage, so another browser, a cleared cache or a private window + * asked again - and moving a message out of Junk taught the filter nothing. + * + * What an earlier version remembered in this browser is moved up the first + * time the list loads, so nobody has to decide a second time. + */ +export function useTrustedSenders() { + const [senders, setSenders] = useState>(new Set()); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/v1/user/trusted-senders", { cache: "no-store" }); + if (!res.ok) return; + const known = new Set(((await res.json()) as { senders: string[] }).senders); + + const local = readTrusted().filter((address) => !known.has(address)); + const moved = await Promise.all( + local.map((address) => + fetch("/api/v1/user/trusted-senders", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address }), + }).then((r) => r.ok), + ), + ); + local.forEach((address, i) => moved[i] && known.add(address)); + // Only cleared when every entry made it: a failure keeps the browser's + // copy, so the next load tries again. + if (moved.every(Boolean)) clearLocalTrusted(); + + if (!cancelled) setSenders(known); + } catch { + // Nothing is trusted this session, which is the safe direction. + } finally { + if (!cancelled) setReady(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const isTrusted = useCallback((from: string) => senders.has(senderKey(from)), [senders]); + + const change = useCallback(async (from: string, trusted: boolean): Promise => { + const address = senderKey(from); + if (!address) return false; + const apply = (on: boolean) => + setSenders((current) => { + const next = new Set(current); + if (on) next.add(address); + else next.delete(address); + return next; + }); + apply(trusted); + try { + const res = trusted + ? await fetch("/api/v1/user/trusted-senders", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address }), + }) + : await fetch(`/api/v1/user/trusted-senders?address=${encodeURIComponent(address)}`, { method: "DELETE" }); + if (!res.ok) throw new Error("rejected"); + return true; + } catch { + apply(!trusted); + return false; + } + }, []); + + return { + ready, + isTrusted, + trust: (from: string) => change(from, true), + revoke: (from: string) => change(from, false), + }; +} diff --git a/services/webmail/src/snippet.test.ts b/services/webmail/src/snippet.test.ts index 366e19b..3bb74de 100644 --- a/services/webmail/src/snippet.test.ts +++ b/services/webmail/src/snippet.test.ts @@ -16,6 +16,9 @@ describe("plainSnippet", () => { it("drops a stylesheet instead of previewing its rules", () => { expect(plainSnippet("

Hi

")).toBe("Hi"); }); + it("drops a tag the preview was cut in the middle of", () => { + expect(plainSnippet("

Open cyber model access { expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b"); });