diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 41be291a3..6797b59ec 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -25,6 +25,7 @@ import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog" import { PluginAppEvents } from "@/components/providers/plugin-app-events"; import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; +import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard"; @@ -97,6 +98,7 @@ export default async function LocaleLayout({ + {/* Lite is a static bundle: no server keeps an archive there. */} {!IS_LITE && } diff --git a/app/print.css b/app/print.css index 5dd793f6c..bc8abf93b 100644 --- a/app/print.css +++ b/app/print.css @@ -39,6 +39,28 @@ } } +/* + * The device policy "No printing" (components/device/device-policy-host.tsx): + * Ctrl+P and the browser's menu can't be stopped, so the page prints only a + * note. Stronger than the print copy's rules above, which it replaces. + */ +[data-print-blocked] { + display: none; +} + +@media print { + html[data-no-print] body > :not([data-print-blocked]):not([data-print-blocked]) { + display: none !important; + } + + html[data-no-print] body > [data-print-blocked][data-print-blocked] { + display: block !important; + margin: 30mm 20mm; + color: #111; + font: 14pt/1.4 var(--font-sans, system-ui, sans-serif); + } +} + .pr-root { /* Landscape page area of the smaller of A4 and Letter, 12 mm margins. */ --pr-page-h: 184mm; diff --git a/components/admin/org/sections/devices-section.tsx b/components/admin/org/sections/devices-section.tsx index bb4864de0..5992f22c1 100644 --- a/components/admin/org/sections/devices-section.tsx +++ b/components/admin/org/sections/devices-section.tsx @@ -1,13 +1,111 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { AlertTriangle } from '@/components/icons'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { InheritNote } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/device-policies'; + +type DeviceKey = 'noDownloads' | 'noExport' | 'noPrint' | 'noInstallPrompt' | 'pushWithoutContent' | 'watermark'; + +interface DeviceField { + key: DeviceKey; + label: string; + description: string; + /** Where it holds: on the server too, or only in the browser. */ + enforced: 'server' | 'browser' | 'both'; +} + +export const DEVICE_FIELDS: DeviceField[] = [ + { + key: 'noDownloads', + label: 'No downloads', + description: 'Attachments and files open in the preview only: no Download, Download all, Open in new tab, Save to Files, dragging out or saving from the PDF viewer. Covers the Files app too, so Save to Files can’t get around it.', + enforced: 'both', + }, + { + key: 'noExport', + label: 'No export', + description: 'Hides saving a message as .eml, View source, dragging messages out, and exporting a folder or the whole account (mbox, ZIP). Importing mail stays. Contacts (vCard) and events (.ics) can still be exported.', + enforced: 'both', + }, + { + key: 'noPrint', + label: 'No printing', + description: 'Hides every Print button in mail, calendar and contacts. Ctrl+P and the browser’s menu print a page that says printing is turned off.', + enforced: 'both', + }, + { + key: 'noInstallPrompt', + label: 'No install prompt', + description: 'Bulwark doesn’t offer to install itself as an app and holds back the browser’s own install banner. The browser’s menu can still install Bulwark.', + enforced: 'browser', + }, + { + key: 'pushWithoutContent', + label: 'Push without sender and subject', + description: 'Notifications say only “New mail”. The server leaves sender, subject and text out of what it sends to the browser and the push service, also for browsers that still run an old Bulwark.', + enforced: 'server', + }, + { + key: 'watermark', + label: 'Watermark', + description: 'The user’s address and the date across the message, previews of attachments and files, and every printout.', + enforced: 'both', + }, +]; + +const ENFORCED: Record = { + server: 'Checked by the server.', + both: 'In the office editor checked by the server; elsewhere in the browser only.', + browser: 'In the browser only.', +}; + +const onOff = (on: boolean) => (on ? 'on' : 'off'); + /** - * Placeholder from the enterprise-pack foundation, replaced by package D: - * Device gates: downloads, export, printing, install prompt, push text, watermark. + * Organisation > Devices (enterprise pack item 13): the switches of + * policy.json `organisation.devices`, each off by default and scoped. A + * message's own account decides; where there is none (the install prompt, + * Ctrl+P) the strictest account signed in decides. */ export function DevicesSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + return ( + +
+
+ + {DEVICE_FIELDS.map((field) => { + const { value, own, everyone } = draft.orgValue('devices', field.key); + return ( + +
+ draft.setOrgValue('devices', field.key, v)} testId={`org-devices-${field.key}`} /> + draft.clearOrgValue('devices', field.key)} /> +
+
+ ); + })} + +

+ With several accounts signed in, each message follows its own account's settings. What isn't about one + message (the install prompt, Ctrl+P, the Files app) follows the strictest account. Lite has no push and no office + editor, so there everything is checked in the browser. +

+
+ ); } diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b38a7d574..2e237ad33 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -37,6 +37,7 @@ import { EventModal, type PendingEventPreview } from "@/components/calendar/even import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventContextMenu } from "@/components/calendar/event-context-menu"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; +import { useStrictestDevices } from "@/hooks/use-device-policy"; import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; @@ -143,6 +144,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const linkSegments = useLiteLinkSegments('calendar', routeSegments); const router = useRouter(); const t = useTranslations("calendar"); + // Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note). + const printOff = useStrictestDevices().noPrint; // The server's reason for a refused change, where the user can act on it. const describeError = useCalendarErrorMessage(); const tSidebar = useTranslations("sidebar"); @@ -2064,7 +2067,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onImport={demoMode ? undefined : () => setShowImportModal(true)} onSubscribe={canSubscribe ? () => setShowSubscriptionModal(true) : undefined} onRefresh={() => { void refreshCalendar(); }} - onPrint={() => { void requestPrint({ kind: "calendar-view" }); }} + onPrint={printOff ? undefined : () => { void requestPrint({ kind: "calendar-view" }); }} isMobile={isMobile} onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined} calendars={toolbarCalendars} @@ -2383,7 +2386,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)} onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)} onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)} - onPrint={() => { void requestPrint({ kind: "calendar-event", event: eventContextMenu.data! }); }} + onPrint={printOff ? undefined : () => { void requestPrint({ kind: "calendar-event", event: eventContextMenu.data! }); }} editable={canEditEvent(eventContextMenu.data)} /> )} @@ -2443,7 +2446,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onDelete={handleDeleteFromDetail} onDuplicate={handleDuplicateFromDetail} onMore={(e) => handleContextMenuEvent(e, detailEvent)} - onPrint={() => { void requestPrint({ kind: "calendar-event", event: detailEvent }); }} + onPrint={printOff ? undefined : () => { void requestPrint({ kind: "calendar-event", event: detailEvent }); }} onClose={closeDetail} onSaveNote={handleSaveNoteFromDetail} onRsvp={handleRsvpFromDetail} diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index d68647c5b..9b6eea288 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -25,6 +25,7 @@ import type { ContactCard } from "@/lib/jmap/types"; import { getContactPrimaryEmail } from "@/stores/contact-store"; import { exportContact, useContactExportBusy } from "./contact-export"; import { printContact } from "./contact-print"; +import { useStrictestDevices } from "@/hooks/use-device-policy"; import { toast } from "@/stores/toast-store"; function getContactPrimaryPhone(contact: ContactCard): string { @@ -89,6 +90,7 @@ export function ContactContextMenu({ onBatchDelete, }: ContactContextMenuProps) { const t = useTranslations("contacts"); + const printOff = useStrictestDevices().noPrint; const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); const showBatchActions = isMultiSelect && selectedCount > 1; @@ -216,11 +218,13 @@ export function ContactContextMenu({ onClick={handle(handleExport)} disabled={exporting} /> - + {!printOff && ( + + )} Devices > No printing: no Print buttons (Ctrl+P prints a note). + const printOff = useStrictestDevices().noPrint; const tSidebar = useTranslations("sidebar"); const tCommon = useTranslations("common"); const tShell = useTranslations("shell"); @@ -961,7 +964,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = return ( directoryKey(e) === openDirectoryKey) ?? null} - onPrint={(entry) => { void requestPrint({ kind: "directory-entry", entry }); }} + onPrint={printOff ? undefined : (entry) => { void requestPrint({ kind: "directory-entry", entry }); }} onCompose={(entry) => handleComposeDirectory([entry])} onAddToContacts={guard(handleAddDirectoryEntryToContacts)} /> @@ -995,7 +998,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onDelete={handleDeleteGroup} onRemoveMember={handleRemoveGroupMember} onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)} - onPrint={() => { void requestPrint({ kind: "contact-group", group: selectedGroup }); }} + onPrint={printOff ? undefined : () => { void requestPrint({ kind: "contact-group", group: selectedGroup }); }} isMobile={isMobile} onSelectMember={guard((id: string) => { setSelectedContact(id); @@ -1102,7 +1105,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = : undefined } onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined} - onPrint={selectedContact ? () => { void printContact(selectedContact); } : undefined} + onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined} isMobile={isMobile} /> ); @@ -1333,7 +1336,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onSelectedChange={setDirectorySelection} onOpen={(entry) => { setOpenDirectoryKey(directoryKey(entry)); setView("directory-detail"); }} activeKey={openDirectoryKey} - onPrint={() => { void requestPrint({ kind: "directory-list" }); }} + onPrint={printOff ? undefined : () => { void requestPrint({ kind: "directory-list" }); }} printing={printContent?.kind === "directory-list"} onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined} className="flex-1" @@ -1386,11 +1389,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = <> { close(); guard(handleCreateGroup)(); }} /> { close(); setShowImportDialog(true); }} /> - { close(); void requestPrint({ kind: "contact-list" }); }} /> + {!printOff && { close(); void requestPrint({ kind: "contact-list" }); }} />} )} isLoading={supportsSync && !contactsLoaded} - onPrint={() => { void requestPrint({ kind: "contact-list" }); }} + onPrint={printOff ? undefined : () => { void requestPrint({ kind: "contact-list" }); }} printTitle={printContent?.kind === "list" ? listTitle : null} /> )} diff --git a/components/device/__tests__/device-gates.test.tsx b/components/device/__tests__/device-gates.test.tsx new file mode 100644 index 000000000..bb86a6b7b --- /dev/null +++ b/components/device/__tests__/device-gates.test.tsx @@ -0,0 +1,172 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ appName: 'Bulwark', faviconUrl: '', appLogoLightUrl: '', appLogoDarkUrl: '' }), +})); +const toast = vi.hoisted(() => ({ info: vi.fn(), error: vi.fn(), success: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); + +import { PWAInstallPrompt } from '@/components/pwa-install-prompt'; +import { ReaderAttachments, type ReaderAttachmentItem } from '@/components/email/reader-attachments'; +import { DevicePolicyHost, NO_PRINT_ATTRIBUTE } from '@/components/device/device-policy-host'; +import { FilePreviewModal } from '@/components/files/file-preview-modal'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { saveBlobAs } from '@/lib/download-guard'; +import { printDocument } from '@/lib/plugin-sandbox/host-api-print'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useMailTransferStore, openMailExport, openMailImport } from '@/stores/mail-transfer-store'; +import { requestPrint, usePrintStore } from '@/stores/print-store'; +import { useAuthStore } from '@/stores/auth-store'; + +// The device policies through the components that hold them +// (Organisation > Devices). One account is signed in here, so its policy +// is also the strictest. + +type Devices = OrganisationPolicy['devices']; +function setDevices(devices: Partial) { + const policy = { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }; + usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true }); +} + +const initialPolicy = usePolicyStore.getState(); +const initialAuth = useAuthStore.getState(); +beforeEach(() => { + localStorage.clear(); + toast.info.mockClear(); + useAuthStore.setState({ activeAccountId: 'acc-a', username: 'alice@example.org', getAllConnectedClients: (() => new Map()) as never }); +}); +afterEach(() => { + usePolicyStore.setState(initialPolicy, true); + useAuthStore.setState(initialAuth, true); + useMailTransferStore.setState({ request: null, visible: false, job: null }); + vi.restoreAllMocks(); +}); + +const pdf: ReaderAttachmentItem = { id: 'p', name: 'Plan.pdf', type: 'application/pdf', size: 10, blobId: 'b1' }; +const zip: ReaderAttachmentItem = { id: 'z', name: 'Data.zip', type: 'application/zip', size: 10, blobId: 'b2' }; + +describe('No downloads', () => { + function bar(downloadsOff: boolean) { + const props = { + attachments: [pdf, zip], + thumbUrls: {}, + opensPreview: (a: ReaderAttachmentItem) => a.id === 'p', + onOpen: vi.fn(), + onDownload: vi.fn(), + onDownloadAll: vi.fn(), + downloadingAll: false, + downloadsOff, + }; + render(); + return props; + } + + it('keeps the preview and drops every download button in the reader', () => { + const props = bar(true); + expect(screen.queryByText('download_all')).toBeNull(); + expect(screen.queryAllByLabelText(/^download_named/)).toHaveLength(0); + fireEvent.click(screen.getByLabelText('preview_named')); + expect(props.onOpen).toHaveBeenCalledWith(pdf); + // An attachment without a preview opens nothing at all. + fireEvent.click(screen.getByLabelText('download_off_named')); + expect(props.onOpen).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('attachments-downloads-off')).toBeTruthy(); + }); + + it('shows them while the policy is off', () => { + bar(false); + expect(screen.getByText('download_all')).toBeTruthy(); + expect(screen.getAllByLabelText(/^download_named/).length).toBeGreaterThan(0); + }); + + it('offers neither Download nor a new tab in the file preview', async () => { + setDevices({ noDownloads: true }); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + render( + {}} + onDownload={() => {}} + getFileContent={async () => ({ blob: new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' }), contentType: 'image/png' })} + policyAccount={null} + />, + ); + await screen.findByAltText('photo.png'); + expect(screen.queryByLabelText('download')).toBeNull(); + expect(screen.queryByLabelText('open_in_new_tab')).toBeNull(); + }); + + it('tells the user why a download did nothing', () => { + setDevices({ noDownloads: true }); + render(); + act(() => { saveBlobAs(new Blob(['x']), 'x.pdf', { kind: 'attachment', subject: null }); }); + expect(toast.info).toHaveBeenCalledWith('download_blocked'); + }); +}); + +describe('No export', () => { + it('doesn’t open the export dialog, and import stays', () => { + setDevices({ noExport: true }); + openMailExport({ mailboxId: 'm1' }); + expect(useMailTransferStore.getState().visible).toBe(false); + openMailImport(); + expect(useMailTransferStore.getState().request).toMatchObject({ kind: 'import' }); + }); + + it('stops a running export and deletes its file when switched on', async () => { + setDevices({}); + const discard = vi.fn(async () => {}); + const controller = new AbortController(); + useMailTransferStore.setState({ + request: { kind: 'export' }, visible: true, + job: { kind: 'export', controller, finished: Promise.resolve(), progress: { phase: 'done', exported: 1, failed: 0, failures: [], total: 1, fraction: 1, file: { name: 'a.mbox', size: 1, discard } } as never }, + }); + render(); + act(() => setDevices({ noExport: true })); + await waitFor(() => expect(discard).toHaveBeenCalledWith(true)); + expect(controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); +}); + +describe('No printing', () => { + it('marks the page so Ctrl+P prints only the note', () => { + setDevices({ noPrint: true }); + const { unmount } = render(); + expect(document.documentElement.hasAttribute(NO_PRINT_ATTRIBUTE)).toBe(true); + expect(document.querySelector('[data-print-blocked]')?.textContent).toBe('print_blocked'); + act(() => setDevices({})); + expect(document.documentElement.hasAttribute(NO_PRINT_ATTRIBUTE)).toBe(false); + unmount(); + }); + + it('opens no print dialog for calendar and contacts', async () => { + setDevices({ noPrint: true }); + const print = vi.spyOn(window, 'print').mockImplementation(() => {}); + await requestPrint({ kind: 'contact-list' }); + expect(print).not.toHaveBeenCalled(); + expect(usePrintStore.getState().printing).toBe(false); + }); +}); + +describe('No install prompt', () => { + it('shows no card and holds back the browser’s own banner', () => { + setDevices({ noInstallPrompt: true }); + render(); + const event = new Event('beforeinstallprompt', { cancelable: true }); + Object.assign(event, { prompt: vi.fn(), userChoice: Promise.resolve({ outcome: 'dismissed' }) }); + act(() => { window.dispatchEvent(event); }); + expect(event.defaultPrevented).toBe(true); + expect(screen.queryByText('title')).toBeNull(); + }); +}); + +describe('Watermark', () => { + it('lies over plugin printouts', () => { + expect(printDocument('T', '

x

', 'ltr', 'alice@example.org · 1 Nov 2026')).toContain('alice@example.org · 1 Nov 2026'); + expect(printDocument('T', '

x

', 'ltr')).not.toContain('bulwark-watermark"'); + }); +}); diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx new file mode 100644 index 000000000..d7465be08 --- /dev/null +++ b/components/device/device-policy-host.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslations } from 'next-intl'; +import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy'; +import { DOWNLOAD_BLOCKED_EVENT, type DownloadKind } from '@/lib/download-guard'; +// Installs the download guard (lib/download-guard.ts) for every page. +import '@/lib/device-policy'; +import { useMailTransferStore } from '@/stores/mail-transfer-store'; +import { toast } from '@/stores/toast-store'; + +/** Set on while printing is off; app/print.css then prints only the note. */ +export const NO_PRINT_ATTRIBUTE = 'data-no-print'; + +/** + * The page-wide parts of the device policies (Organisation > Devices): + * - says why nothing happened when a download or export was refused; + * - Ctrl+P and the browser's Print menu print a page that only says + * printing is turned off (they can't be stopped, only blanked); + * - an export that runs or waits for its download is stopped and its file + * deleted when "No export" is switched on while it runs. + */ +export function DevicePolicyHost() { + const t = useTranslations('device_policy'); + const strictest = useStrictestDevices(); + const activeNoExport = useDevicePolicy().noExport; + + useEffect(() => { + const onBlocked = (event: Event) => { + const kind = (event as CustomEvent<{ kind?: DownloadKind }>).detail?.kind; + toast.info(kind === 'export' ? t('export_blocked') : t('download_blocked')); + }; + window.addEventListener(DOWNLOAD_BLOCKED_EVENT, onBlocked); + return () => window.removeEventListener(DOWNLOAD_BLOCKED_EVENT, onBlocked); + }, [t]); + + useEffect(() => { + const root = document.documentElement; + if (strictest.noPrint) root.setAttribute(NO_PRINT_ATTRIBUTE, ''); + else root.removeAttribute(NO_PRINT_ATTRIBUTE); + return () => root.removeAttribute(NO_PRINT_ATTRIBUTE); + }, [strictest.noPrint]); + + useEffect(() => { + if (!activeNoExport) return; + const store = useMailTransferStore.getState(); + if (store.job?.kind !== 'export') return; + const job = store.job; + store.cancelJob('policy'); + // The private file goes at once, once the job has stopped: nothing may download it any more. + const discard = () => { + if (useMailTransferStore.getState().job?.controller === job.controller) useMailTransferStore.getState().close({ now: true }); + }; + if (job.finished) void job.finished.then(discard, discard); + else discard(); + }, [activeNoExport]); + + if (!strictest.noPrint || typeof document === 'undefined') return null; + return createPortal( + , + document.body, + ); +} diff --git a/components/device/watermark.tsx b/components/device/watermark.tsx new file mode 100644 index 000000000..c58904b7d --- /dev/null +++ b/components/device/watermark.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useMemo } from 'react'; +import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy'; +import { watermarkAddress, watermarkText, type DeviceSubject } from '@/lib/device-policy'; +import { WATERMARK_COLUMNS, WATERMARK_CSS, WATERMARK_ROWS } from '@/lib/watermark'; +import { useAuthStore } from '@/stores/auth-store'; +import { useLocaleStore } from '@/stores/locale-store'; + +type HookSubject = Exclude; + +/** + * The watermark text for a message or file of this account ("user@example.org + * · 1 Nov 2026"), or null when its device policy has no watermark. Without a + * subject: the strictest of the signed-in accounts, with the active + * account's address. + */ +export function useWatermarkText(subject?: HookSubject, options: { strictest?: boolean } = {}): string | null { + const own = useDevicePolicy(subject); + const strictest = useStrictestDevices(); + const locale = useLocaleStore((s) => s.locale) || undefined; + const username = useAuthStore((s) => s.username); + const on = options.strictest ? strictest.watermark : own.watermark; + return useMemo( + () => (on ? watermarkText(watermarkAddress(subject) || username || '', new Date(), locale) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [on, locale, username, typeof subject === 'string' ? subject : subject?.sourceClientAccountId], + ); +} + +/** + * The watermark layer over a reader or preview: its parent needs + * `position: relative`. Hidden from screen readers and from the pointer. + */ +export function Watermark({ text, fixed = false }: { text: string | null; fixed?: boolean }) { + if (!text) return null; + return ( + <> + + + + ); +} diff --git a/components/email/conversation-header-actions.tsx b/components/email/conversation-header-actions.tsx index 6c62cc510..daccc86de 100644 --- a/components/email/conversation-header-actions.tsx +++ b/components/email/conversation-header-actions.tsx @@ -15,6 +15,7 @@ import { Archive, FolderInput, MoreVertical, Trash2 } from "@/components/icons"; import { EmailContextMenu } from "./email-context-menu"; import { MoveTargetList } from "./move-target-list"; import { ViewSourceDialog } from "./view-source-dialog"; +import { canExport } from "@/lib/device-policy"; import type { MessageActionKey } from "./message-actions"; /** @@ -135,8 +136,9 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac onUndoSpam={actions.onUndoSpam} copyTargets={copyTargets} onCopyToAccount={(accountId, mailboxId) => void copyToAccount([email], accountId, mailboxId)} - onExport={() => void exportEmail(email, exportFollowUp, source)} - onViewSource={() => setShowSource(true)} + // Not under the device policy "No export" of the message's account. + onExport={canExport(email) ? () => void exportEmail(email, exportFollowUp, source) : undefined} + onViewSource={canExport(email) ? () => setShowSource(true) : undefined} omit={OMIT} rowPlugins={false} /> diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 835d986d6..05def799e 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -56,6 +56,7 @@ import { MvListSectionHeader } from "./mv-list-section-header"; import { PluginSearchResults } from "./plugin-search-results"; import { commonTagScope } from "@/hooks/use-account-tags"; import { FolderRetentionLine } from "./retention-notice"; +import { canExport, canPrint } from "@/lib/device-policy"; interface EmailListProps { emails: Email[]; @@ -1367,7 +1368,8 @@ export function EmailList({ : [contextMenuEmail!]; await copyToAccount(emails, accountId, mailboxId); }} - onExport={() => { + // Export, print and View source follow the device policy of the row's own account. + onExport={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => { const target = contextMenuEmail!; void exportEmail(target, { onArchive: onArchive ? () => onArchive(target) : undefined, @@ -1375,13 +1377,13 @@ export function EmailList({ archiveEnabled: archiveActionEnabled, }); }} - onPrint={onEmailSelect ? () => { + onPrint={onEmailSelect && !(contextMenuEmail && !canPrint(contextMenuEmail)) ? () => { // The print window opens in the click; the reader fills it once // the message is open. const target = contextMenuEmail!; - if (requestPrint(emailKeyFor(target))) onEmailSelect(target); + if (requestPrint(emailKeyFor(target), target)) onEmailSelect(target); } : undefined} - onViewSource={() => setSourceEmail(contextMenuEmail)} + onViewSource={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => setSourceEmail(contextMenuEmail)} onBatchMarkAsRead={(read) => markSelectionRead(read)} batchTagIds={selectionTags.all} batchPartialTagIds={selectionTags.some} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ae8b25a55..64f7813bd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -149,6 +149,10 @@ import { MailtoLink } from "@/components/ui/mailto-link"; import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; import { openPreviewInNewWindow } from "@/lib/standalone-display"; +import { downloadAllowed, saveBlobAs } from "@/lib/download-guard"; +import { useDevicePolicy } from "@/hooks/use-device-policy"; +import { Watermark, useWatermarkText } from "@/components/device/watermark"; +import { watermarkForPrint } from "@/lib/watermark"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; @@ -1328,6 +1332,17 @@ export function EmailViewer({ const messageSpacing = useSettingsStore((state) => state.messageSpacing); const plainTextFont = useSettingsStore((state) => state.plainTextFont); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); + // The device policies of the message's own account (Organisation > + // Devices): no downloads keeps previews and drops every way to save a + // copy; no export and no print drop those actions; the watermark lies over + // the body and the printout. + const devices = useDevicePolicy(email ?? undefined); + const downloadsOff = devices.noDownloads; + const downloadsOffRef = useRef(downloadsOff); + downloadsOffRef.current = downloadsOff; + const guardEmailRef = useRef(email); + guardEmailRef.current = email; + const watermark = useWatermarkText(email ?? undefined); // Office documents the built-in preview can't render still open in the // configured WOPI editor (#1047) - only blob-backed ones, the editor // fetches the content server-side. @@ -1571,14 +1586,7 @@ export function EmailViewer({ }, [inAppPreview]); const downloadInAppPreview = useCallback(() => { if (!inAppPreview) return; - const url = URL.createObjectURL(inAppPreview.blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = inAppPreview.downloadName; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 60_000); + saveBlobAs(inAppPreview.blob, inAppPreview.downloadName, { kind: 'attachment', subject: guardEmailRef.current }); }, [inAppPreview]); const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); @@ -2853,8 +2861,9 @@ export function EmailViewer({ // Blob URLs inherit our origin; script-bearing MIME types (text/html, // image/svg+xml, etc.) would execute as the webmail origin if opened // top-level. Force the download path for anything not on the inert allowlist. + // With downloads turned off, a click previews whatever can be previewed. const opensPreview = isPreviewable - && mailAttachmentAction === 'preview' + && (mailAttachmentAction === 'preview' || downloadsOff) && isMimeTypeSafeForInlinePreview(attachment.type); const downloadName = resolveAttachmentName(attachment); @@ -2884,17 +2893,12 @@ export function EmailViewer({ if (opensPreview) { const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info); - if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) { + if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl, email)) { setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type }); } - } else { + } else if (downloadAllowed('attachment', email)) { emailHooks.onAttachmentDownload.emit(info); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = downloadName; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); + saveBlobAs(objectUrl, downloadName, { kind: 'attachment', subject: email }); } setTimeout(() => URL.revokeObjectURL(objectUrl), 60000); @@ -2916,23 +2920,19 @@ export function EmailViewer({ if (opensPreview) { const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info); - if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) { + if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl, email)) { setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type }); } - } else { + } else if (downloadAllowed('attachment', email)) { emailHooks.onAttachmentDownload.emit(info); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = downloadName; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); + saveBlobAs(objectUrl, downloadName, { kind: 'attachment', subject: email }); } setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); - }, [mailAttachmentAction, onDownloadAttachment, email, resolveAttachmentName]); + }, [mailAttachmentAction, downloadsOff, onDownloadAttachment, email, resolveAttachmentName]); const handleEffectiveAttachmentDownload = useCallback((attachment: EffectiveAttachment) => { + if (!downloadAllowed('attachment', email)) return; const downloadName = resolveAttachmentName(attachment); const info: AttachmentInfo = { name: attachment.name || '', @@ -2953,14 +2953,7 @@ export function EmailViewer({ attachment.tnefData.byteOffset + attachment.tnefData.byteLength, ) as ArrayBuffer; const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); - const objectUrl = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = downloadName; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(objectUrl), 60000); + saveBlobAs(blob, downloadName, { kind: 'attachment', subject: email }); return; } @@ -2969,15 +2962,8 @@ export function EmailViewer({ if (!bytes || bytes.byteLength === 0) return; const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); - const objectUrl = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = downloadName; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); - }, [onDownloadAttachment, email?.id, resolveAttachmentName]); + saveBlobAs(blob, downloadName, { kind: 'attachment', subject: email }); + }, [onDownloadAttachment, email, resolveAttachmentName]); // Bundle every attachment of this email into a single .zip and download it. // Fetches blob-backed attachments through the JMAP client and reuses already @@ -2985,6 +2971,7 @@ export function EmailViewer({ // failures are skipped so a single bad blob doesn't sink the whole archive. const handleDownloadAllAttachments = useCallback(async () => { if (isDownloadingAll || effectiveAttachments.length === 0) return; + if (!downloadAllowed('attachment', email)) return; setIsDownloadingAll(true); try { const { default: JSZip } = await import('jszip'); @@ -3034,14 +3021,8 @@ export function EmailViewer({ } const zipBlob = await zip.generateAsync({ type: 'blob', mimeType: 'application/zip' }); - const objectUrl = URL.createObjectURL(zipBlob); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = attachmentsBundleFilename(email); - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + // The policy may have changed while the attachments were fetched. + if (!saveBlobAs(zipBlob, attachmentsBundleFilename(email), { kind: 'attachment', subject: email })) return; const omitted = effectiveAttachments.length - added; if (omitted > 0) toast.warning(t('download_all_partial', { count: omitted })); } catch (error) { @@ -3057,15 +3038,17 @@ export function EmailViewer({ const readerAttachmentProps = { attachments: effectiveAttachments, thumbUrls: imageThumbUrls, - opensPreview: (attachment: EffectiveAttachment) => isAttachmentPreviewable(attachment) && mailAttachmentAction === 'preview', + opensPreview: (attachment: EffectiveAttachment) => isAttachmentPreviewable(attachment) && (mailAttachmentAction === 'preview' || downloadsOff), onOpen: handleEffectiveAttachmentOpen, onDownload: handleEffectiveAttachmentDownload, onDownloadAll: handleDownloadAllAttachments, downloadingAll: isDownloadingAll, - onSaveToFiles: canSaveToFiles ? setSaveToFilesTarget : undefined, + downloadsOff, + // Save to Files would get around the policy: Files downloads are off too, but the copy would stay. + onSaveToFiles: canSaveToFiles && !downloadsOff ? setSaveToFilesTarget : undefined, pluginMenu: (attachment: EffectiveAttachment) => , renderItem: (attachment: EffectiveAttachment, tile: (drag: AttachmentDragProps) => React.ReactNode) => ( - + {(dragProps) => (<> {tile(dragProps)} @@ -3273,8 +3256,8 @@ export function EmailViewer({ pre { white-space: pre-wrap; word-wrap: break-word; } ${wordHtmlCSS} ${darkModeCSS} -${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}${reading.frameCss ? `` : ''}`; - }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]); +${devices.noPrint ? '' : ''}${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}${reading.frameCss ? `` : ''}`; + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources, devices.noPrint]); // Unblocking external content is handled by rebuilding the iframe srcDoc: // toggling allowExternalContent (both "Load images" and "Trust sender" set @@ -3379,18 +3362,13 @@ export function EmailViewer({ && mailAttachmentActionRef.current === 'preview' && isFilePreviewable(part.name, part.type) && isMimeTypeSafeForInlinePreview(part.type); - if (opensPreview) { - if (!openPreviewInNewWindow(href)) { + if (opensPreview || (part && downloadsOffRef.current && isFilePreviewable(part.name, part.type) && isMimeTypeSafeForInlinePreview(part.type))) { + if (!openPreviewInNewWindow(href, guardEmailRef.current)) { setInAppPreview({ name: part.name, downloadName: part.name, blob: part.blob, contentType: part.type || part.blob.type }); } return; } - const anchor = document.createElement('a'); - anchor.href = href; - anchor.download = part?.name || 'download'; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); + saveBlobAs(href, part?.name || 'download', { kind: 'attachment', subject: guardEmailRef.current }); }, []); const handleIframeLoad = useCallback(() => { @@ -3615,7 +3593,7 @@ export function EmailViewer({ // Export email as .eml file; the row's menu exports the same way. const exportEmail = useEmailExport(); const handleExportEmail = async () => { - if (!email) return; + if (!email || devices.noExport) return; // With the Archive action off a stored 'archive' keeps the message. await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined }); }; @@ -3629,6 +3607,11 @@ export function EmailViewer({ // the list's row menu brings its own window, opened in that click. const handlePrint = (openedWindow?: Window) => { if (!email) return; + if (devices.noPrint) { + openedWindow?.close(); + return; + } + const printWatermark = watermark ? watermarkForPrint(watermark) : null; const printSender = email.from?.[0]; const date = formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }); const formatRecipient = (r: { name?: string | null; email: string }) => @@ -3679,7 +3662,8 @@ export function EmailViewer({ .body img[style*="max-width"] { max-width: 100%; } .body img { height: auto !important; } @media print { body { margin: 20px; } } - +${printWatermark ? printWatermark.style : ''} +${printWatermark ? printWatermark.html : ''}
${escapeHtml(subjectText)}
@@ -4126,9 +4110,10 @@ export function EmailViewer({ onRemoveFromFolder, onMarkAsSpam, onUndoSpam, - onExport: () => void handleExportEmail(), - onPrint: () => handlePrint(), - onViewSource: () => setShowSourceModal(true), + // Hidden by the device policies of the message's account. + onExport: devices.noExport ? undefined : () => void handleExportEmail(), + onPrint: devices.noPrint ? undefined : () => handlePrint(), + onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true), omit: readerMenuOmit, extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)), }; @@ -4420,7 +4405,7 @@ export function EmailViewer({ {/* Print - hidden on mobile, overflows to More menu. Mountain View has it on the subject line. */} - {!mvViewer && ( + {!mvViewer && !devices.noPrint && ( + )} {onToggleFullscreen && (
); @@ -167,7 +181,7 @@ export function ReaderAttachments({
({