feat(webmail): trusted senders on the server, Not spam, and a trust pill in the reader
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7f9659b5e
commit
d2611d39fa
9 files changed
+203
-37
No files matched your search
@@ -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<MessageSummary[]>([]);
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(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<ListFilter>(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");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
|
||||
Reference in new issue
Block a user