From 128f61646e8ceae7d93d91054e7515d588463ab7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 06:42:46 +0100
Subject: [PATCH] fix: hold remote content per account and until scoped locks
are known, also in previews and drafts
---
.../remote-content-account-hold.test.tsx | 175 ++++++++++++++++++
components/email/email-composer.tsx | 6 +-
components/email/email-viewer.tsx | 6 +-
components/email/quoted-html.ts | 14 +-
components/email/resizable-image.tsx | 11 +-
components/email/thread-conversation-view.tsx | 17 +-
components/files/eml-preview.tsx | 8 +-
components/files/file-preview-modal.tsx | 8 +-
hooks/use-third-party-mode.ts | 123 +++++++++---
lib/org/account-subjects.ts | 33 +++-
stores/auth-store.ts | 1 +
stores/policy-store.ts | 10 +
12 files changed, 357 insertions(+), 55 deletions(-)
create mode 100644 components/email/__tests__/remote-content-account-hold.test.tsx
diff --git a/components/email/__tests__/remote-content-account-hold.test.tsx b/components/email/__tests__/remote-content-account-hold.test.tsx
new file mode 100644
index 000000000..2f3368490
--- /dev/null
+++ b/components/email/__tests__/remote-content-account-hold.test.tsx
@@ -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: '
Hello
' } },
+ ...(account ? { sourceClientAccountId: account, sourceAccountId: 'jmap-b' } : {}),
+}) as unknown as Email;
+
+function policy(scopes: unknown[], state: Record = {}) {
+ 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();
+ 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();
+ 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(', 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(', attachments: [] }} />);
+ expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').not.toMatch(/\ssrc="https:\/\/t\.example/);
+ });
+});
+
+describe('remote images in a draft’s own content', () => {
+ const HTML = 'Hi 
';
+
+ 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 ;
+ }
+ const { container } = render();
+ 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);
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index bcae27e81..c1ad4145f 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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 => {
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 177f44be5..2a7f66aef 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -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);
diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts
index 426d6c7f4..d1aa476df 100644
--- a/components/email/quoted-html.ts
+++ b/components/email/quoted-html.ts
@@ -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
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
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);
diff --git a/components/email/resizable-image.tsx b/components/email/resizable-image.tsx
index 906b441a5..bbed84d3c 100644
--- a/components/email/resizable-image.tsx
+++ b/components/email/resizable-image.tsx
@@ -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(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
>
state.addToTrustedSendersBook);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState>(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);
diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx
index 0ff73b06e..8c3256491 100644
--- a/components/files/eml-preview.tsx
+++ b/components/files/eml-preview.tsx
@@ -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");
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index b1bbc10c1..43b5d2695 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -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(null);
const [htmlDoc, setHtmlDoc] = useState(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
diff --git a/hooks/use-third-party-mode.ts b/hooks/use-third-party-mode.ts
index f021184b4..5937513e4 100644
--- a/hooks/use-third-party-mode.ts
+++ b/hooks/use-third-party-mode.ts
@@ -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;
+/**
+ * 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 };
}
diff --git a/lib/org/account-subjects.ts b/lib/org/account-subjects.ts
index 74885eaf6..e6332b7f6 100644
--- a/lib/org/account-subjects.ts
+++ b/lib/org/account-subjects.ts
@@ -17,9 +17,11 @@ import { usePolicyStore } from '@/stores/policy-store';
interface AccountSubjectsState {
/** Resolved subjects by local account id. */
subjects: Record;
+ /** Accounts whose subject was worked out, found or not. */
+ settled: Record;
}
-export const useAccountSubjects = create()(() => ({ subjects: {} }));
+export const useAccountSubjects = create()(() => ({ subjects: {}, settled: {} }));
const pending = new Map();
@@ -29,10 +31,29 @@ type SubjectSource = Parameters[0];
function resolveSubject(accountId: string, client: NonNullable): 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);
+ 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: {} });
}
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index c78734dc5..f68bcd660 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -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
diff --git a/stores/policy-store.ts b/stores/policy-store.ts
index 864436af4..64a9ecc21 100644
--- a/stores/policy-store.ts
+++ b/stores/policy-store.ts
@@ -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()((set, get) => ({
...INITIAL,
subject: null,
+ subjectPending: false,
scopeDomains: null,
loaded: false,
fetching: false,
@@ -210,7 +217,10 @@ export const usePolicyStore = create()((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 });
},