fix: hold remote content per account and until scoped locks are known, also in previews and drafts

This commit is contained in:
Linus Rath
2026-11-01 06:42:46 +01:00
parent 45d1a0041d
commit 128f61646e
12 changed files with 357 additions and 55 deletions
@@ -0,0 +1,175 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { Editor } from '@tiptap/core';
import { EditorContent, useEditor } from '@tiptap/react';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { resetAccountSubjects, useAccountSubjects } from '@/lib/org/account-subjects';
import { remoteContentHeldBack } from '@/hooks/use-third-party-mode';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import { ResizableImage } from '../resizable-image';
import { serializeEditorContent } from '../quoted-html';
// The organisation's remote-content lock follows the account a message
// belongs to and holds while that account's scopes aren't known yet:
// - a lock in a domain or group scope (not only Everyone's), during the
// window before the account's groups are known and while only the public
// policy subset is in;
// - another signed-in account's message (unified inbox, Pro tab) under that
// account's lock while the active account has none;
// - attachment previews (.eml) and remote images in a draft's own content.
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { EmailViewer } = await import('../email-viewer');
const { EmlPreview } = await import('@/components/files/eml-preview');
const LOCK = { externalContentPolicy: { locked: true, value: 'block' } };
const lockedScope = { id: 'locked', label: 'Locked', match: { domains: ['locked.example'] }, policy: { restrictions: LOCK } };
const groupScope = { id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { restrictions: LOCK } };
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
const message = (account?: string) => ({
id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }],
subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
bodyValues: { '1': { value: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
...(account ? { sourceClientAccountId: account, sourceAccountId: 'jmap-b' } : {}),
}) as unknown as Email;
function policy(scopes: unknown[], state: Record<string, unknown> = {}) {
const raw = { ...DEFAULT_POLICY, scopes } as never;
usePolicyStore.setState({ rawPolicy: raw, policy: DEFAULT_POLICY, loaded: true, fetching: false, partial: false, subjectPending: false, ...state });
}
afterEach(() => {
cleanup();
usePolicyStore.setState({ rawPolicy: DEFAULT_POLICY, policy: DEFAULT_POLICY, subject: null, subjectPending: false, partial: false, loaded: false, fetching: false });
useSettingsStore.setState({ externalContentPolicy: 'ask', trustedSenders: [] });
useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never);
resetAccountSubjects();
});
describe('a lock in a scope', () => {
it('holds while the active account’s groups are being worked out, then follows the scope', () => {
policy([groupScope], { subject: null, subjectPending: true });
expect(remoteContentHeldBack()).toBe(true);
act(() => usePolicyStore.getState().setPolicySubject({ email: 'me@example.org', groups: [] }));
// Not in the group: nothing held back.
expect(remoteContentHeldBack()).toBe(false);
act(() => usePolicyStore.getState().setPolicySubject({ email: 'me@example.org', groups: ['team@example.org'] }));
expect(remoteContentHeldBack()).toBe(true);
});
it('holds while only the public part of the policy is in', () => {
policy([lockedScope], { partial: true, subject: { email: 'me@example.org', groups: [] } });
expect(remoteContentHeldBack()).toBe(true);
});
it('doesn’t hold without scopes once the policy is in', () => {
policy([], { subject: null, subjectPending: true });
expect(remoteContentHeldBack()).toBe(false);
});
});
describe('another signed-in account’s message', () => {
function twoAccounts() {
const other = { getUsername: () => 'b@locked.example', getSharedAccounts: () => [], getAccountId: () => 'jmap-b', getBlobDownloadUrl: () => '', fetchBlob: async () => new Blob() };
useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never);
policy([lockedScope], { subject: { email: 'a@free.example', groups: [] } });
useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@locked.example', groups: [] } }, settled: { 'acct-b': true } });
}
it('stays blocked under that account’s lock although the active account has none', async () => {
twoAccounts();
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] });
const { container } = render(<EmailViewer email={message('acct-b')} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
expect((await screen.findByTestId('external-content-notice')).textContent).toContain('remote_content_blocked');
expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').not.toMatch(/\ssrc="https:\/\/track/);
});
it('loads for the active account’s own trusted sender', async () => {
twoAccounts();
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] });
const { container } = render(<EmailViewer email={message()} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
expect(screen.queryByTestId('external-content-notice')).toBeNull();
expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toMatch(/\ssrc="https:\/\/track/);
});
it('holds while that account’s groups are still being worked out', () => {
const other = { getUsername: () => 'b@x.example', getSharedAccounts: () => [] };
useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never);
policy([groupScope], { subject: { email: 'a@free.example', groups: [] } });
useAccountSubjects.setState({ subjects: {}, settled: {} });
expect(remoteContentHeldBack('acct-b')).toBe(true);
expect(remoteContentHeldBack()).toBe(false);
});
});
describe('attachment previews', () => {
it('keep an attached message’s images blocked until a scoped lock is known, even with "allow"', () => {
setNoThirdPartyTraffic(false);
useSettingsStore.setState({ externalContentPolicy: 'allow' });
// Without the lock the image loads (the control).
const open = render(<EmlPreview message={{ subject: 's', html: '<img src="https://t.example/p.gif">', attachments: [] }} />);
expect(open.container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toMatch(/\ssrc="https:\/\/t\.example/);
cleanup();
// The policy with a scope that may lock it isn't known for this account yet.
policy([groupScope], { subject: null, subjectPending: true });
useSettingsStore.setState({ externalContentPolicy: 'allow' });
const { container } = render(<EmlPreview message={{ subject: 's', html: '<img src="https://t.example/p.gif">', attachments: [] }} />);
expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').not.toMatch(/\ssrc="https:\/\/t\.example/);
});
});
describe('remote images in a draft’s own content', () => {
const HTML = '<p>Hi <img src="https://tracker.example/p.gif"></p>';
it('are serialised without loading them in the app document', () => {
const editor = new Editor({ extensions: [Document, Paragraph, Text, ResizableImage], content: HTML });
const created: Element[] = [];
const original = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation(((tag: string, options?: ElementCreationOptions) => {
const el = original(tag, options);
created.push(el);
return el;
}) as typeof document.createElement);
const html = serializeEditorContent(editor);
vi.restoreAllMocks();
expect(html).toContain('src="https://tracker.example/p.gif"');
expect(created.filter((el) => el.tagName === 'IMG' || el.querySelector('img'))).toEqual([]);
editor.destroy();
});
it('are parked in the editor while the organisation blocks remote content', async () => {
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: LOCK as never }, loaded: true });
function Box() {
const editor = useEditor({ extensions: [Document, Paragraph, Text, ResizableImage], content: HTML, immediatelyRender: true });
return <EditorContent editor={editor} />;
}
const { container } = render(<Box />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
const img = container.querySelector('img');
expect(img?.getAttribute('src')).not.toContain('tracker.example');
expect(img?.hasAttribute('data-remote-parked')).toBe(true);
});
});
+4 -2
View File
@@ -762,7 +762,9 @@ export function EmailComposer({
// Whether the quoted original may show its remote images in the editor:
// the viewer's rule, minus the per-message "load" click it doesn't know.
const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState();
const quoteRemoteAllowed = !remoteContentHeldBack()
// The original's own account decides (replyTo.accountId), and the
// active one, which the composer may send from.
const quoteRemoteAllowed = !remoteContentHeldBack() && !remoteContentHeldBack(replyTo?.accountId)
&& (externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email)));
const replyHeader = (html: string) => mode === 'reply' || mode === 'replyAll'
@@ -3779,7 +3781,7 @@ export function EmailComposer({
// Replying trusts the recipients, but not while the organisation
// blocks remote content: the list would take effect the moment the
// block is lifted, with senders nobody chose to trust.
if ((mode === 'reply' || mode === 'replyAll') && !remoteContentBlockedByOrg()) {
if ((mode === 'reply' || mode === 'replyAll') && !remoteContentBlockedByOrg() && !remoteContentBlockedByOrg(composingAccountId)) {
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, recipient).catch(err => {
+4 -2
View File
@@ -1317,9 +1317,11 @@ export function EmailViewer({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
// No third-party traffic (#903), or the organisation locked "block": remote
// content never loads, whatever trust says, and nothing offers to load it.
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
// The policy of the message's own account: in the unified inbox or a Pro
// tab that may be another signed-in account than the active one.
const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email?.sourceClientAccountId);
// Also true while the policy is still loading: trust can't unblock yet.
const remoteHeldBack = useRemoteContentHeldBack();
const remoteHeldBack = useRemoteContentHeldBack(email?.sourceClientAccountId);
const tThirdParty = useTranslations('third_party');
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
+10 -4
View File
@@ -9,7 +9,7 @@ import { addParagraphSpacing, addTrailingParagraphBreaks } from "@/components/em
import { buildSignatureBlock } from "@/components/email/signature-block";
import { parkRemoteResources, restoreRemoteResources, sanitizeComposerIslandHtml } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
import { remoteContentHeldBack } from "@/hooks/use-third-party-mode";
import { remoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
// Marker attribute that identifies the quoted-original wrapper in serialized
// HTML, so parseHTML can recognise it on the way back in.
@@ -170,7 +170,9 @@ export const QuotedHtml = TiptapNode.create({
// A draft re-opened after the organisation blocked remote content (or
// before its policy loaded) keeps its quote parked, whatever the
// wrapper remembered from when the draft was started.
const blockRemote = remoteContentHeldBack()
// The draft doesn't say which account it quotes: held back in any
// signed-in account keeps it parked.
const blockRemote = remoteContentHeldBackAnywhere()
|| (!node.attrs.remoteAllowed && useSettingsStore.getState().externalContentPolicy !== "allow");
let renderedHtml = "";
const render = (html: string) => {
@@ -257,6 +259,10 @@ export const QuotedHtml = TiptapNode.create({
*/
export function serializeEditorContent(editor: Editor): string {
const serializer = DOMSerializer.fromSchema(editor.schema);
// Built in an inert document: an <img> made in the app document starts
// loading at once, so serialising (on every change) fired every remote
// image of the content, also while remote content is blocked.
const inert = document.implementation.createHTMLDocument("");
const parts: string[] = [];
// Empty paragraphs at the very end (the editor keeps one after a quote)
// stay invisible; every other empty line gets a <br> so it keeps its
@@ -280,8 +286,8 @@ export function serializeEditorContent(editor: Editor): string {
parts.push(buildSignatureBlock((node.attrs.html as string) || ""));
return;
}
const fragment = serializer.serializeNode(node);
const tmp = document.createElement("div");
const fragment = serializer.serializeNode(node, { document: inert });
const tmp = inert.createElement("div");
tmp.appendChild(fragment);
if (index <= lastLine) addTrailingParagraphBreaks(tmp);
addParagraphSpacing(tmp);
+10 -1
View File
@@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { Node, mergeAttributes } from "@tiptap/core";
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
import { TRANSPARENT_BLOCKED_PIXEL } from "@/lib/email-sanitization";
export interface ResizableImageOptions {
/** Open the alt text dialog for the image at this position. */
@@ -16,6 +18,12 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions;
const imgRef = useRef<HTMLImageElement>(null);
const [resizing, setResizing] = useState(false);
// A remote image (a draft from another client, pasted web content) stays
// parked while the organisation blocks remote content or its policy isn't
// known yet; the address stays in the message.
const heldBack = useRemoteContentHeldBackAnywhere();
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
const parked = heldBack && /^(https?:)?\/\//i.test(src.trim());
const startState = useRef<{ x: number; y: number; width: number; height: number; handle: string }>({
x: 0, y: 0, width: 0, height: 0, handle: "",
});
@@ -78,7 +86,8 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
>
<img
ref={imgRef}
src={node.attrs.src}
src={parked ? TRANSPARENT_BLOCKED_PIXEL : node.attrs.src}
data-remote-parked={parked ? "" : undefined}
alt={node.attrs.alt || ""}
title={node.attrs.title || undefined}
style={{ width: "100%", height: "auto", display: "block" }}
@@ -67,7 +67,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { useRemoteContentBlockedByOrg, useRemoteContentHeldBack } from "@/hooks/use-third-party-mode";
import { useRemoteContentBlockedByOrg, useRemoteContentChecks } from "@/hooks/use-third-party-mode";
import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions";
import { PhishingMarkBanner } from "./phishing-mark-banner";
import { isPhishingMarked, turnOffLinks } from "@/lib/phishing-links";
@@ -110,11 +110,10 @@ export function useConversationExternalContent() {
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
// No third-party traffic (#903), or the organisation locked "block":
// nothing remote loads and nothing offers to.
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
// Also true until the policy has loaded: trust can't unblock before it.
const remoteHeldBack = useRemoteContentHeldBack();
// No third-party traffic (#903), or the organisation locked "block" for
// the message's own account: nothing remote loads and nothing offers to.
// Also held back until that account's policy is known.
const remoteContent = useRemoteContentChecks();
const allow = useCallback((emailId: string) => {
setAllowedIds(prev => new Set(prev).add(emailId));
@@ -125,13 +124,13 @@ export function useConversationExternalContent() {
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
if (remoteHeldBack) return false;
if (remoteContent.heldBack(email.sourceClientAccountId)) return false;
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
};
const trustSender = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
if (!senderEmail || remoteBlockedByOrg) return undefined;
if (!senderEmail || remoteContent.blockedByOrg(email.sourceClientAccountId)) return undefined;
return () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
@@ -549,7 +548,7 @@ export function ConversationEmailBody({
bodyStyle,
className,
}: ConversationEmailBodyProps) {
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email.sourceClientAccountId);
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
+5 -3
View File
@@ -7,7 +7,7 @@ import { getEffectiveTimeZone } from "@/lib/timezone";
import { stripBidiControls } from "@/lib/bidi";
import { isFilePreviewable } from "@/lib/file-preview";
import { useSettingsStore } from "@/stores/settings-store";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
// this preview renders are typed.
@@ -53,8 +53,10 @@ export function EmlPreview({ message, onOpenAttachment }: {
onOpenAttachment?: (attachment: EmlAttachment) => void;
}) {
const t = useTranslations("email_viewer");
// Always blocked with no third-party traffic (#903).
const remoteBlockedByOrg = useNoThirdPartyTraffic();
// Always blocked with no third-party traffic (#903), while an
// organisation locks "block" in any signed-in account (the attachment's
// account isn't known here), and until the policy is known.
const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
const tFiles = useTranslations("files");
+5 -3
View File
@@ -17,7 +17,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza
import { useSettingsStore } from "@/stores/settings-store";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import type { FileResourceView } from "@/lib/plugin-types";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
/**
* An HTML attachment as a document for a fully locked frame (sandbox="",
@@ -163,8 +163,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// open-in-new-tab fallback use.
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [htmlDoc, setHtmlDoc] = useState<string | null>(null);
// Always blocked with no third-party traffic (#903).
const remoteBlockedByOrg = useNoThirdPartyTraffic();
// Always blocked with no third-party traffic (#903), while an
// organisation locks "block" in any signed-in account (the attachment's
// account isn't known here), and until the policy is known.
const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
// An attachment of the previewed .eml, shown in a preview of its own in
// place of this one. It holds a Back entry of its own, so Escape and Back
+98 -25
View File
@@ -2,7 +2,10 @@
import { useSyncExternalStore } from 'react';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
import type { SettingsPolicy } from '@/lib/admin/types';
import { accountSubjectPending, subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
/**
* Whether the browser must stay off built-in outside hosts (#903), as React
@@ -15,12 +18,22 @@ export function useNoThirdPartyTraffic(): boolean {
type PolicyState = ReturnType<typeof usePolicyStore.getState>;
/**
* The effective policy of a signed-in account (AccountEntry id): the active
* one's from the store, another one's (a message of it in the unified
* inbox or a Pro tab) resolved for its own domain and group scopes.
*/
function policyForAccount(state: PolicyState, accountId: string | null | undefined): SettingsPolicy {
const subject = subjectForAccount(accountId ?? null);
return subject === state.subject ? state.policy : state.resolveFor(subject);
}
/**
* The external content setting locked to "block" by the organisation, or
* limited to "block" as its only allowed value.
*/
function externalContentLockedToBlock(state: PolicyState): boolean {
const restriction = state.getRestriction('externalContentPolicy');
function externalContentLockedToBlock(policy: SettingsPolicy): boolean {
const restriction = policy.restrictions?.externalContentPolicy;
const allowed = restriction?.allowedValues;
if (Array.isArray(allowed) && allowed.length > 0 && !allowed.includes('allow') && !allowed.includes('ask')) return true;
if (restriction?.locked !== true) return false;
@@ -28,43 +41,103 @@ function externalContentLockedToBlock(state: PolicyState): boolean {
// settings store applies it).
const value = Object.prototype.hasOwnProperty.call(restriction, 'value')
? restriction.value
: state.getEffectiveDefault('externalContentPolicy');
: policy.defaults?.externalContentPolicy;
return value === 'block';
}
/** Another signed-in account than the active one. */
function isOtherAccount(accountId: string | null | undefined): accountId is string {
const auth = useAuthStore.getState();
return !!accountId && accountId !== auth.activeAccountId && !!auth.getClientForAccount?.(accountId);
}
/**
* Whether the organisation keeps remote content in mail from loading at all:
* no third-party traffic, or the external content setting locked to "block".
* Then neither a one-off load nor a trusted sender lets it through.
* Whether the policy that may lock remote content for `accountId` isn't
* known yet: the first fetch hasn't answered, only the public subset is in
* (just after sign-in), or the policy has domain or group scopes and the
* account's groups are still being worked out. A later refetch keeps the
* policy it already has, so it doesn't count.
*/
export function useRemoteContentBlockedByOrg(): boolean {
const noThirdParty = useNoThirdPartyTraffic();
const lockedToBlock = usePolicyStore(externalContentLockedToBlock);
return noThirdParty || lockedToBlock;
function policyPending(state: PolicyState, accountId: string | null | undefined): boolean {
if (state.fetching && !state.loaded) return true;
if (state.partial) return true;
if ((state.rawPolicy.scopes ?? []).length === 0) return false;
return isOtherAccount(accountId) ? accountSubjectPending(accountId) : state.subjectPending;
}
/** useRemoteContentBlockedByOrg, read once outside React. */
export function remoteContentBlockedByOrg(): boolean {
return noThirdPartyTraffic() || externalContentLockedToBlock(usePolicyStore.getState());
/** Re-renders on anything the remote-content decisions read. */
function usePolicyInputs(): void {
useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true);
usePolicyStore((s) => s.policy);
usePolicyStore((s) => s.rawPolicy);
usePolicyStore((s) => s.subject);
usePolicyStore((s) => s.subjectPending);
usePolicyStore((s) => s.fetching);
usePolicyStore((s) => s.loaded);
usePolicyStore((s) => s.partial);
useAccountSubjects((s) => s.subjects);
useAccountSubjects((s) => s.settled);
useAuthStore((s) => s.activeAccountId);
}
// The first policy fetch hasn't answered yet (a later refetch keeps the
// policy it already has, so it doesn't count).
const policyPending = (state: PolicyState) => state.fetching && !state.loaded;
/**
* Whether the organisation keeps remote content in mail from loading at all
* for the account a message belongs to (the active one when left out): no
* third-party traffic, or the external content setting locked to "block" in
* that account's policy. Then neither a one-off load nor a trusted sender
* lets it through.
*/
export function remoteContentBlockedByOrg(accountId?: string | null): boolean {
return noThirdPartyTraffic() || externalContentLockedToBlock(policyForAccount(usePolicyStore.getState(), accountId));
}
/**
* Whether remote content stays blocked whatever the user chose: blocked by
* the organisation, or the policy that may lock it hasn't loaded yet. Until
* it has, neither a trusted sender nor a one-off load lets anything through,
* the organisation, or the policy that may lock it isn't known yet. Until
* it is, neither a trusted sender nor a one-off load lets anything through,
* so the first paint can't fire a tracker the organisation blocks.
*/
export function useRemoteContentHeldBack(): boolean {
const blockedByOrg = useRemoteContentBlockedByOrg();
const pending = usePolicyStore(policyPending);
return blockedByOrg || pending;
export function remoteContentHeldBack(accountId?: string | null): boolean {
return remoteContentBlockedByOrg(accountId) || policyPending(usePolicyStore.getState(), accountId);
}
/** useRemoteContentHeldBack, read once outside React. */
export function remoteContentHeldBack(): boolean {
return remoteContentBlockedByOrg() || policyPending(usePolicyStore.getState());
/**
* remoteContentHeldBack for every signed-in account: for content whose
* account isn't known (a re-opened draft's quote, which may come from any).
*/
export function remoteContentHeldBackAnywhere(): boolean {
const connected = useAuthStore.getState().getAllConnectedClients?.();
const ids = connected ? [...connected.keys()] : [];
return remoteContentHeldBack() || ids.some((id) => remoteContentHeldBack(id));
}
/** remoteContentHeldBackAnywhere as React state (attachment previews, whose account isn't known). */
export function useRemoteContentHeldBackAnywhere(): boolean {
usePolicyInputs();
useAuthStore((s) => s.connectedAccountsRevision);
return remoteContentHeldBackAnywhere();
}
/** remoteContentBlockedByOrg as React state, for the account a message belongs to. */
export function useRemoteContentBlockedByOrg(accountId?: string | null): boolean {
usePolicyInputs();
return remoteContentBlockedByOrg(accountId);
}
/** remoteContentHeldBack as React state, for the account a message belongs to. */
export function useRemoteContentHeldBack(accountId?: string | null): boolean {
usePolicyInputs();
return remoteContentHeldBack(accountId);
}
/**
* For views with messages of several accounts (a conversation): re-renders
* on what the decisions read and hands back the per-account checks.
*/
export function useRemoteContentChecks(): {
heldBack: (accountId?: string | null) => boolean;
blockedByOrg: (accountId?: string | null) => boolean;
} {
usePolicyInputs();
return { heldBack: remoteContentHeldBack, blockedByOrg: remoteContentBlockedByOrg };
}
+27 -6
View File
@@ -17,9 +17,11 @@ import { usePolicyStore } from '@/stores/policy-store';
interface AccountSubjectsState {
/** Resolved subjects by local account id. */
subjects: Record<string, PolicySubject>;
/** Accounts whose subject was worked out, found or not. */
settled: Record<string, true>;
}
export const useAccountSubjects = create<AccountSubjectsState>()(() => ({ subjects: {} }));
export const useAccountSubjects = create<AccountSubjectsState>()(() => ({ subjects: {}, settled: {} }));
const pending = new Map<string, unknown>();
@@ -29,10 +31,29 @@ type SubjectSource = Parameters<typeof policySubjectForClient>[0];
function resolveSubject(accountId: string, client: NonNullable<SubjectSource>): void {
if (pending.get(accountId) === client) return;
pending.set(accountId, client);
void policySubjectForClient(client).then((subject) => {
if (pending.get(accountId) !== client || !subject) return;
useAccountSubjects.setState((state) => ({ subjects: { ...state.subjects, [accountId]: subject } }));
}).catch(() => {});
const settle = (subject: PolicySubject | null) => {
if (pending.get(accountId) !== client) return;
useAccountSubjects.setState((state) => ({
subjects: subject ? { ...state.subjects, [accountId]: subject } : state.subjects,
settled: { ...state.settled, [accountId]: true },
}));
};
void policySubjectForClient(client).then(settle, () => settle(null));
}
/**
* Whether another signed-in account's subject is still being worked out,
* so its group scopes may not apply yet. False for the active account
* (see the policy store's subjectPending) and for accounts not signed in.
*/
export function accountSubjectPending(accountId: string | null | undefined): boolean {
const auth = useAuthStore.getState();
if (!accountId || accountId === auth.activeAccountId) return false;
const client = auth.getClientForAccount?.(accountId);
if (!client) return false;
if (useAccountSubjects.getState().settled[accountId]) return false;
resolveSubject(accountId, client as unknown as NonNullable<SubjectSource>);
return !useAccountSubjects.getState().settled[accountId];
}
/**
@@ -57,5 +78,5 @@ export function subjectForAccount(accountId: string | null | undefined): PolicyS
/** For tests: forget every subject worked out. */
export function resetAccountSubjects(): void {
pending.clear();
useAccountSubjects.setState({ subjects: {} });
useAccountSubjects.setState({ subjects: {}, settled: {} });
}
+1
View File
@@ -3291,6 +3291,7 @@ useAuthStore.subscribe((state, prev) => {
usePolicyStore.getState().setPolicySubject(null);
return;
}
usePolicyStore.getState().setSubjectPending(true);
void policySubjectForClient(state.client).then(async (subject) => {
if (request !== policySubjectRequest) return;
// The server sends only the scopes of the domains signed in when the
+10
View File
@@ -18,6 +18,12 @@ interface PolicyState {
rawPolicy: SettingsPolicy;
/** The account `policy` was resolved for; null before one is known. */
subject: PolicySubject | null;
/**
* The active account's subject is being worked out (signed in, its groups
* not known yet). Until it is, a group scope's lock may not be applied yet.
*/
subjectPending: boolean;
setSubjectPending: (pending: boolean) => void;
/** Ids of the scopes that apply to `subject`. */
matchedScopes: string[];
/** What set each non-default value of `policy` (see ResolvedPolicy.sources). */
@@ -150,6 +156,7 @@ const INITIAL = resolved({ ...DEFAULT_POLICY }, null);
export const usePolicyStore = create<PolicyState>()((set, get) => ({
...INITIAL,
subject: null,
subjectPending: false,
scopeDomains: null,
loaded: false,
fetching: false,
@@ -210,7 +217,10 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
}
},
setSubjectPending: (pending) => { if (get().subjectPending !== pending) set({ subjectPending: pending }); },
setPolicySubject: (subject) => {
if (get().subjectPending) set({ subjectPending: false });
if (sameSubject(subject, get().subject)) return;
set({ ...resolved(get().rawPolicy, subject), subject });
},