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 > {node.attrs.alt 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 }); },