From b504e39b63f05ff061423f5b8ffdaf7fcfbaba09 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:53:39 +0100 Subject: [PATCH 01/54] feat: label mail from outside the organisation --- .../__tests__/external-sender-chip.test.tsx | 97 +++++++++++++ components/email/email-viewer.tsx | 5 + components/email/external-sender-chip.tsx | 50 +++++++ components/email/thread-conversation-view.tsx | 2 + lib/__tests__/external-mail.test.ts | 129 ++++++++++++++++++ lib/admin/organisation-policy.ts | 6 +- lib/external-mail.ts | 119 ++++++++++++++++ locales/de/common.json | 4 + locales/en/common.json | 4 + 9 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 components/email/__tests__/external-sender-chip.test.tsx create mode 100644 components/email/external-sender-chip.tsx create mode 100644 lib/__tests__/external-mail.test.ts create mode 100644 lib/external-mail.ts diff --git a/components/email/__tests__/external-sender-chip.test.tsx b/components/email/__tests__/external-sender-chip.test.tsx new file mode 100644 index 000000000..a14269288 --- /dev/null +++ b/components/email/__tests__/external-sender-chip.test.tsx @@ -0,0 +1,97 @@ +import { render, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Identity, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; + +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: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { ExternalSenderChip } = await import('../external-sender-chip'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const message = (from: string) => ({ + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: from }], to: [{ name: 'Org Alice', email: 'org-alice@example.org' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: '

Body

' } }, +}) as unknown as Email; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +async function openMessage(from: string) { + const view = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + return view; +} + +beforeEach(() => { + clearAllHooks(); + setExternalMail({}); + useAuthStore.setState({ identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] }); +}); + +describe('External sender chip', () => { + it('names the sender domain in its tooltip when the label is on', () => { + setExternalMail({ senderLabel: true }); + const { container } = render(); + const chip = container.querySelector('[data-external-sender-chip]'); + expect(chip?.textContent).toContain('sender_label'); + expect(chip?.getAttribute('title')).toBe('sender_tooltip'); + }); + + it('shows in the reader header for a sender outside the organisation', async () => { + setExternalMail({ senderLabel: true }); + const { container } = await openMessage('org-dave@org-two.test'); + expect(container.querySelectorAll('[data-external-sender-chip]').length).toBeGreaterThan(0); + }); + + it('stays away for a sender in an identity domain or its subdomain', async () => { + setExternalMail({ senderLabel: true }); + const { container } = await openMessage('org-bob@example.org'); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + const sub = render(); + expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); + + it('stays away for a domain the admin listed', async () => { + setExternalMail({ senderLabel: true, domains: ['org-two.test'] }); + const { container } = await openMessage('org-dave@org-two.test'); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); + + it('stays away while the label is off, even with the recipient warning on', async () => { + setExternalMail({ senderLabel: false, recipientWarning: true }); + const { container } = await openMessage('org-dave@org-two.test'); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 54a8409d6..573d48422 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -114,6 +114,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useEmailStore, resolveEmailBlobContext, emailWriteSource, emailLinkSource, type EmailBlobContext } from "@/stores/email-store"; import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; +import { ExternalSenderChip } from "./external-sender-chip"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { ReadReceiptBanner } from "./read-receipt-banner"; @@ -854,6 +855,7 @@ export function MvExpandedConversationCard({ {!compact && sender?.name && sender.email && ( <{sender.email}> )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -5045,6 +5047,7 @@ export function EmailViewer({ {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( {t('unknown_sender')} )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + {senderVerification && ( s.policy.organisation?.externalMail); + const identities = useAuthStore((s) => s.identities); + return useMemo(() => { + if (!externalMail?.senderLabel) return null; + const internal = internalDomains(externalMail.domains, (identities ?? []).map((identity) => identity.email)); + return externalSenderDomain(fromEmail, internal); + }, [externalMail, identities, fromEmail]); +} + +/** + * "External" next to the sender's name. A chip and not a banner, so it sits + * beside the unverified-sender warning without adding a second banner, and + * it keeps to one short word so the narrow phone header doesn't wrap more. + */ +export function ExternalSenderChip({ fromEmail, className }: { fromEmail: string | null | undefined; className?: string }) { + const t = useTranslations('external_mail'); + const domain = useExternalSenderDomain(fromEmail); + if (!domain) return null; + const tooltip = t('sender_tooltip', { domain }); + return ( + + + ); +} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4cde880b6..26c19ad7f 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -11,6 +11,7 @@ import { emailDisplayDate } from "@/lib/email-date"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code"; import { VerificationCodeChip } from "./verification-code-chip"; +import { ExternalSenderChip } from "./external-sender-chip"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; @@ -396,6 +397,7 @@ function EmailCard({ )}> {sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"} + {isStarred && ( )} diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts new file mode 100644 index 000000000..5f07e76ca --- /dev/null +++ b/lib/__tests__/external-mail.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + addressDomain, + contactAddresses, + coreExternalWarningsOn, + currentInternalDomains, + domainMatches, + externalRecipients, + externalSenderDomain, + internalDomains, + isExternalAddress, +} from '../external-mail'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; +import type { ContactCard, Identity } from '@/lib/jmap/types'; + +const identity = (email: string) => ({ id: email, name: '', email }) as unknown as Identity; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +describe('addressDomain', () => { + it('takes the domain of bare and named addresses, lowercased', () => { + expect(addressDomain('Bob@Example.COM')).toBe('example.com'); + expect(addressDomain('Bob ')).toBe('eu.example.com'); + expect(addressDomain('no-at-sign')).toBe(''); + expect(addressDomain(undefined)).toBe(''); + }); +}); + +describe('domainMatches', () => { + it('matches the domain itself and its subdomains, nothing else', () => { + expect(domainMatches('example.com', ['example.com'])).toBe(true); + expect(domainMatches('eu.example.com', ['example.com'])).toBe(true); + expect(domainMatches('a.b.example.com', ['example.com'])).toBe(true); + // A suffix that isn't a subdomain doesn't count. + expect(domainMatches('badexample.com', ['example.com'])).toBe(false); + expect(domainMatches('example.com.evil.net', ['example.com'])).toBe(false); + // A subdomain in the list doesn't cover its parent. + expect(domainMatches('example.com', ['eu.example.com'])).toBe(false); + }); +}); + +describe('internalDomains', () => { + it('always counts the identity domains and adds the admin list', () => { + expect(internalDomains([], ['me@example.org'])).toEqual(['example.org']); + expect(internalDomains(['partner.com'], ['me@example.org', 'alias@Example.org'])).toEqual(['partner.com', 'example.org']); + }); +}); + +describe('isExternalAddress', () => { + const internal = internalDomains(['partner.com'], ['me@example.org']); + it('is external only outside the identity domains and the list, subdomains included', () => { + expect(isExternalAddress('bob@example.org', internal)).toBe(false); + expect(isExternalAddress('x@eu.partner.com', internal)).toBe(false); + expect(isExternalAddress('dave@org-two.test', internal)).toBe(true); + }); + it('calls nobody external when no internal domain is known', () => { + expect(isExternalAddress('dave@org-two.test', [])).toBe(false); + }); +}); + +describe('externalRecipients', () => { + const internal = ['example.org']; + it('lists only the external recipients, each once, with the field it is in', () => { + const result = externalRecipients( + { to: ['bob@example.org', 'dave@org-two.test'], cc: ['DAVE@org-two.test', 'eve@other.net'], bcc: ['x@sub.example.org'] }, + internal, + ); + expect(result).toEqual([ + { address: 'dave@org-two.test', domain: 'org-two.test', field: 'to' }, + { address: 'eve@other.net', domain: 'other.net', field: 'cc' }, + ]); + }); + it('leaves out trusted addresses (contacts)', () => { + const trusted = new Set(['dave@org-two.test']); + const result = externalRecipients({ to: ['Dave@org-two.test', 'eve@other.net'], cc: [], bcc: [] }, internal, (a) => trusted.has(a)); + expect(result.map((r) => r.address)).toEqual(['eve@other.net']); + }); +}); + +describe('externalSenderDomain', () => { + it('names the domain of an external sender and nothing for internal ones', () => { + expect(externalSenderDomain('dave@org-two.test', ['example.org'])).toBe('org-two.test'); + expect(externalSenderDomain('bob@example.org', ['example.org'])).toBeNull(); + expect(externalSenderDomain(undefined, ['example.org'])).toBeNull(); + }); +}); + +describe('the active account', () => { + beforeEach(() => { + setExternalMail({}); + useAuthStore.setState({ identities: [] }); + useContactStore.setState({ contacts: [] }); + }); + + it('reads the switches from the effective policy', () => { + expect(coreExternalWarningsOn()).toBe(false); + setExternalMail({ senderLabel: true }); + expect(coreExternalWarningsOn()).toBe(true); + setExternalMail({ recipientWarning: true }); + expect(coreExternalWarningsOn()).toBe(true); + }); + + it('combines the policy domains with the identity domains', () => { + setExternalMail({ domains: ['partner.com'] }); + useAuthStore.setState({ identities: [identity('org-alice@example.org')] }); + expect(currentInternalDomains()).toEqual(['partner.com', 'example.org']); + }); + + it('collects the addresses of every contact', () => { + useContactStore.setState({ + contacts: [ + { id: 'c1', emails: { e1: { address: 'Dave@org-two.test' }, e2: { address: 'second@x.test' } } }, + { id: 'c2' }, + ] as unknown as ContactCard[], + }); + expect([...contactAddresses()].sort()).toEqual(['dave@org-two.test', 'second@x.test']); + }); +}); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index 55769a4e3..77562da19 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -25,7 +25,11 @@ export interface OrganisationPolicy { senderLabel: boolean; /** Confirm before sending to recipients outside the organisation domains. */ recipientWarning: boolean; - /** Organisation domains; empty means the domains of the user's identities. Subdomains match. */ + /** + * More organisation domains. The domains of the user's own identities + * always count as internal; these are added to them. A domain covers its + * subdomains (example.com covers eu.example.com). See lib/external-mail.ts. + */ domains: string[]; /** Recipients in the user's contacts don't trigger the warning. */ trustContacts: boolean; diff --git a/lib/external-mail.ts b/lib/external-mail.ts new file mode 100644 index 000000000..d81add2b0 --- /dev/null +++ b/lib/external-mail.ts @@ -0,0 +1,119 @@ +/** + * Who is outside the organisation, for the "External" sender chip and the + * confirm before sending outside it (policy.json organisation.externalMail). + * + * Internal are the domains of the user's own identities plus the domains + * the admin lists; a listed domain covers its subdomains (example.com + * covers eu.example.com). With no internal domain known at all nobody can + * be told apart, so nothing counts as external. + * + * The pure functions take everything as arguments; the `current*` helpers + * read the active account's effective policy and identities. + */ + +import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; + +export type ExternalMailPolicy = OrganisationPolicy['externalMail']; + +/** The lowercased domain of an address ("Name " or "a@b"); '' without one. */ +export function addressDomain(address: string | null | undefined): string { + const raw = String(address ?? '').trim(); + const bracket = raw.match(/<([^<>]*)>\s*$/); + const bare = (bracket ? bracket[1] : raw).trim(); + const at = bare.lastIndexOf('@'); + if (at < 0) return ''; + return bare.slice(at + 1).trim().toLowerCase().replace(/\.+$/, ''); +} + +/** True when `domain` is one of `internal` or a subdomain of one. */ +export function domainMatches(domain: string, internal: readonly string[]): boolean { + if (!domain) return false; + const d = domain.toLowerCase(); + return internal.some((entry) => d === entry || d.endsWith(`.${entry}`)); +} + +/** The admin's domains plus the domains of the user's identities, normalised and deduplicated. */ +export function internalDomains(policyDomains: readonly string[], identityEmails: readonly string[]): string[] { + const own = identityEmails.map(addressDomain).filter(Boolean); + return normalizeDomainList([...policyDomains, ...own]) ?? []; +} + +/** Whether mail from or to this address leaves the organisation. */ +export function isExternalAddress(address: string, internal: readonly string[]): boolean { + if (internal.length === 0) return false; + const domain = addressDomain(address); + return !!domain && !domainMatches(domain, internal); +} + +export type RecipientField = 'to' | 'cc' | 'bcc'; + +export interface ExternalRecipient { + address: string; + domain: string; + field: RecipientField; +} + +/** + * The recipients outside the organisation, each address once (in the first + * field it appears in). `trusted` is asked for the rest: with "trust + * contacts" on, an address in the user's contacts doesn't count. + */ +export function externalRecipients( + recipients: { to: readonly string[]; cc: readonly string[]; bcc: readonly string[] }, + internal: readonly string[], + trusted?: (address: string) => boolean, +): ExternalRecipient[] { + const out: ExternalRecipient[] = []; + const seen = new Set(); + for (const field of ['to', 'cc', 'bcc'] as const) { + for (const raw of recipients[field]) { + const address = String(raw ?? '').trim(); + const key = address.toLowerCase(); + if (!address || seen.has(key)) continue; + seen.add(key); + if (!isExternalAddress(address, internal)) continue; + if (trusted?.(key)) continue; + out.push({ address, domain: addressDomain(address), field }); + } + } + return out; +} + +/** The sender's domain when it is outside the organisation, else null. */ +export function externalSenderDomain(fromEmail: string | null | undefined, internal: readonly string[]): string | null { + if (!fromEmail || !isExternalAddress(fromEmail, internal)) return null; + return addressDomain(fromEmail); +} + +// ─── The active account ────────────────────────────────────── + +/** The externalMail settings of the active account's effective policy. */ +export function currentExternalMailPolicy(): ExternalMailPolicy { + return usePolicyStore.getState().getOrganisation().externalMail ?? DEFAULT_ORGANISATION_POLICY.externalMail; +} + +/** Either core switch is on: the label or the recipient warning. */ +export function coreExternalWarningsOn(policy: ExternalMailPolicy = currentExternalMailPolicy()): boolean { + return policy.senderLabel || policy.recipientWarning; +} + +/** The internal domains for the active account. */ +export function currentInternalDomains(policy: ExternalMailPolicy = currentExternalMailPolicy()): string[] { + const identities = useAuthStore.getState().identities ?? []; + return internalDomains(policy.domains, identities.map((identity) => identity.email)); +} + +/** Lowercased addresses of every loaded contact (all address books). */ +export function contactAddresses(): Set { + const out = new Set(); + for (const contact of useContactStore.getState().contacts) { + for (const entry of Object.values(contact.emails ?? {})) { + const address = entry?.address?.trim().toLowerCase(); + if (address) out.add(address); + } + } + return out; +} diff --git a/locales/de/common.json b/locales/de/common.json index ffdd16235..73f54a823 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -659,6 +659,10 @@ "plugin_panel_hide": "Plugin-Bereich ausblenden", "copy": "Kopieren" }, + "external_mail": { + "sender_label": "Extern", + "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}" + }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index 95ec87fa3..60734aa9d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -659,6 +659,10 @@ "plugin_panel_show": "Show plugin panel", "plugin_panel_hide": "Hide plugin panel" }, + "external_mail": { + "sender_label": "External", + "sender_tooltip": "From outside your organisation: {domain}" + }, "email_composer": { "select_from_directory": "Select from directory", "minimize": "Minimize", From 2855ca00104847d96efec089083d2d7dda201d35 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:59:13 +0100 Subject: [PATCH 02/54] feat: confirm before sending to recipients outside the organisation --- app/(main)/[locale]/layout.tsx | 2 + .../composer-external-recipients.test.tsx | 278 ++++++++++++++++++ .../email/external-recipient-confirm.tsx | 83 ++++++ .../__tests__/external-recipients.test.ts | 112 +++++++ lib/outgoing/external-recipients.ts | 74 +++++ locales/de/common.json | 9 +- locales/en/common.json | 9 +- stores/external-recipient-confirm-store.ts | 31 ++ 8 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/composer-external-recipients.test.tsx create mode 100644 components/email/external-recipient-confirm.tsx create mode 100644 lib/outgoing/__tests__/external-recipients.test.ts create mode 100644 lib/outgoing/external-recipients.ts create mode 100644 stores/external-recipient-confirm-store.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 76f9396ad..eab9e2482 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; @@ -63,6 +64,7 @@ export default async function LocaleLayout({ {children} + diff --git a/components/email/__tests__/composer-external-recipients.test.tsx b/components/email/__tests__/composer-external-recipients.test.tsx new file mode 100644 index 000000000..c3a5ea328 --- /dev/null +++ b/components/email/__tests__/composer-external-recipients.test.tsx @@ -0,0 +1,278 @@ +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { resetSendPipeline } from '@/lib/outgoing/send-pipeline'; +import { ExternalRecipientConfirmHost } from '../external-recipient-confirm'; +import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; + +// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─ + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me' }, + { id: 'id-sales', email: 'sales@example.com', name: 'Sales' }, + ] as Array>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + // The default position: replies/forwards get the signature appended at + // send time rather than embedded above the quote. + signaturePosition: 'below_quote', + signatureSeparatorEnabled: true, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +// The external-recipient confirm in the real composer: the check is +// registered by the dialog host, asks only about the recipients outside the +// organisation, and the composer sends or not by the user's answer. + +const DRAFT = { + to: 'bob@example.com, dave@org-two.test', + cc: 'carol@example.com', + bcc: '', + subject: 'Hello', + body: '

Hi

', + showCc: true, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +function clickSend() { + fireEvent.click(screen.getAllByTestId('composer-send')[0]); +} + +function renderComposer(onSend: () => void) { + return render( + <> + + + , + ); +} + +describe('composer: confirm before sending outside the organisation', () => { + beforeEach(() => { + resetSendPipeline(); + useExternalRecipientConfirmStore.setState({ pending: null }); + setExternalMail({ recipientWarning: true }); + }); + afterEach(() => { + cleanup(); + resetSendPipeline(); + vi.clearAllMocks(); + }); + + it('lists only the external recipient and sends nothing on Back', async () => { + const onSend = vi.fn(); + const { container } = renderComposer(onSend); + clickSend(); + const list = await waitFor(() => { + const el = container.querySelector('[data-external-recipients]'); + expect(el).not.toBeNull(); + return el!; + }); + expect(list.textContent).toContain('dave@org-two.test'); + expect(list.textContent).not.toContain('bob@example.com'); + expect(list.textContent).not.toContain('carol@example.com'); + fireEvent.click(container.querySelector('[data-external-recipient-back]')!); + await waitFor(() => expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull()); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('sends after Send anyway', async () => { + const onSend = vi.fn(); + const { container } = renderComposer(onSend); + clickSend(); + await waitFor(() => expect(container.querySelector('[data-external-recipient-send]')).not.toBeNull()); + fireEvent.click(container.querySelector('[data-external-recipient-send]')!); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + }); + + it('sends without asking while the warning is off', async () => { + setExternalMail({ recipientWarning: false, senderLabel: true }); + const onSend = vi.fn(); + const { container } = renderComposer(onSend); + clickSend(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull(); + }); +}); diff --git a/components/email/external-recipient-confirm.tsx b/components/email/external-recipient-confirm.tsx new file mode 100644 index 000000000..ee6e0b85a --- /dev/null +++ b/components/email/external-recipient-confirm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useId } from 'react'; +import { useTranslations } from 'next-intl'; +import { Building } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { useFocusTrap } from '@/hooks/use-focus-trap'; +import { registerExternalRecipientCheck } from '@/lib/outgoing/external-recipients'; +import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store'; + +/** + * "Send outside your organisation?", asked once per send with every + * external recipient listed. Mounted once in the app layout; while it is + * mounted the pre-send check is registered, so the composer and quick reply + * both ask through it. Sits above the composer like the plugin dialogs. + */ +export function ExternalRecipientConfirmHost() { + const t = useTranslations('external_mail'); + const pending = useExternalRecipientConfirmStore((s) => s.pending); + const answer = useExternalRecipientConfirmStore((s) => s.answer); + const id = useId(); + + useEffect(() => registerExternalRecipientCheck(), []); + + // A send can't be waiting on a dialog that is gone. + useEffect(() => () => useExternalRecipientConfirmStore.getState().answer(false), []); + + const dialogRef = useFocusTrap({ isActive: !!pending, onEscape: () => answer(false), restoreFocus: true }); + + if (!pending) return null; + const { recipients } = pending; + const field = { to: t('field_to'), cc: t('field_cc'), bcc: t('field_bcc') }; + + return ( +
{ + if (e.target === e.currentTarget) answer(false); + }} + > +
+
+
+
+
+
+

{t('confirm_title')}

+

+ {t('confirm_message', { count: recipients.length })} +

+
+
+
    + {recipients.map((recipient) => ( +
  • + {field[recipient.field]} + {recipient.address} +
  • + ))} +
+
+
+ {/* Back comes first, so the focus trap puts the focus on it: sending outside takes a deliberate click. */} + + +
+
+
+ ); +} diff --git a/lib/outgoing/__tests__/external-recipients.test.ts b/lib/outgoing/__tests__/external-recipients.test.ts new file mode 100644 index 000000000..c9982315f --- /dev/null +++ b/lib/outgoing/__tests__/external-recipients.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { externalRecipientCheck, registerExternalRecipientCheck } from '../external-recipients'; +import { resetSendPipeline, type OutgoingContext } from '../send-pipeline'; +import { prepareQuickReply } from '../quick-reply'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store'; +import type { ContactCard, Identity } from '@/lib/jmap/types'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const context = (to: string[], cc: string[] = [], bcc: string[] = []): OutgoingContext => ({ + surface: 'composer', to, cc, bcc, fromEmail: 'org-alice@example.org', identityId: 'i1', mode: 'compose', plainText: false, +}); + +beforeEach(() => { + setExternalMail({ recipientWarning: true }); + useAuthStore.setState({ client: null, identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] }); + useContactStore.setState({ contacts: [], contactsLoaded: true }); + useExternalRecipientConfirmStore.setState({ pending: null }); +}); +afterEach(() => resetSendPipeline()); + +describe('externalRecipientCheck', () => { + it('does not ask while the warning is off', async () => { + setExternalMail({ recipientWarning: false, senderLabel: true }); + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('does not ask when every recipient is internal, subdomains included', async () => { + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-bob@example.org'], ['x@eu.example.org']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('asks about the external recipients only and returns the answer', async () => { + const ask = vi.fn(async () => false); + const recipients = context(['org-bob@example.org', 'org-dave@org-two.test'], [], ['eve@other.net']); + expect(await externalRecipientCheck(recipients, ask)).toBe(false); + expect(ask).toHaveBeenCalledWith([ + { address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' }, + { address: 'eve@other.net', domain: 'other.net', field: 'bcc' }, + ]); + ask.mockResolvedValueOnce(true); + expect(await externalRecipientCheck(recipients, ask)).toBe(true); + }); + + it('counts the admin domains as internal', async () => { + setExternalMail({ recipientWarning: true, domains: ['org-two.test'] }); + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('trusts contacts only while trustContacts is on', async () => { + useContactStore.setState({ + contacts: [{ id: 'c1', emails: { e: { address: 'Org-Dave@org-two.test' } } }] as unknown as ContactCard[], + }); + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + + setExternalMail({ recipientWarning: true, trustContacts: false }); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(false); + expect(ask).toHaveBeenCalledTimes(1); + }); +}); + +describe('quick reply goes through the same confirm', () => { + const input = { body: 'Thanks', signatureIdentity: null, separator: true, to: ['org-dave@org-two.test'], fromEmail: 'org-alice@example.org', identityId: 'i1' }; + + async function answerWhenAsked(send: boolean) { + await vi.waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull()); + expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']); + useExternalRecipientConfirmStore.getState().answer(send); + } + + it('sends nothing on Back', async () => { + registerExternalRecipientCheck(); + const result = prepareQuickReply(input); + await answerWhenAsked(false); + expect(await result).toBeNull(); + }); + + it('returns the bodies on Send anyway', async () => { + registerExternalRecipientCheck(); + const result = prepareQuickReply(input); + await answerWhenAsked(true); + expect((await result)?.text).toContain('Thanks'); + }); + + it('does not ask for a reply to an internal sender', async () => { + registerExternalRecipientCheck(); + const result = await prepareQuickReply({ ...input, to: ['org-bob@example.org'] }); + expect(result?.text).toContain('Thanks'); + expect(useExternalRecipientConfirmStore.getState().pending).toBeNull(); + }); +}); diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts new file mode 100644 index 000000000..e7f2b4cb4 --- /dev/null +++ b/lib/outgoing/external-recipients.ts @@ -0,0 +1,74 @@ +/** + * The pre-send check behind "Send outside your organisation?": when the + * admin turned the recipient warning on, recipients outside the + * organisation (and, with trustContacts, not in the user's contacts) are + * listed in one confirm. Runs for every surface that sends through the + * pipeline: the composer and quick reply. + */ + +import { + addressDomain, + contactAddresses, + currentExternalMailPolicy, + currentInternalDomains, + externalRecipients, + type ExternalRecipient, +} from '@/lib/external-mail'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store'; +import { registerPreSendCheck, type OutgoingContext } from './send-pipeline'; +import { debug } from '@/lib/debug'; + +export const EXTERNAL_RECIPIENT_CHECK_ID = 'external-recipients'; +/** After the composer's own checks (attachment, subject, placeholders), before anything later. */ +const ORDER = 40; +/** Longest the send waits for the contacts to load the first time. */ +const CONTACTS_WAIT_MS = 3000; + +async function ensureContactsLoaded(): Promise { + const contacts = useContactStore.getState(); + if (contacts.contactsLoaded) return; + const client = useAuthStore.getState().client; + if (!client) return; + try { + await Promise.race([ + contacts.fetchContacts(client), + new Promise((resolve) => setTimeout(resolve, CONTACTS_WAIT_MS)), + ]); + } catch (error) { + debug.error('Could not load contacts for the external-recipient check:', error); + } +} + +/** The recipients the user is asked about; empty when the check doesn't apply. */ +export async function recipientsToConfirm(context: OutgoingContext): Promise { + const policy = currentExternalMailPolicy(); + if (!policy.recipientWarning) return []; + // The From address is one of the user's own, even an override typed in the composer. + const fromDomain = addressDomain(context.fromEmail); + const internal = currentInternalDomains(policy); + if (fromDomain && !internal.includes(fromDomain)) internal.push(fromDomain); + let trusted: ((address: string) => boolean) | undefined; + if (policy.trustContacts) { + await ensureContactsLoaded(); + const known = contactAddresses(); + trusted = (address) => known.has(address); + } + return externalRecipients(context, internal, trusted); +} + +/** True to send: nobody external, or the user chose "Send anyway". */ +export async function externalRecipientCheck( + context: OutgoingContext, + ask: (recipients: ExternalRecipient[]) => Promise = (recipients) => useExternalRecipientConfirmStore.getState().ask(recipients), +): Promise { + const recipients = await recipientsToConfirm(context); + if (recipients.length === 0) return true; + return ask(recipients); +} + +/** Called by the dialog host once it is mounted; returns the unregister function. */ +export function registerExternalRecipientCheck(): () => void { + return registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context)); +} diff --git a/locales/de/common.json b/locales/de/common.json index 73f54a823..729bb80ca 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -661,7 +661,14 @@ }, "external_mail": { "sender_label": "Extern", - "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}" + "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}", + "confirm_title": "Außerhalb Ihrer Organisation senden?", + "confirm_message": "{count, plural, one {Dieser Empfänger ist} other {Diese # Empfänger sind}} außerhalb Ihrer Organisation. Senden Sie nur, wenn das so gewollt ist.", + "field_to": "An", + "field_cc": "Cc", + "field_bcc": "Bcc", + "send_anyway": "Trotzdem senden", + "back": "Zurück" }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", diff --git a/locales/en/common.json b/locales/en/common.json index 60734aa9d..b8c4ecb2c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -661,7 +661,14 @@ }, "external_mail": { "sender_label": "External", - "sender_tooltip": "From outside your organisation: {domain}" + "sender_tooltip": "From outside your organisation: {domain}", + "confirm_title": "Send outside your organisation?", + "confirm_message": "{count, plural, one {This recipient is} other {These # recipients are}} outside your organisation. Send only if you mean to.", + "field_to": "To", + "field_cc": "Cc", + "field_bcc": "Bcc", + "send_anyway": "Send anyway", + "back": "Back" }, "email_composer": { "select_from_directory": "Select from directory", diff --git a/stores/external-recipient-confirm-store.ts b/stores/external-recipient-confirm-store.ts new file mode 100644 index 000000000..45e3304e5 --- /dev/null +++ b/stores/external-recipient-confirm-store.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; +import type { ExternalRecipient } from '@/lib/external-mail'; + +/** + * The one open "Send outside your organisation?" question. The pre-send + * check asks, the dialog (components/email/external-recipient-confirm.tsx) + * shows it and answers. Asking again while a question is open answers the + * old one with "Back", so no send waits forever. + */ +interface ExternalRecipientConfirmState { + pending: { recipients: ExternalRecipient[]; resolve: (send: boolean) => void } | null; + /** Resolves true for "Send anyway", false for "Back". */ + ask: (recipients: ExternalRecipient[]) => Promise; + answer: (send: boolean) => void; +} + +export const useExternalRecipientConfirmStore = create()((set, get) => ({ + pending: null, + ask: (recipients) => { + get().pending?.resolve(false); + return new Promise((resolve) => { + set({ pending: { recipients, resolve } }); + }); + }, + answer: (send) => { + const pending = get().pending; + if (!pending) return; + set({ pending: null }); + pending.resolve(send); + }, +})); From ab878ebf4fbc84de1ddf46e8c63a5ecee2bf3015 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:10:10 +0100 Subject: [PATCH 03/54] feat: tell plugins when Bulwark's own external mail warnings are on and skip the retired plugin's --- .../__tests__/plugin-slot-handover.test.tsx | 47 ++++++ components/plugins/plugin-slot.tsx | 12 +- .../plugin-external-mail-handover.test.ts | 105 +++++++++++++ lib/__tests__/plugin-host-features.test.ts | 143 ++++++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 23 ++- lib/plugin-sandbox/host-features.ts | 65 ++++++++ lib/plugin-sandbox/loader.ts | 8 +- lib/plugin-sandbox/protocol.ts | 10 +- lib/plugin-sandbox/runtime.tsx | 34 ++++- lib/plugin-types.ts | 14 ++ 10 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 components/plugins/__tests__/plugin-slot-handover.test.tsx create mode 100644 lib/__tests__/plugin-external-mail-handover.test.ts create mode 100644 lib/__tests__/plugin-host-features.test.ts create mode 100644 lib/plugin-sandbox/host-features.ts diff --git a/components/plugins/__tests__/plugin-slot-handover.test.tsx b/components/plugins/__tests__/plugin-slot-handover.test.tsx new file mode 100644 index 000000000..24cb78e67 --- /dev/null +++ b/components/plugins/__tests__/plugin-slot-handover.test.tsx @@ -0,0 +1,47 @@ +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { clearSlotOffers, offerSlots } from './fake-slot-offer'; + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const { PluginSlot } = await import('../plugin-slot'); + +// The retired external-mail-warning plugin's banner stays away while the +// organisation's own External label or recipient warning is on, so the +// reader doesn't show both; other plugins' banners stay. + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const frames = (container: HTMLElement) => + [...container.querySelectorAll('[data-testid="plugin-frame-email-banner"]')].map((el) => el.getAttribute('data-plugin-id')); + +beforeEach(() => { + setExternalMail({}); + offerSlots('external-mail-warning', ['email-banner']); + offerSlots('spam-score', ['email-banner']); +}); +afterEach(() => { + cleanup(); + clearSlotOffers(); +}); + +describe('email-banner slot while core external warnings are on', () => { + it('shows the plugin banner while they are off and hides it as soon as one is on', () => { + const { container } = render(); + expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']); + act(() => setExternalMail({ senderLabel: true })); + expect(frames(container)).toEqual(['spam-score']); + act(() => setExternalMail({})); + expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']); + }); +}); diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx index ad7e96dbd..90e87d560 100644 --- a/components/plugins/plugin-slot.tsx +++ b/components/plugins/plugin-slot.tsx @@ -1,10 +1,12 @@ 'use client'; -import React, { useSyncExternalStore } from 'react'; +import React, { useMemo, useSyncExternalStore } from 'react'; import type { SlotName } from '@/lib/plugin-types'; import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry'; import { PluginIframeSlot } from './plugin-iframe-slot'; import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout'; +import { externalWarningsOn, isSlotHandedOver } from '@/lib/plugin-sandbox/host-features'; +import { usePolicyStore } from '@/stores/policy-store'; import { cn } from '@/lib/utils'; interface PluginSlotProps { @@ -15,7 +17,13 @@ interface PluginSlotProps { export function PluginSlot({ name, className, extraProps }: PluginSlotProps) { const getSnapshot = () => offersForSlot(name); - const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const allOffers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + // A retired plugin's banner stays away while Bulwark shows its own (host-features.ts). + const externalMailWarnings = usePolicyStore((s) => externalWarningsOn(s.policy.organisation?.externalMail)); + const offers = useMemo( + () => allOffers.filter((offer) => !isSlotHandedOver(offer.pluginId, name, { externalMailWarnings })), + [allOffers, name, externalMailWarnings], + ); if (offers.length === 0) return null; diff --git a/lib/__tests__/plugin-external-mail-handover.test.ts b/lib/__tests__/plugin-external-mail-handover.test.ts new file mode 100644 index 000000000..37c50dea9 --- /dev/null +++ b/lib/__tests__/plugin-external-mail-handover.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import type { InstalledPlugin } from '../plugin-types'; + +// Copies of the external-mail-warning plugin older than the release that +// steps back on its own keep their send check. While the organisation's +// own warnings are on, the host skips that plugin's hooks by id, so the +// user isn't asked twice; other plugins keep theirs. + +const mocks = vi.hoisted(() => ({ + hooks: [] as string[], + invokeHook: null as null | ReturnType, +})); + +const CODE = 'export default { activate() {} }'; +const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex'); + +vi.mock('../plugin-storage', () => ({ + pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() }, +})); +vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() })); +vi.mock('../plugin-sandbox/host-bridge', () => ({ + createBackgroundInstance: () => { + // The old plugin's check: "Send to external recipients?" answered with Cancel. + const invokeHook = vi.fn(async () => false); + mocks.invokeHook = invokeHook; + return { + initPromise: Promise.resolve({ hooks: mocks.hooks, slots: [], shortcuts: [] }), + invokeHook, + destroy: vi.fn(), + }; + }, + snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })), + forEachInstance: vi.fn(), + SandboxInstance: class {}, +})); +vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() })); +vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} })); + +import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader'; +import { emailHooks } from '../plugin-hooks'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; + +function plugin(id: string): InstalledPlugin { + return { + id, name: id, version: '1.5.1', author: 'someone', description: '', type: 'hook', + permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'enabled', + settings: {}, managed: true, adminApproved: true, bundleHash: HASH, + }; +} + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const OUTGOING = { to: ['dave@org-two.test'], cc: [], bcc: [], subject: 'Hi' }; +let loaded: string | null = null; + +beforeEach(() => { + setSandboxStoreAccessor({ setPluginStatus: () => {} }); + mocks.hooks = ['onBeforeEmailSend', 'onNewEmailReceived']; + setExternalMail({}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); +afterEach(() => { + if (loaded) unloadSandboxedPlugin(loaded); + loaded = null; + vi.restoreAllMocks(); +}); + +describe('the retired external-mail-warning plugin', () => { + it('asks as before while the core warnings are off', async () => { + loaded = 'external-mail-warning'; + await loadSandboxedPlugin(plugin(loaded)); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + expect(mocks.invokeHook).toHaveBeenCalledWith('onBeforeEmailSend', [OUTGOING]); + }); + + it('is skipped while the core warnings are on, and asked again once they are off', async () => { + loaded = 'external-mail-warning'; + await loadSandboxedPlugin(plugin(loaded)); + setExternalMail({ senderLabel: true }); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('allowed'); + await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never); + expect(mocks.invokeHook).not.toHaveBeenCalled(); + + setExternalMail({}); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + }); + + it('leaves the send checks of other plugins alone', async () => { + loaded = 'reply-all-guardrail'; + await loadSandboxedPlugin(plugin(loaded)); + setExternalMail({ recipientWarning: true }); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + }); +}); diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts new file mode 100644 index 000000000..ea665ba73 --- /dev/null +++ b/lib/__tests__/plugin-host-features.test.ts @@ -0,0 +1,143 @@ +/** + * api.host.features tells plugins which of Bulwark's own features replace + * them (externalMailWarnings: the organisation's External label or + * recipient warning). The host sends it with init and again on every + * change; the runtime keeps one object up to date. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { + EXTERNAL_MAIL_WARNING_PLUGIN_ID, + isHookHandedOver, + isSlotHandedOver, + snapshotHostFeatures, +} from '@/lib/plugin-sandbox/host-features'; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +beforeEach(() => setExternalMail({})); + +describe('snapshotHostFeatures', () => { + it('says externalMailWarnings while either switch is on', () => { + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: false }); + setExternalMail({ senderLabel: true }); + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: true }); + setExternalMail({ recipientWarning: true }); + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: true }); + }); +}); + +describe('hand-over of the external-mail-warning plugin', () => { + it('skips its send check, toast and banner only while core warnings are on', () => { + const id = EXTERNAL_MAIL_WARNING_PLUGIN_ID; + expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false); + expect(isSlotHandedOver(id, 'email-banner')).toBe(false); + setExternalMail({ recipientWarning: true }); + expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(true); + expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(true); + expect(isSlotHandedOver(id, 'email-banner')).toBe(true); + // Its other hooks, and every other plugin, keep running. + expect(isHookHandedOver(id, 'onIdentitiesLoaded')).toBe(false); + expect(isHookHandedOver('reply-all-guardrail', 'onBeforeEmailSend')).toBe(false); + expect(isSlotHandedOver('spam-score', 'email-banner')).toBe(false); + }); +}); + +// ─── Host bridge: init payload and pushes ──────────────────── + +describe('host bridge', () => { + afterEach(() => { document.body.innerHTML = ''; }); + + it('puts the features into the init payload and pushes changes to running frames', async () => { + const { createBackgroundInstance } = await import('@/lib/plugin-sandbox/host-bridge'); + setExternalMail({ senderLabel: true }); + const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never; + const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' }); + const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit; + expect(pending?.hostFeatures).toEqual({ externalMailWarnings: true }); + + // Before init has gone out a change lands in the payload ... + setExternalMail({}); + expect(pending?.hostFeatures).toEqual({ externalMailWarnings: false }); + + // ... after it, as a host-change message. + const sent: unknown[] = []; + (instance as unknown as { pendingInit: unknown }).pendingInit = null; + (instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m); + setExternalMail({ recipientWarning: true }); + expect(sent).toEqual([{ type: 'host-change', features: { externalMailWarnings: true } }]); + instance.destroy(); + }, 30_000); +}); + +// ─── Sandbox runtime: api.host.features ────────────────────── + +const HOST_ORIGIN = window.location.origin; + +function post(source: unknown, origin: string, data: unknown): void { + const ev = new MessageEvent('message', { data, origin }); + Object.defineProperty(ev, 'source', { value: source }); + window.dispatchEvent(ev); +} + +describe('api.host in the sandbox runtime', () => { + let hostWindow: { postMessage: ReturnType }; + const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); + const g = globalThis as { + __pluginApi?: { host: { features: { externalMailWarnings: boolean }; onFeaturesChange(fn: (f: unknown) => void): () => void } }; + __seenAtActivate?: boolean; + }; + + beforeEach(() => { + vi.resetModules(); + hostWindow = { postMessage: vi.fn() }; + Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window }); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + delete g.__pluginApi; + delete g.__seenAtActivate; + if (realParent) Object.defineProperty(window, 'parent', realParent); + }); + + it('has the value from init in activate and follows host-change', async () => { + const bundle = `module.exports = { activate: function (api) { + globalThis.__seenAtActivate = api.host.features.externalMailWarnings; + globalThis.__pluginApi = api; + } };`; + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); + post(hostWindow, HOST_ORIGIN, { + type: 'init', + payload: { + mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en', + manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} }, + code: bundle, + hostFeatures: { externalMailWarnings: true }, + }, + }); + await waitFor(() => expect(g.__pluginApi).toBeDefined()); + expect(g.__seenAtActivate).toBe(true); + + const api = g.__pluginApi!; + const kept = api.host.features; + const changes: unknown[] = []; + api.host.onFeaturesChange((f) => changes.push({ ...(f as object) })); + post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalMailWarnings: false } }); + expect(kept.externalMailWarnings).toBe(false); + expect(changes).toEqual([{ externalMailWarnings: false }]); + }, 30_000); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index db5a39018..1d0e7d67c 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -7,12 +7,13 @@ // `event.source === iframe.contentWindow`. The iframe's runtime pins the // parent on the first inbound message. -import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; +import type { HostFeaturesView, InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH, type RegionalSnapshot } from './protocol'; export type { RegionalSnapshot }; import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; +import { snapshotHostFeatures, watchHostFeatures } from './host-features'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import { resolveDateLocale } from '../utils'; @@ -252,6 +253,13 @@ function pushThemeToFrames(): void { } } +/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */ +function pushHostFeaturesToFrames(features: HostFeaturesView): void { + for (const instance of liveInstances) { + try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ } + } +} + function ensureThemeWatcher(): void { if (themeWatcher || typeof MutationObserver === 'undefined' || typeof document === 'undefined') return; let scheduled = false; @@ -414,6 +422,7 @@ export class SandboxInstance { hostContainer.appendChild(this.iframe); liveInstances.add(this); if (this.mode === 'slot') ensureThemeWatcher(); + watchHostFeatures(pushHostFeaturesToFrames); // Send init after the iframe runtime signals it's ready. this.readyPromise.then(() => { @@ -637,6 +646,16 @@ export class SandboxInstance { this.send({ type: 'theme-change', theme }); } + /** Push what api.host.features says now; before init it goes into the init payload. */ + setHostFeatures(features: HostFeaturesView): void { + if (this.destroyed) return; + if (this.pendingInit) { + this.pendingInit.hostFeatures = features; + return; + } + this.send({ type: 'host-change', features }); + } + /** Push the plugin's current settings (see effectiveSettings). */ setSettings(settings: Record): void { if (this.destroyed) return; @@ -735,6 +754,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan code: opts.code, locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), + hostFeatures: snapshotHostFeatures(), }; return new SandboxInstance( opts.plugin, @@ -764,6 +784,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), theme: snapshotHostTheme(), + hostFeatures: snapshotHostFeatures(), }; return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown); } diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts new file mode 100644 index 000000000..f9721ba19 --- /dev/null +++ b/lib/plugin-sandbox/host-features.ts @@ -0,0 +1,65 @@ +// What the host tells plugins about its own features (`api.host.features`), +// so a plugin can step back where Bulwark now does the job itself. Sent with +// every frame's init and again whenever it changes (the admin's policy, an +// account switch to another scope). +// +// And the other half of that hand-over: copies of a plugin core replaced +// that are older than the release which knows the flag keep running their +// hooks. While core does the job, the host skips those hooks by plugin id. + +import { usePolicyStore } from '@/stores/policy-store'; +import type { HostFeaturesView } from '../plugin-types'; +import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; + +/** + * Either external-mail switch is on. The same rule as coreExternalWarningsOn + * (lib/external-mail.ts), kept apart so the sandbox doesn't pull in the + * account and contact stores. + */ +export function externalWarningsOn(externalMail: OrganisationPolicy['externalMail'] | undefined): boolean { + return !!externalMail && (externalMail.senderLabel || externalMail.recipientWarning); +} + +export function snapshotHostFeatures(): HostFeaturesView { + return { externalMailWarnings: externalWarningsOn(usePolicyStore.getState().getOrganisation().externalMail) }; +} + +/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ +export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; + +/** Its send check and its new-mail toast; its banner is the email-banner slot. */ +const EXTERNAL_MAIL_WARNING_HOOKS = new Set(['onBeforeEmailSend', 'onNewEmailReceived']); +const EXTERNAL_MAIL_WARNING_SLOTS = new Set(['email-banner']); + +/** True when core does this plugin hook's job right now, so the host skips it. */ +export function isHookHandedOver(pluginId: string, hookName: string): boolean { + return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID + && EXTERNAL_MAIL_WARNING_HOOKS.has(hookName) + && snapshotHostFeatures().externalMailWarnings; +} + +/** True when core does this plugin slot's job right now, so the host doesn't show it. */ +export function isSlotHandedOver(pluginId: string, slot: string, features: HostFeaturesView = snapshotHostFeatures()): boolean { + return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID + && EXTERNAL_MAIL_WARNING_SLOTS.has(slot) + && features.externalMailWarnings; +} + +// ─── Change watch ──────────────────────────────────────────── + +let watching = false; +let lastKey = ''; + +/** Calls `onChange` with the new features whenever they change; set up once. */ +export function watchHostFeatures(onChange: (features: HostFeaturesView) => void): void { + if (watching) return; + watching = true; + lastKey = JSON.stringify(snapshotHostFeatures()); + usePolicyStore.subscribe(() => { + const features = snapshotHostFeatures(); + const key = JSON.stringify(features); + if (key === lastKey) return; + lastKey = key; + onChange(features); + }); +} diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index a95b2bc47..bbf79ef99 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -31,6 +31,7 @@ import { clearPluginPages } from './pages'; import { clearSectionTitle } from './section-titles'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; +import { isHookHandedOver } from './host-features'; import type { SlotName } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -280,7 +281,12 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { } // The bus records a failure once; recording it here too made the // three-strikes breaker trip on the second error. - const proxy = (...args: unknown[]) => bg.invokeHook(hookName, args); + // While Bulwark does a retired plugin's job itself, that plugin's + // hook is skipped: copies older than the release that steps back on + // its own would ask twice (host-features.ts). Asked per call, so + // turning the core switch off gives the plugin its hook back. + const pluginId = plugin.id; + const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, args)); hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown)); if (hookName === 'onEmailOpen') openHandler = proxy; } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 0dda74f34..c947991a9 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -8,7 +8,7 @@ // the boundary must be structured-cloneable: no functions, no DOM nodes, no // class instances. -import type { SlotName, PluginTier } from '../plugin-types'; +import type { SlotName, PluginTier, HostFeaturesView } from '../plugin-types'; import type { ThemeSnapshot } from './host-theme'; /** Resolved presentation/regional settings mirrored into plugin runtimes. */ @@ -50,6 +50,8 @@ export interface BackgroundInit { locale: string; /** Resolved presentation/regional preferences (time format, date locale, time zone). */ regional?: RegionalSnapshot; + /** Bulwark features that replace plugins (api.host.features); host pushes updates via 'host-change'. */ + hostFeatures?: HostFeaturesView; } /** Initialisation payload for a slot-instance iframe (one per slot mount). */ @@ -97,6 +99,8 @@ export interface SlotInit { * injected CSS + a `.dark` class. Host pushes updates via 'theme-change'. */ theme: ThemeSnapshot; + /** Mirrors `BackgroundInit.hostFeatures`. */ + hostFeatures?: HostFeaturesView; } export type InitPayload = BackgroundInit | SlotInit; @@ -265,6 +269,9 @@ export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; } /** Host → sandbox: the user changed this plugin's settings. */ export interface SettingsChangeMsg { type: 'settings-change'; settings: Record; } +/** Host → sandbox: a Bulwark feature that replaces plugins was turned on or off. */ +export interface HostChangeMsg { type: 'host-change'; features: HostFeaturesView; } + export interface PropsUpdateMsg { type: 'props-update'; props: Record; } export interface SlotShouldShowMsg { @@ -309,6 +316,7 @@ export type HostToSandbox = | LocaleChangeMsg | ThemeChangeMsg | SettingsChangeMsg + | HostChangeMsg | PropsUpdateMsg | SlotShouldShowMsg | FloatingDropdownSelectMsg diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 3f8f46405..748de7916 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -33,7 +33,7 @@ import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui- import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - AppInfoView, AppSettingsView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, + AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; @@ -148,6 +148,18 @@ function applySettings(next: Record): void { try { listener(settingsState); } catch (err) { console.error('[plugin] settings listener failed', err); } } } +// What api.host.features says: one object for the iframe's lifetime, +// updated in place on 'host-change' like the settings. +const hostFeaturesState: HostFeaturesView = { externalMailWarnings: false }; +const hostFeatureListeners = new Set<(features: HostFeaturesView) => void>(); + +function applyHostFeatures(next: HostFeaturesView | undefined): void { + if (!next) return; + hostFeaturesState.externalMailWarnings = next.externalMailWarnings === true; + for (const listener of hostFeatureListeners) { + try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); } + } +} const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); const hookHandlers: Record unknown> = {}; @@ -375,6 +387,18 @@ function buildPluginApi(manifest: PluginManifest) { return () => { settingsListeners.delete(listener); }; }, }, + /** + * Bulwark features that replace what plugins did, so a plugin can step + * back while Bulwark does its job. Updated in place; no permission needed. + */ + host: { + features: hostFeaturesState, + /** Called with the new features whenever one turns on or off. Returns an unsubscribe function. */ + onFeaturesChange: (listener: (features: HostFeaturesView) => void) => { + hostFeatureListeners.add(listener); + return () => { hostFeatureListeners.delete(listener); }; + }, + }, /** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */ app: { getInfo: () => callApi('app.getInfo', []) as Promise, @@ -1143,9 +1167,11 @@ async function bootSlot(payload: SlotInit): Promise { const bump = () => setSettingsVersion((v) => v + 1); settingsListeners.add(bump); localeListeners.add(bump); + hostFeatureListeners.add(bump); return () => { settingsListeners.delete(bump); localeListeners.delete(bump); + hostFeatureListeners.delete(bump); }; }, []); React.useEffect(() => { @@ -1193,6 +1219,8 @@ async function handleInit(payload: InitPayload): Promise { // Make the active locale available to plugin code (api.i18n) right away - // not only after the first 'locale-change' push. applyLocale(payload.locale, payload.regional); + // Before the bundle runs, so activate() and shouldShow already see it. + applyHostFeatures(payload.hostFeatures); try { if (payload.mode === 'background') { await bootBackground(payload); @@ -1289,6 +1317,10 @@ function handleHostMessage(ev: MessageEvent): void { applySettings(msg.settings ?? {}); break; + case 'host-change': + applyHostFeatures(msg.features); + break; + case 'props-update': if (slotPropsUpdater) slotPropsUpdater(msg.props ?? {}); else if (mode === 'slot') propsBeforeBoot = msg.props ?? {}; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 9fc9b4959..3660d9d5c 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -837,6 +837,20 @@ export interface AppInfoView { demoMode: boolean; } +/** + * Features of Bulwark itself that replace what a plugin did, as + * `api.host.features` tells the plugin (no permission needed). A plugin + * that does the same job steps back while one is true. Missing on hosts + * older than 1.19.0, so read it as `api.host?.features?.x === true`. + */ +export interface HostFeaturesView { + /** + * The admin turned on the "External" sender label or the external + * recipient warning (policy organisation.externalMail) for this user. + */ + externalMailWarnings: boolean; +} + /** One setting `api.app.searchSettings` found, in the user's language. */ export interface SettingsSearchResult { /** The settings page's id: account, appearance, composing, vacation, ... */ From 553f1e9149169cef18b01e761bb6740abb15b9fb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:19:06 +0100 Subject: [PATCH 04/54] feat: add a switch that stops all third-party traffic #903 --- app/(main)/admin/_pages/telemetry.tsx | 8 +- app/(main)/admin/_pages/version.tsx | 6 + app/api/admin/config/route.ts | 12 + app/api/admin/marketplace/[slug]/route.ts | 5 + app/api/admin/marketplace/route.ts | 8 + app/api/admin/marketplace/updates/route.ts | 9 + app/api/admin/telemetry/route.ts | 6 + app/api/admin/version/route.ts | 2 + app/api/config/route.ts | 3 + app/api/connector/capabilities/route.ts | 3 +- app/api/favicon/route.ts | 5 + app/api/plugin-proxy/route.ts | 3 + app/api/plugins/route.ts | 7 +- app/api/translate/route.ts | 5 + components/calendar/event-detail-popover.tsx | 7 +- components/push-notification-prompt.tsx | 3 + components/settings/message-list-settings.tsx | 5 +- components/settings/notification-settings.tsx | 12 +- components/settings/plugins-settings.tsx | 5 +- components/ui/__tests__/avatar.test.tsx | 14 + components/ui/avatar.tsx | 10 +- hooks/use-config.ts | 7 + lib/__tests__/favicon-route-inflight.test.ts | 6 +- lib/__tests__/fixtures/config.ts | 1 + lib/__tests__/no-third-party-client.test.ts | 123 ++++++++ lib/__tests__/no-third-party-server.test.ts | 273 ++++++++++++++++++ lib/__tests__/plugin-sandbox-loader.test.ts | 19 ++ lib/admin/csp-frame-origins.ts | 7 + lib/admin/marketplace-directory.ts | 21 +- lib/admin/third-party-mode.ts | 37 +++ lib/admin/types.ts | 7 + lib/config-validation.ts | 1 + lib/lite-config.ts | 2 + lib/outgoing-hosts.ts | 255 ++++++++++++++++ lib/plugin-sandbox/host-api.ts | 6 + lib/plugin-sandbox/loader.ts | 8 + lib/push-relays.ts | 18 +- lib/telemetry/sender.ts | 2 + lib/telemetry/state.ts | 9 +- lib/third-party-mode.ts | 26 ++ lib/version-check/sender.ts | 9 + lib/web-push.ts | 16 +- locales/de/common.json | 6 + locales/en/common.json | 6 + scripts/lite/build.mjs | 1 + scripts/lite/lib.mjs | 4 + 46 files changed, 978 insertions(+), 30 deletions(-) create mode 100644 lib/__tests__/no-third-party-client.test.ts create mode 100644 lib/__tests__/no-third-party-server.test.ts create mode 100644 lib/admin/third-party-mode.ts create mode 100644 lib/outgoing-hosts.ts create mode 100644 lib/third-party-mode.ts diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx index a745ac45c..796a1564d 100644 --- a/app/(main)/admin/_pages/telemetry.tsx +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -15,7 +15,7 @@ import { cn } from '@/lib/utils'; interface TelemetryStatus { consent: 'pending' | 'on' | 'off'; - consentSource: 'env' | 'file'; + consentSource: 'no-third-party' | 'env' | 'file'; endpoint: string; defaultEndpoint: string; consentedAt: string | null; @@ -132,14 +132,16 @@ export function TelemetryPage() { ); } - const envOverridden = status.consentSource === 'env'; + const noThirdParty = status.consentSource === 'no-third-party'; + const envOverridden = status.consentSource === 'env' || noThirdParty; const isOn = status.consent === 'on'; const consentDescription = [ status.consent === 'pending' && 'Initialising - no heartbeats sent yet.', status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!', status.consent === 'off' && 'Heartbeats are off (default).', - envOverridden && 'Locked by the BULWARK_TELEMETRY env var.', + noThirdParty && 'Locked off: no third-party traffic is on (Organisation > Security).', + envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.', ].filter(Boolean).join(' '); return ( diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx index e9dcd6d0c..7a700786a 100644 --- a/app/(main)/admin/_pages/version.tsx +++ b/app/(main)/admin/_pages/version.tsx @@ -24,6 +24,7 @@ interface VersionAdminStatus { endpoint: string; defaultEndpoint: string; disabledByEnv: boolean; + disabledByNoThirdPartyTraffic?: boolean; lastCheckedAt: string | null; lastSuccessAt: string | null; nextScheduledAt: string | null; @@ -206,6 +207,11 @@ export function VersionPage() { {data.disabledByEnv ? 'Yes' : 'No'} + {data.disabledByNoThirdPartyTraffic && ( + + Yes + + )} diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 2981b5b8a..33e3cd203 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -10,6 +10,7 @@ import { logger } from '@/lib/logger'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url'; +import { parseNoThirdPartyEnv } from '@/lib/third-party-mode'; // Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the @@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) { if (problem) return NextResponse.json({ error: problem }, { status: 400 }); } + // The no-third-party switch is fail-closed: an "on" from the environment + // can't be undone from the panel (lib/admin/third-party-mode.ts). + if ('noThirdPartyTraffic' in updates) { + if (typeof updates.noThirdPartyTraffic !== 'boolean') { + return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 }); + } + if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) { + return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 }); + } + } + // Enum keys only accept their declared values; a stray string would // otherwise be persisted and fail closed (or open) at the read site. for (const [key, value] of Object.entries(updates)) { diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index 4df6da909..f35fd899b 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -12,6 +12,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, historyFor, installedExtensions, installedStateOf, @@ -65,6 +67,9 @@ export async function GET( try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const { slug } = await params; const requested = request.nextUrl.searchParams.get('version'); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 50404e882..19cfb739e 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -32,6 +32,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, installedStateOf, readBundleBytes, @@ -57,6 +59,9 @@ export async function GET(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const directoryUrl = await getDirectoryUrl(); const query: Record = {}; @@ -105,6 +110,9 @@ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const ip = getClientIP(request); const { slug, version, type, force } = await request.json(); diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts index 52b14e8cd..7d2643452 100644 --- a/app/api/admin/marketplace/updates/route.ts +++ b/app/api/admin/marketplace/updates/route.ts @@ -8,6 +8,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, v2Url, type DirectoryAdvisory, @@ -43,6 +45,13 @@ export async function GET(request: NextRequest) { const list = [...installed].map(([id, e]) => ({ id, ...e })); const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version })); + // The installed list is still useful with the official directory off (#903). + if (await directoryOff()) { + return NextResponse.json( + { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } const directoryUrl = await getDirectoryUrl(); const channel = getDirectoryChannel(); let answer: UpdatesAnswer = EMPTY; diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index 192e64b31..daa5c19f8 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -81,6 +81,12 @@ export async function POST(request: NextRequest) { { status: 409 }, ); } + if (source === 'no-third-party') { + return NextResponse.json( + { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' }, + { status: 409 }, + ); + } if (body.consent !== 'on' && body.consent !== 'off') { return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 }); } diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts index 2c63a7b33..004db83d2 100644 --- a/app/api/admin/version/route.ts +++ b/app/api/admin/version/route.ts @@ -9,6 +9,7 @@ import { disabledByEnv, DEFAULT_VERSION_ENDPOINT, } from '@/lib/version-check'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * GET /api/admin/version @@ -27,6 +28,7 @@ export async function GET(request: NextRequest) { endpoint: effectiveEndpoint(state), defaultEndpoint: DEFAULT_VERSION_ENDPOINT, disabledByEnv: disabledByEnv(), + disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(), lastCheckedAt: state.lastCheckedAt, lastSuccessAt: state.lastSuccessAt, nextScheduledAt: state.nextScheduledAt, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 1faf393c3..d2293318d 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -11,6 +11,7 @@ import { pickRequestHost, type BrandingOverrideKey, } from '@/lib/admin/domain-branding'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * Runtime configuration endpoint @@ -98,6 +99,8 @@ export async function GET(request: NextRequest) { embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), + // Browser-side switches of lib/outgoing-hosts.ts read this (#903). + noThirdPartyTraffic: await isNoThirdPartyTrafficOn(), }, { // Runtime settings can change without new asset URLs. Prevent stale diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts index 4ff6fd1a1..2235298b9 100644 --- a/app/api/connector/capabilities/route.ts +++ b/app/api/connector/capabilities/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; import { TARGET_NAMES } from '@/lib/connector/registry'; /** @@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry'; export async function GET() { await configManager.ensureLoaded(); - if (!configManager.get('connectorEnabled', true)) { + if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) { return new NextResponse(null, { status: 404 }); } diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 7dc48fab8..311757fbe 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; @@ -439,6 +440,10 @@ function evictOldest() { } export async function GET(request: NextRequest) { + // Sender icons come from DuckDuckGo: off, before anything else, while the + // server must not contact outside services (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons'); + const domain = request.nextUrl.searchParams.get('domain'); if (!domain || !isValidDomain(domain)) { diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index 5da089c90..2715160e7 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials'; @@ -41,6 +42,8 @@ export async function POST(request: NextRequest) { if (!configManager.getPolicy().features.pluginsEnabled) { return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 }); } + // Every proxied request goes to a host outside this deployment (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy'); const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 238ecc0e7..b5652688a 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; +import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -32,11 +34,14 @@ export async function GET(request: NextRequest) { // plugin without uninstalling it first. const devIds = new Set(devEntries.map(e => e.plugin.id)); const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); + // Plugins that declare network access never reach a browser while the + // server must stay off outside hosts (#903); the rest run as usual. + const noThirdParty = await isNoThirdPartyTrafficOn(); const plugins = [ ...devEntries.map(e => ({ ...e.plugin, dev: true })), ...installedEnabled.map(p => ({ ...p, dev: false })), - ].map(p => ({ + ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({ id: p.id, name: p.name, version: p.version, diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index cdf729636..7507303f6 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { readFileEnv } from "@/lib/read-file-env"; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: // ["/api/translate"]). The plugin slot iframe POSTs { text, target, source, @@ -284,6 +285,10 @@ export async function POST(request: NextRequest) { const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en'; const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto'; const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory'; + // MyMemory is a public service; the admin's own LibreTranslate is not (#903). + if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) { + return thirdPartyRefusal('Translation through MyMemory'); + } if (!text) { return NextResponse.json({ error: 'No text to translate' }, { status: 400 }); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 4ab0eba77..0df3ef58d 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -25,6 +25,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; +import { mapsLinksAllowed } from "@/lib/outgoing-hosts"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; @@ -160,7 +161,9 @@ export function EventDetailPopover({ const locationHref = useMemo(() => { if (!locationName) return null; const action = locationAction(locationName, meeting); - return action.kind === "url" ? action.uri : mapsUrl(action.query); + if (action.kind === "url") return action.uri; + // No Google Maps link while third-party traffic is off (#903). + return mapsLinksAllowed() ? mapsUrl(action.query) : null; }, [locationName, meeting]); const [locationCopied, setLocationCopied] = useState(false); const copiedTimer = useRef | null>(null); @@ -493,7 +496,7 @@ export function EventDetailPopover({ href={locationHref ?? undefined} target="_blank" rel="noreferrer" - className="text-sm text-primary hover:underline min-w-0 break-words" + className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")} title={locationName} > {/^https?:\/\//i.test(locationName) diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index da584f836..47b5a70c3 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -121,6 +121,7 @@ export function PushNotificationPrompt() { ); const accountId = client?.getAccountId() ?? null; + // Empty when no relay may be used (third-party traffic off, no own relay). const relayBaseUrl = resolveActiveRelayUrl(policy, userPushRelayUrl); const dismissForSession = useCallback((id: string) => { @@ -252,6 +253,7 @@ export function PushNotificationPrompt() { || wasDismissed(accountId) || !isWebPushSupported() || Notification.permission === "denied" + || !relayBaseUrl ) { return; } @@ -284,6 +286,7 @@ export function PushNotificationPrompt() { pathname, policyLoaded, pwaPromptVisible, + relayBaseUrl, sessionDismissedAccountIds, ]); diff --git a/components/settings/message-list-settings.tsx b/components/settings/message-list-settings.tsx index 2f730c958..7d47fc158 100644 --- a/components/settings/message-list-settings.tsx +++ b/components/settings/message-list-settings.tsx @@ -13,6 +13,7 @@ import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store'; import { toast } from '@/stores/toast-store'; import { cn } from '@/lib/utils'; import { IS_LITE } from '@/lib/lite'; +import { senderFaviconsAllowed } from '@/lib/outgoing-hosts'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { MessageListOrderSettings } from './message-list-order-settings'; import { Button } from '@/components/ui/button'; @@ -216,13 +217,13 @@ export function MessageListSettings() { {/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */} - {!IS_LITE && ( + {!IS_LITE && senderFaviconsAllowed() && ( updateSetting('senderFavicons', checked)} /> )} - {!IS_LITE && senderFavicons && ( + {!IS_LITE && senderFaviconsAllowed() && senderFavicons && (