From 9f8d134e853ed8716cd0b5251244a34641ec27c2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:47:28 +0100 Subject: [PATCH] fix: keep pictures in messages and the Files image preview from being saved under No downloads, and say the mobile app's notifications aren't covered yet --- .../admin/org/sections/devices-section.tsx | 2 +- .../device/__tests__/device-media.test.tsx | 70 +++++++++++++++++++ components/email/email-viewer.tsx | 7 +- components/email/reader-attachments.tsx | 3 +- components/email/thread-conversation-view.tsx | 9 ++- components/files/file-preview-modal.tsx | 3 +- components/files/image-preview-modal.tsx | 9 ++- lib/device-frame.ts | 37 ++++++++++ 8 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 components/device/__tests__/device-media.test.tsx create mode 100644 lib/device-frame.ts diff --git a/components/admin/org/sections/devices-section.tsx b/components/admin/org/sections/devices-section.tsx index 5992f22c1..48243d5d9 100644 --- a/components/admin/org/sections/devices-section.tsx +++ b/components/admin/org/sections/devices-section.tsx @@ -45,7 +45,7 @@ export const DEVICE_FIELDS: DeviceField[] = [ { 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.', + description: 'Browser 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. The Bulwark mobile app isn’t covered yet: its notifications still show sender and subject.', enforced: 'server', }, { diff --git a/components/device/__tests__/device-media.test.tsx b/components/device/__tests__/device-media.test.tsx new file mode 100644 index 000000000..9f99b843e --- /dev/null +++ b/components/device/__tests__/device-media.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ImagePreviewModal } from '@/components/files/image-preview-modal'; +import { ReaderAttachments, type ReaderAttachmentItem } from '@/components/email/reader-attachments'; +import { blockMediaSaving } from '@/lib/device-frame'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +// "No downloads" for pictures: no "Save image as" or dragging out in the +// message frames, the attachment thumbnails and the Files image preview, +// which also gets the watermark (Organisation > Devices). + +const initialPolicy = usePolicyStore.getState(); +const initialAuth = useAuthStore.getState(); +afterEach(() => { + usePolicyStore.setState(initialPolicy, true); + useAuthStore.setState(initialAuth, true); +}); + +function setDevices(devices: Record) { + const policy = { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }; + usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true }); + useAuthStore.setState({ activeAccountId: 'acc-a', username: 'alice@example.org', getAllConnectedClients: (() => new Map()) as never }); +} + +/** Dispatches a cancelable event and says whether something refused it. */ +function refused(target: Element, type: 'contextmenu' | 'dragstart'): boolean { + const event = new Event(type, { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event.defaultPrevented; +} + +describe('pictures under "No downloads"', () => { + it('a message frame refuses the context menu and dragging on pictures only, while the policy is on', () => { + const doc = document.implementation.createHTMLDocument('mail'); + doc.body.innerHTML = '

Text

inline'; + let on = true; + blockMediaSaving(doc, () => on); + expect(refused(doc.querySelector('img')!, 'contextmenu')).toBe(true); + expect(refused(doc.querySelector('img')!, 'dragstart')).toBe(true); + expect(refused(doc.querySelector('p')!, 'contextmenu')).toBe(false); + on = false; + expect(refused(doc.querySelector('img')!, 'contextmenu')).toBe(false); + }); + + it('attachment thumbnails can’t be saved', () => { + const png: ReaderAttachmentItem = { id: 'p', name: 'chart.png', type: 'image/png', size: 10, blobId: 'b' }; + render( true} onOpen={() => {}} onDownload={() => {}} onDownloadAll={() => {}} downloadingAll={false} downloadsOff />); + const img = document.querySelector('[data-testid="reader-attachments"] img')!; + expect(fireEvent.contextMenu(img)).toBe(false); + }); + + it('the Files image preview refuses saving and carries the watermark', async () => { + setDevices({ noDownloads: true, watermark: true }); + render( {}} getImageUrl={async () => 'blob:photo'} />); + const img = await screen.findByAltText('photo.png'); + expect(fireEvent.contextMenu(img)).toBe(false); + expect(screen.getByTestId('watermark').textContent).toContain('alice@example.org'); + }); + + it('leaves the Files image preview alone without the policy', async () => { + setDevices({}); + render( {}} getImageUrl={async () => 'blob:photo'} />); + const img = await screen.findByAltText('photo.png'); + expect(fireEvent.contextMenu(img)).toBe(true); + expect(screen.queryByTestId('watermark')).toBeNull(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 64f7813bd..ac95f578f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -153,6 +153,7 @@ 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 { NO_SAVE_MEDIA_CSS, blockMediaSaving } from "@/lib/device-frame"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; @@ -3256,8 +3257,8 @@ export function EmailViewer({ pre { white-space: pre-wrap; word-wrap: break-word; } ${wordHtmlCSS} ${darkModeCSS} -${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]); +${devices.noPrint ? '' : ''}${devices.noDownloads ? `` : ''}${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, devices.noDownloads]); // Unblocking external content is handled by rebuilding the iframe srcDoc: // toggling allowExternalContent (both "Load images" and "Trust sender" set @@ -3556,6 +3557,8 @@ export function EmailViewer({ // A press in the body closes a mention's card. The page never sees // it: the focus is already inside the iframe, so there is no blur. doc.addEventListener('mousedown', () => setBodyMention(null), true); + // "No downloads": the message's own pictures can't be saved or dragged out either. + blockMediaSaving(doc, () => downloadsOffRef.current); // Selected text, for the side panel, the onTextSelectionChange hook // and the right-click menu plugins may add entries to. watchBodySelection(doc, iframe, { diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index cc7facc74..95f3b54e4 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { cn, formatFileSize } from "@/lib/utils"; +import { noMediaSavingProps } from "@/lib/device-frame"; import { attachmentIcon } from "./attachment-chips"; export interface ReaderAttachmentItem { @@ -237,7 +238,7 @@ export function ReaderAttachments({ const draw = layout === "cards" ? card : tile; return ( -
+