mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: hold remote content per account and until scoped locks are known, also in previews and drafts
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: {} });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user