feat: let admins turn off downloads, export, printing and the install prompt, and add a watermark

Organisation > Devices now sets policy.json `organisation.devices`, each
switch off by default and scoped per domain or group:

- No downloads: attachments and files only preview (reader, conversation
  view, Pro tab, list chips, .eml previews, the Files app). Download,
  Download all, Open in new tab, Save to Files, drag-out, the browser's
  PDF viewer (pdf.js instead) and media download controls go away.
- No export: no .eml export, View source, message drag-out, or folder and
  account export; a running export stops and its file is deleted when
  the switch turns on. Import stays.
- No printing: no Print buttons in mail, calendar and contacts; Ctrl+P
  prints a note; the reader frame prints blank.
- No install prompt: no card, and the browser's own banner is held back.
- Watermark: the user's address and the date over the reader,
  previews and every printout (mail, calendar, contacts, plugins).

A message follows its own account's policy; what isn't about one message
follows the strictest signed-in account. Every user path hides its
button, and the anchor and new-tab helpers refuse as a backstop
(lib/download-guard.ts), never the blob readers. The plugin APIs
`ui.downloadFile` and `ui.print` refuse too, and the office editor frame
loses `allow-downloads`.

Two assertions in components/mail/__tests__/blob-routing.test.tsx now
expect `{ kind: 'export' }` as the fifth argument of downloadBlob for an
.eml export: the export says what it is, so the backstop checks "No
export" rather than "No downloads" for it.
This commit is contained in:
Linus Rath
2026-11-01 08:52:34 +01:00
parent 140db2cf8f
commit e80f5ade81
80 changed files with 1475 additions and 241 deletions
+2
View File
@@ -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({
<PluginAppEvents />
<PluginPageNavigator />
<PWAInstallPrompt />
<DevicePolicyHost />
<PushNotificationPrompt />
{/* Lite is a static bundle: no server keeps an archive there. */}
{!IS_LITE && <AccountArchiveRestorePrompt />}
+22
View File
@@ -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;
@@ -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<DeviceField['enforced'], string> = {
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 <SettingsSection id="devices" title="Downloads, printing and notifications" description="Not set up yet.">{null}</SettingsSection>;
return (
<SettingsSection
id="devices"
title="Downloads, printing and notifications"
description="Make it harder to copy mail out of Bulwark on the devices people use. Each switch is off until you turn it on."
>
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200" data-testid="org-devices-warning">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>
These settings make copying harder in Bulwark. They don&apos;t stop screenshots, other mail apps, or someone who
knows the browser&apos;s tools: the browser fetches mail from the mail server directly, so only the push text and
the office editor are checked on the server.{' '}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
What each switch covers
</a>
</p>
</div>
{DEVICE_FIELDS.map((field) => {
const { value, own, everyone } = draft.orgValue('devices', field.key);
return (
<SettingItem key={field.key} label={field.label} description={`${field.description} ${ENFORCED[field.enforced]}`}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={value} onChange={(v) => draft.setOrgValue('devices', field.key, v)} testId={`org-devices-${field.key}`} />
<InheritNote draft={draft} own={own} everyone={onOff(everyone)} onReset={() => draft.clearOrgValue('devices', field.key)} />
</div>
</SettingItem>
);
})}
<p className="pt-2 text-xs text-muted-foreground">
With several accounts signed in, each message follows its own account&apos;s settings. What isn&apos;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.
</p>
</SettingsSection>
);
}
+6 -3
View File
@@ -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}
+9 -5
View File
@@ -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}
/>
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
{!printOff && (
<ContextMenuItem
icon={Printer}
label={t("context_menu.print")}
onClick={handle(handlePrint)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
+9 -6
View File
@@ -23,6 +23,7 @@ import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-deta
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
import { inlineContactMedia, type ContactSaveOptions } from "@/lib/contact-media";
@@ -89,6 +90,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
const t = useTranslations("contacts");
// Organisation > 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 (
<DirectoryEntryDetail
entry={directoryPrincipals.find((e) => 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 =
<>
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { close(); guard(handleCreateGroup)(); }} />
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { close(); setShowImportDialog(true); }} />
<ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { close(); void requestPrint({ kind: "contact-list" }); }} />
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { 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}
/>
)}
@@ -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<Devices>) {
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(<ReaderAttachments {...props} />);
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(
<FilePreviewModal
name="photo.png"
onClose={() => {}}
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(<DevicePolicyHost />);
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(<DevicePolicyHost />);
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(<DevicePolicyHost />);
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(<PWAInstallPrompt />);
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', '<p>x</p>', 'ltr', 'alice@example.org · 1 Nov 2026')).toContain('alice@example.org · 1 Nov 2026</span>');
expect(printDocument('T', '<p>x</p>', 'ltr')).not.toContain('bulwark-watermark"');
});
});
+66
View File
@@ -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 <html> 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(
<div data-print-blocked="" aria-hidden="true">
<p>{t('print_blocked')}</p>
</div>,
document.body,
);
}
+45
View File
@@ -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<DeviceSubject, { client: object }>;
/**
* 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 (
<>
<style>{WATERMARK_CSS}</style>
<div className="bulwark-watermark" aria-hidden="true" data-testid="watermark" data-fixed={fixed ? '' : undefined}>
{Array.from({ length: WATERMARK_ROWS * WATERMARK_COLUMNS }, (_, i) => <span key={i}>{text}</span>)}
</div>
</>
);
}
@@ -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}
/>
+6 -4
View File
@@ -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}
+59 -70
View File
@@ -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) => <AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="p-2 rounded-md" />,
renderItem: (attachment: EffectiveAttachment, tile: (drag: AttachmentDragProps) => React.ReactNode) => (
<DraggableAttachmentChip attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive && !downloadsOff} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
{tile(dragProps)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
@@ -3273,8 +3256,8 @@ export function EmailViewer({
pre { white-space: pre-wrap; word-wrap: break-word; }
${wordHtmlCSS}
${darkModeCSS}
</style></head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}body{overflow-y:hidden!important}</style>${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, recolorDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]);
</style>${devices.noPrint ? '<style>@media print{html,body{display:none!important}}</style>' : ''}</head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, recolorDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}body{overflow-y:hidden!important}</style>${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [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; } }
</style></head><body dir="auto">
</style>${printWatermark ? printWatermark.style : ''}</head><body dir="auto">
${printWatermark ? printWatermark.html : ''}
<div class="header">
<div class="subject">${escapeHtml(subjectText)}</div>
<div class="meta">
@@ -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 && (
<Button
variant="ghost"
size="sm"
@@ -4754,6 +4739,7 @@ export function EmailViewer({
{/* Mountain View: print and full screen at the end of the subject line. */}
{mvViewer && (
<div className="hidden lg:flex items-center flex-shrink-0 -me-2" data-mv-subject-actions="">
{!devices.noPrint && (
<button
type="button"
onClick={() => handlePrint()}
@@ -4763,6 +4749,7 @@ export function EmailViewer({
>
<Printer className="w-5 h-5" />
</button>
)}
{onToggleFullscreen && (
<button
type="button"
@@ -5742,6 +5729,7 @@ export function EmailViewer({
)}
style={isDark ? { backgroundColor: '#121212' } : undefined}>
<ReadAloudBar state={readAloud.state} onPause={readAloud.pause} onResume={readAloud.resume} onStop={readAloud.stop} />
<Watermark text={watermark} />
{readingMaskOn && !isBodyLoading && (
<ReadingMask
containerRef={bodyWrapRef}
@@ -6116,6 +6104,7 @@ export function EmailViewer({
onDownload={downloadInAppPreview}
getFileContent={getInAppPreviewContent}
linksOff={phishingMarked}
policyAccount={email ?? undefined}
/>
)}
</>
+30 -10
View File
@@ -52,6 +52,11 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
onDownload: (attachment: T) => void;
onDownloadAll: () => void;
downloadingAll: boolean;
/**
* The account's device policy turned downloads off (Organisation >
* Devices): previews stay, every way to save a copy goes.
*/
downloadsOff?: boolean;
/** Set when the Files app can take attachments. */
onSaveToFiles?: (attachment: T) => void;
/** Plugin actions for one attachment; renders nothing when no plugin offers any. */
@@ -86,6 +91,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
onDownload,
onDownloadAll,
downloadingAll,
downloadsOff = false,
onSaveToFiles,
pluginMenu,
renderItem,
@@ -93,6 +99,11 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
}: ReaderAttachmentsProps<T>) {
const t = useTranslations("email_viewer");
const tFiles = useTranslations("files");
const tDevice = useTranslations("device_policy");
// Opens nothing: no preview, and downloads are off.
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
const openLabel = (attachment: T, name: string) =>
opensPreview(attachment) ? t("preview_named", { name }) : locked(attachment) ? tDevice("download_off_named", { name }) : t("download_named", { name });
const [expanded, setExpanded] = useState(false);
if (attachments.length === 0) return null;
@@ -111,7 +122,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
<div
data-testid="attachment"
data-attachment-name={attachment.name ?? undefined}
title={`${preview ? tFiles("preview") : t("download")} ${name}`}
title={locked(attachment) ? tDevice("downloads_off") : `${preview ? tFiles("preview") : t("download")} ${name}`}
draggable={drag.draggable}
onPointerEnter={drag.onPointerEnter}
onDragStart={drag.onDragStart}
@@ -122,8 +133,9 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
{/* The button covers the whole tile, so a click on the preview opens the file too. */}
<button
type="button"
onClick={() => onOpen(attachment)}
aria-label={preview ? t("preview_named", { name }) : t("download_named", { name })}
onClick={() => { if (!locked(attachment)) onOpen(attachment); }}
aria-disabled={locked(attachment) || undefined}
aria-label={openLabel(attachment, name)}
className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 self-stretch text-start outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
<span className="flex min-w-0 text-sm text-foreground" dir="auto">
@@ -142,6 +154,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</IconButton>
)}
{pluginMenu?.(attachment)}
{!downloadsOff && (
<IconButton
label={t("download_named", { name })}
title={t("download")}
@@ -151,6 +164,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
>
<Download className="h-4 w-4" />
</IconButton>
)}
</div>
</div>
);
@@ -167,7 +181,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
<div
data-testid="attachment"
data-attachment-name={attachment.name ?? undefined}
title={`${preview ? tFiles("preview") : t("download")} ${name}`}
title={locked(attachment) ? tDevice("downloads_off") : `${preview ? tFiles("preview") : t("download")} ${name}`}
draggable={drag.draggable}
onPointerEnter={drag.onPointerEnter}
onDragStart={drag.onDragStart}
@@ -188,8 +202,9 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</div>
<button
type="button"
onClick={() => onOpen(attachment)}
aria-label={preview ? t("preview_named", { name }) : t("download_named", { name })}
onClick={() => { if (!locked(attachment)) onOpen(attachment); }}
aria-disabled={locked(attachment) || undefined}
aria-label={openLabel(attachment, name)}
className="flex min-w-0 flex-col gap-0.5 border-t border-border px-3 py-2.5 text-start outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
<span className="flex min-w-0 text-sm text-foreground" dir="auto">
@@ -209,9 +224,11 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</IconButton>
)}
{pluginMenu?.(attachment)}
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
<Download className="h-4 w-4" />
</IconButton>
{!downloadsOff && (
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
<Download className="h-4 w-4" />
</IconButton>
)}
</div>
</div>
);
@@ -230,7 +247,10 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
<bdi>{formatFileSize(totalSize)}</bdi>
</span>
</div>
{attachments.length > 1 && (
{downloadsOff && (
<span className="text-xs text-muted-foreground" data-testid="attachments-downloads-off">{tDevice("downloads_off")}</span>
)}
{attachments.length > 1 && !downloadsOff && (
<Button
variant="ghost"
size="sm"
+15 -5
View File
@@ -72,6 +72,8 @@ import { ConversationHeaderActions, type ConversationActions } from "./conversat
import { PhishingMarkBanner } from "./phishing-mark-banner";
import { turnOffLinks } from "@/lib/phishing-links";
import { usePhishingMarked } from "@/hooks/use-phishing-report";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
export type ConversationAttachmentHandler = (
blobId: string, name: string, type: string | undefined, source: EmailBlobContext,
@@ -553,6 +555,9 @@ export function ConversationEmailBody({
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
// The device policies of the message's own account (Organisation > Devices).
const devices = useDevicePolicy(email);
const watermark = useWatermarkText(email);
const wopiStatus = useWopiStatus(true);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
@@ -827,8 +832,8 @@ export function ConversationEmailBody({
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
${isDark ? emailDarkModeCss() : ''}
</style></head><body dir="auto">${emailContent.html}${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources]);
</style>${devices.noPrint ? '<style>@media print{html,body{display:none!important}}</style>' : ''}</head><body dir="auto">${emailContent.html}${reading.frameCss ? `<style>${reading.frameCss}</style>` : ''}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage, reading.frameKey, reading.frameCss, appFontSources, devices.noPrint]);
useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]);
// One object per body: React writes innerHTML again whenever this object
// changes, dropping whatever was mounted in the body.
@@ -966,6 +971,7 @@ export function ConversationEmailBody({
{/* Email Body */}
<div className="relative" style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
<Watermark text={watermark} />
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
@@ -1016,15 +1022,19 @@ export function ConversationEmailBody({
const { icon: Icon, className: iconClass } = attachmentIcon(attachment.type, attachment.name);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
// Without downloads, whatever can be previewed is, and nothing else opens.
const opensPreview = isPreviewable && (mailAttachmentAction === 'preview' || devices.noDownloads);
const locked = devices.noDownloads && !opensPreview;
return (
<React.Fragment key={idx}>
<button
onClick={(e) => {
e.stopPropagation();
if (locked) return;
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type, source);
}}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
aria-disabled={locked || undefined}
title={locked ? t('device_policy.downloads_off') : opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className={cn("w-4 h-4", iconClass)} />
@@ -1037,7 +1047,7 @@ export function ConversationEmailBody({
</span>
{opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
) : locked ? null : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
+15 -9
View File
@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { Paperclip, Download } from "@/components/icons";
import { saveBlobAs } from "@/lib/download-guard";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
import { turnOffLinks } from "@/lib/phishing-links";
import { getEffectiveTimeZone } from "@/lib/timezone";
@@ -49,11 +50,13 @@ function escapeHtml(s: string): string {
// can show - often another message: a certified-mail (PEC) envelope wraps the
// original, and a forwarded PEC wraps that envelope - opens through
// onOpenAttachment, with download beside it.
export function EmlPreview({ message, onOpenAttachment, linksOff }: {
export function EmlPreview({ message, onOpenAttachment, linksOff, downloadsOff = false }: {
message: ParsedEml;
onOpenAttachment?: (attachment: EmlAttachment) => void;
/** Attached to a message reported as phishing: links are off. */
linksOff?: boolean;
/** A device policy turned downloads off: attachments only preview. */
downloadsOff?: boolean;
}) {
const t = useTranslations("email_viewer");
const tPhishing = useTranslations("phishing_report");
@@ -82,14 +85,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff }: {
if (!att.content) return;
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
const a = document.createElement("a");
a.href = url;
a.download = att.filename || "attachment";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
saveBlobAs(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }), att.filename || "attachment", { kind: "attachment" });
};
return (
@@ -124,6 +120,14 @@ export function EmlPreview({ message, onOpenAttachment, linksOff }: {
{message.attachments.map((att, i) => {
const name = att.filename ? stripBidiControls(att.filename) : "attachment";
const opens = !!onOpenAttachment && !!att.content && isFilePreviewable(att.filename, att.mimeType);
if (!opens && downloadsOff) {
return (
<span key={i} className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-muted-foreground">
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{name}</span>
</span>
);
}
if (!opens) {
return (
<button
@@ -150,6 +154,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff }: {
<Paperclip className="w-3 h-3 flex-shrink-0" />
<span className="max-w-[200px] truncate">{name}</span>
</button>
{!downloadsOff && (
<button
type="button"
onClick={() => downloadAttachment(att)}
@@ -159,6 +164,7 @@ export function EmlPreview({ message, onOpenAttachment, linksOff }: {
>
<Download className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
);
})}
+14 -11
View File
@@ -115,6 +115,8 @@ interface FileBrowserProps {
onRenameNode: (id: string, newName: string) => Promise<void>;
onDeleteNode: (id: string, name: string) => Promise<void>;
onDownloadNode: (id: string) => Promise<void>;
/** A device policy turned downloads off (Organisation > Devices): every download action goes. */
downloadsOff?: boolean;
onRevealNode: (id: string) => Promise<void>;
onRemoveRecent: (id: string) => void;
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
@@ -203,6 +205,7 @@ export function FileBrowser({
onRenameNode,
onDeleteNode,
onDownloadNode,
downloadsOff = false,
onRevealNode,
onRemoveRecent,
accountFolders,
@@ -861,7 +864,7 @@ export function FileBrowser({
{one && !one.isDirectory && !one.isSymlink && isPreviewable(one.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(one); }} />
)}
{one && !one.isDirectory && !one.isSymlink && (
{one && !one.isDirectory && !one.isSymlink && !downloadsOff && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(one.name); }} />
)}
<ContextMenuSeparator />
@@ -875,7 +878,7 @@ export function FileBrowser({
const withFolders = !!onDownloadZip && menuSelection.some((r) => r.isDirectory);
return (
<>
{(fileCount > 0 || withFolders) && (
{(fileCount > 0 || withFolders) && !downloadsOff && (
<ContextMenuItem
icon={Download}
label={withFolders ? t("download_zip") : t("download_count", { count: fileCount })}
@@ -936,10 +939,10 @@ export function FileBrowser({
{!resource.isSymlink && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy([resource.name]); }} />
)}
{resource.isDirectory && onDownloadZip && (
{resource.isDirectory && onDownloadZip && !downloadsOff && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
)}
{!resource.isDirectory && !resource.isSymlink && (
{!resource.isDirectory && !resource.isSymlink && !downloadsOff && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
)}
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
@@ -969,13 +972,13 @@ export function FileBrowser({
onToggleSelect: handleToggleSelect,
onOpen: openResource,
onContextMenu: openItemMenu,
onDownload: (r) => void onDownload(r.name),
onDownload: downloadsOff ? undefined : (r) => void onDownload(r.name),
onShare: (r) => setShareTargetId(r.id),
canShare,
dragProps: itemDragProps,
// Tab onto an item: the arrows go on from there.
onFocusItem: (r) => setFocusedName(r.name),
}), [handleSelectClick, handleToggleSelect, openResource, openItemMenu, onDownload, canShare, itemDragProps]);
}), [handleSelectClick, handleToggleSelect, openResource, openItemMenu, onDownload, downloadsOff, canShare, itemDragProps]);
const handleSort = useCallback((key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
@@ -1031,11 +1034,11 @@ export function FileBrowser({
onShare={fromDrawer((r: FileResource) => setShareTargetId(r.id))}
onCreateIn={fromDrawer(createIn)}
onRenameFolder={fromDrawer(setRenameFolder)}
onDownloadFolder={onDownloadZip ? (folder: FileResource) => void onDownloadZip([folder.id], `${folder.name}.zip`) : undefined}
onDownloadFolder={onDownloadZip && !downloadsOff ? (folder: FileResource) => void onDownloadZip([folder.id], `${folder.name}.zip`) : undefined}
onMoveFolder={onMoveNode ? fromDrawer((folder: FileResource) => setMoveTarget({ folder })) : undefined}
onDeleteFolder={fromDrawer((folder: FileResource) => void onDeleteNode(folder.id, folder.name))}
onRevealRecent={fromDrawer((id: string) => void onRevealNode(id))}
onDownloadRecent={fromDrawer((id: string) => void onDownloadNode(id))}
onDownloadRecent={downloadsOff ? undefined : fromDrawer((id: string) => void onDownloadNode(id))}
onRemoveRecent={onRemoveRecent}
trashCount={trashCount}
onOpenTrash={fromDrawer(() => onNavigate(TRASH_PATH, TRASH_LOCATION))}
@@ -1154,7 +1157,7 @@ export function FileBrowser({
onClose={() => toggleDetails(false)}
onOpen={() => singleSelected && (singleSelected.isDirectory ? openResource(singleSelected) : previewResource(singleSelected))}
onEdit={() => singleSelected && onEditFile?.(singleSelected.name)}
onDownload={() => singleSelected && void onDownload(singleSelected.name)}
onDownload={downloadsOff ? undefined : () => singleSelected && void onDownload(singleSelected.name)}
onShare={() => singleSelected && setShareTargetId(singleSelected.id)}
onToggleStar={() => singleSelected && onToggleFavorite(pathOf(singleSelected))}
className={cn(
@@ -1183,8 +1186,8 @@ export function FileBrowser({
pluginFiles: [],
} : selectedList.length > 0 ? {
count: selectedList.length,
// Folders download as a zip.
fileCount: selectedList.filter((r) => !r.isSymlink && (!r.isDirectory || !!onDownloadZip)).length,
// Folders download as a zip. None with downloads turned off: no Download button.
fileCount: downloadsOff ? 0 : selectedList.filter((r) => !r.isSymlink && (!r.isDirectory || !!onDownloadZip)).length,
canOpen: !!singleSelected && !singleSelected.isSymlink
&& (singleSelected.isDirectory || isPreviewable(singleSelected.name) || !!isOfficeEditable?.(singleSelected.name)),
canRename: !!singleSelected,
+3 -2
View File
@@ -30,7 +30,8 @@ interface FileDetailsPanelProps {
onClose: () => void;
onOpen: () => void;
onEdit: () => void;
onDownload: () => void;
/** Left out when a device policy turned downloads off. */
onDownload?: () => void;
onShare: () => void;
onToggleStar: () => void;
className?: string;
@@ -138,7 +139,7 @@ export function FileDetailsPanel({
<Eye className="w-4 h-4" />{t("preview")}
</Button>
) : null}
{!resource.isDirectory && !resource.isSymlink && (
{!resource.isDirectory && !resource.isSymlink && onDownload && (
<Button size="sm" variant="outline" className="h-8 gap-1.5" onClick={onDownload}>
<Download className="w-4 h-4" />{t("download")}
</Button>
+34 -17
View File
@@ -12,6 +12,9 @@ import dynamic from "next/dynamic";
import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/files/eml-preview";
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import { saveBlobAs } from "@/lib/download-guard";
import { useDevicePolicy, useStrictestDevices } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
import { stripBidiControls } from "@/lib/bidi";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
import { turnOffLinks, type LinksOffText } from "@/lib/phishing-links";
@@ -56,6 +59,12 @@ interface FilePreviewModalProps {
neighbours?: PreviewNeighbours;
/** The file is an attachment of a message reported as phishing: links in it are off. */
linksOff?: boolean;
/**
* Whose device policy applies (Organisation > Devices): the signed-in
* account the file or message belongs to. Left out: the strictest of the
* accounts signed in here.
*/
policyAccount?: string | { sourceClientAccountId?: string | null } | null;
}
function SimpleMarkdown({ content }: { content: string }) {
@@ -149,8 +158,16 @@ function processInline(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff }: FilePreviewModalProps) {
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount }: FilePreviewModalProps) {
const t = useTranslations("files");
// No downloads: no download button, no new tab, no save from the
// browser's own PDF viewer or media controls. No printing: the PDF goes
// through pdf.js, which has no print button. The preview itself stays.
const ownDevices = useDevicePolicy(policyAccount ?? undefined);
const strictestDevices = useStrictestDevices();
const devices = policyAccount === undefined ? strictestDevices : ownDevices;
const downloadsOff = devices.noDownloads;
const watermark = useWatermarkText(policyAccount ?? undefined, { strictest: policyAccount === undefined });
const tPhishing = useTranslations("phishing_report");
const linksOffText = useMemo<LinksOffText | undefined>(
() => (linksOff ? { title: tPhishing("link_off"), note: tPhishing("link_off_note") } : undefined),
@@ -206,6 +223,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
useCloseOnBack({ onBack: onClose });
const fileType = resolvedFileType;
// The browser's own PDF viewer has Download and Print buttons of its own.
const pdfViaPdfJs = devices.noDownloads || devices.noPrint;
useEffect(() => {
let cancelled = false;
@@ -301,14 +320,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
);
const downloadNested = () => {
if (!nestedPreview) return;
const url = URL.createObjectURL(nestedPreview.blob);
const a = document.createElement("a");
a.href = url;
a.download = nestedPreview.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
saveBlobAs(nestedPreview.blob, nestedPreview.name, { kind: "attachment", subject: policyAccount });
};
// In place of this frame, not on top of it: one frame at a time takes
@@ -321,6 +333,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
onDownload={downloadNested}
getFileContent={nestedContent}
linksOff={linksOff}
policyAccount={policyAccount}
/>
);
}
@@ -329,11 +342,11 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
<PreviewShell
name={name}
onClose={onClose}
onDownload={() => void onDownload()}
onDownload={downloadsOff ? undefined : () => void onDownload()}
neighbours={neighbours}
// An installed app hands the new window to the system browser, which
// cannot load our blob: URL (#865): no dead button there.
actions={objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) ? (
actions={objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) ? (
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
<ExternalLink />
</IconButton>
@@ -341,6 +354,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
>
<div className="relative flex-1 min-h-0 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
{loading && <Spinner label={t("loading")} className="w-8 h-8 text-white/80" />}
{!loading && !error && <Watermark text={watermark} />}
{error && (
<p className="text-sm text-destructive">{t("preview_error")}</p>
@@ -365,11 +379,11 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
{!loading && !error && fileType === "eml" && emlContent && (
<EmlPreview message={emlContent} onOpenAttachment={openEmlAttachment} linksOff={linksOff} />
<EmlPreview message={emlContent} onOpenAttachment={openEmlAttachment} linksOff={linksOff} downloadsOff={downloadsOff} />
)}
{!loading && !error && fileType === "image" && objectUrl && (
<div className="absolute inset-0">
<div className="absolute inset-0" onContextMenu={downloadsOff ? (e) => e.preventDefault() : undefined}>
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={onClose} />
</div>
)}
@@ -384,7 +398,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && (
{!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && !pdfViaPdfJs && (
// <iframe> renders PDFs reliably across desktop Chromium, Firefox,
// and Safari from a blob: URL. <object> was prone to falling back to
// a silent download when the blob's Content-Type wasn't recognised.
@@ -396,7 +410,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && pdfBlob && !pdfInlineSupported && (
{!loading && !error && fileType === "pdf" && objectUrl && pdfBlob && (!pdfInlineSupported || pdfViaPdfJs) && (
// Mobile browsers can't show a PDF inline in an <iframe> (Android: a
// blank frame / silent download; iOS: only the first page), so render
// it with pdf.js (canvas) instead. The header's open-in-new-tab /
@@ -405,20 +419,23 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
className="w-full max-w-3xl h-full overflow-auto rounded-lg bg-neutral-200 dark:bg-neutral-800 p-2"
onClick={(e) => e.stopPropagation()}
>
<PdfMobileViewer url={objectUrl} blob={pdfBlob} />
<PdfMobileViewer url={objectUrl} blob={pdfBlob} allowNewTab={!downloadsOff} />
</div>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
<div className="bg-background rounded-lg p-8 max-w-lg w-full" onClick={(e) => e.stopPropagation()}>
<p className="text-sm font-medium mb-4 text-center">{name}</p>
<audio controls className="w-full" src={objectUrl} />
<audio controls className="w-full" src={objectUrl} controlsList={downloadsOff ? "nodownload" : undefined} onContextMenu={downloadsOff ? (e) => e.preventDefault() : undefined} />
</div>
)}
{!loading && !error && fileType === "video" && objectUrl && (
<video
controls
controlsList={downloadsOff ? "nodownload" : undefined}
disablePictureInPicture={downloadsOff || undefined}
onContextMenu={downloadsOff ? (e) => e.preventDefault() : undefined}
className="max-w-4xl max-h-full rounded-lg"
src={objectUrl}
onClick={(e) => e.stopPropagation()}
+4 -3
View File
@@ -19,7 +19,8 @@ export interface FileItemHandlers {
onToggleSelect: (resource: FileResource) => void;
onOpen: (resource: FileResource) => void;
onContextMenu: (e: React.MouseEvent, resource: FileResource) => void;
onDownload: (resource: FileResource) => void;
/** Left out when a device policy turned downloads off. */
onDownload?: (resource: FileResource) => void;
onShare: (resource: FileResource) => void;
canShare: (resource: FileResource) => boolean;
dragProps: (resource: FileResource) => React.HTMLAttributes<HTMLElement> & { draggable?: boolean };
@@ -293,8 +294,8 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : <FileSize bytes={resource.contentLength} />)}
</div>
<div role="gridcell" className="flex items-center justify-end gap-0.5 w-[4.75rem]">
{!touch && !resource.isDirectory && !resource.isSymlink && (
<RowAction icon={Download} label={t("download")} onClick={() => handlers.onDownload(resource)} />
{!touch && !resource.isDirectory && !resource.isSymlink && handlers.onDownload && (
<RowAction icon={Download} label={t("download")} onClick={() => handlers.onDownload?.(resource)} />
)}
{!touch && handlers.canShare(resource) && (
<RowAction icon={Share2} label={t("share")} onClick={() => handlers.onShare(resource)} />
+8 -1
View File
@@ -31,6 +31,7 @@ import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status";
import { apiFetch } from "@/lib/browser-navigation";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { AlertTriangle, Loader2 } from "@/components/icons";
import { isFilePreviewable } from "@/lib/file-preview";
import { isImageFile } from "@/components/files/file-icons";
@@ -685,6 +686,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [deleteNode, trashNode, confirmDialog, handleUndo, t, failureReason]);
// Files may hold what an attachment-only block would keep in: the device
// policy covers them too, the strictest of the accounts signed in here
// (the drive may be another account's).
const filesDownloadsOff = useStrictestDevices().noDownloads;
const handleDownloadNode = useCallback(async (id: string) => {
try {
await downloadNode(id);
@@ -892,6 +898,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
onRenameNode={handleRenameNode}
onDeleteNode={handleDeleteNode}
onDownloadNode={handleDownloadNode}
downloadsOff={filesDownloadsOff}
onRevealNode={handleRevealNode}
onRemoveRecent={removeRecentFile}
accountFolders={accountFolders}
@@ -924,7 +931,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
<ImagePreviewModal
name={previewImage}
onClose={() => setPreviewImage(null)}
onDownload={handleDownload}
onDownload={filesDownloadsOff ? undefined : handleDownload}
getImageUrl={getImageUrl}
file={previewedView}
neighbours={previewNeighbours}
+5 -2
View File
@@ -83,7 +83,8 @@ export interface FilesSidebarProps {
onDeleteFolder: (folder: FileResource) => void;
/** Open the folder holding a recent file, with the file selected. */
onRevealRecent: (id: string) => void;
onDownloadRecent: (id: string) => void;
/** Left out when a device policy turned downloads off. */
onDownloadRecent?: (id: string) => void;
onRemoveRecent: (id: string) => void;
/** Items in the trash; the entry shows their number. */
trashCount?: number;
@@ -520,7 +521,9 @@ export function FilesSidebar({
<>
<ContextMenuItem icon={Eye} label={t("open")} onClick={() => run(() => onOpenRecent(menuTarget.id))} />
<ContextMenuItem icon={FolderSearch} label={t("show_in_folder")} onClick={() => run(() => onRevealRecent(menuTarget.id))} />
<ContextMenuItem icon={Download} label={t("download")} onClick={() => run(() => onDownloadRecent(menuTarget.id))} />
{onDownloadRecent && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => run(() => onDownloadRecent(menuTarget.id))} />
)}
<ContextMenuSeparator />
<ContextMenuItem icon={X} label={t("remove_from_recent")} onClick={() => run(() => onRemoveRecent(menuTarget.id))} />
</>
+3 -2
View File
@@ -12,7 +12,8 @@ import { useCloseOnBack } from "@/hooks/use-close-on-back";
interface ImagePreviewModalProps {
name: string;
onClose: () => void;
onDownload: (name: string) => Promise<void>;
/** Left out when a device policy turned downloads off. */
onDownload?: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
/** The previewed file as plugins see it (file-preview-section). */
file?: FileResourceView | null;
@@ -56,7 +57,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
}, [name, getImageUrl]);
return (
<PreviewShell name={name} onClose={onClose} onDownload={() => void onDownload(name)} neighbours={neighbours}>
<PreviewShell name={name} onClose={onClose} onDownload={onDownload ? () => void onDownload(name) : undefined} neighbours={neighbours}>
<div className="relative flex-1 min-h-0">
{loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+2 -2
View File
@@ -33,7 +33,7 @@ const DOUBLE_TAP_SLOP = 30; // px
// used for the open-in-new-tab fallback. pdf.js must be fed the bytes, not the
// URL: fetching a blob: URL is governed by CSP connect-src, which doesn't (and
// shouldn't need to) allow blob:, so getDocument({ url }) is blocked (#871).
export function PdfMobileViewer({ url, blob }: { url: string; blob: Blob }) {
export function PdfMobileViewer({ url, blob, allowNewTab = true }: { url: string; blob: Blob; /** False under a device policy without downloads. */ allowNewTab?: boolean }) {
const rootRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement>(null);
const zoom = useRef({ step: 0, scale: 1 });
@@ -220,7 +220,7 @@ export function PdfMobileViewer({ url, blob }: { url: string; blob: Blob }) {
// An installed app cannot open the blob: URL in a new window (#865),
// so it only says so; the header's download button still works.
<div className="flex justify-center py-10">
{canOpenUrlInNewWindow(url) ? (
{allowNewTab && canOpenUrlInNewWindow(url) ? (
<Button
variant="outline"
size="sm"
+7 -4
View File
@@ -16,7 +16,8 @@ export interface PreviewNeighbours {
interface PreviewShellProps {
name: string;
onClose: () => void;
onDownload: () => void;
/** Left out: no download button (a device policy turned downloads off). */
onDownload?: () => void;
/** Extra header buttons before Download (e.g. "Open in new tab"). */
actions?: ReactNode;
neighbours?: PreviewNeighbours;
@@ -97,9 +98,11 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
</>
)}
{actions}
<IconButton label={t("download")} onClick={onDownload}>
<Download />
</IconButton>
{onDownload && (
<IconButton label={t("download")} onClick={onDownload}>
<Download />
</IconButton>
)}
<IconButton label={t("close_preview")} onClick={onClose}>
<X />
</IconButton>
+5 -1
View File
@@ -7,6 +7,7 @@ import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { apiFetch } from "@/lib/browser-navigation";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useStrictestDevices } from "@/hooks/use-device-policy";
/** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */
export type WopiDocument =
@@ -41,6 +42,9 @@ interface LaunchData {
* Mail attachments open in the same overlay as a read-only viewer (#1047).
*/
export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = false }: WopiEditorProps) {
// The editor turns print and export off itself (CheckFileInfo); without
// downloads the frame may not start one either.
const downloadsOff = useStrictestDevices().noDownloads;
const t = useTranslations("files");
// A full-screen modal: Tab stays in it, the focus starts on Close and goes
// back to the file when the editor closes. Escape is the listener below.
@@ -152,7 +156,7 @@ export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = fal
title={target.name}
className="w-full h-full border-0"
onLoad={() => setFrameLoaded(true)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
sandbox={`allow-scripts allow-same-origin allow-forms allow-popups${downloadsOff ? "" : " allow-downloads"} allow-modals`}
referrerPolicy="no-referrer"
allow="clipboard-read; clipboard-write"
/>
+12 -7
View File
@@ -30,6 +30,7 @@ import {
} from "@/components/icons";
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
interface Position {
x: number;
@@ -144,6 +145,8 @@ export function MailboxContextMenu({
const t = useTranslations("mailbox_context_menu");
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
const exportOff = useDevicePolicy().noExport;
const handleAction = (action: () => void) => {
action();
@@ -325,13 +328,15 @@ export function MailboxContextMenu({
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
/>
<ContextMenuItem
icon={Download}
label={tExport("menu_export")}
onClick={() => handleAction(() => openMailExport({ mailboxId: mailbox.id }))}
disabled={mailbox.totalEmails === 0 || mailbox.myRights?.mayReadItems === false}
testId="mailbox-export"
/>
{!exportOff && (
<ContextMenuItem
icon={Download}
label={tExport("menu_export")}
onClick={() => handleAction(() => openMailExport({ mailboxId: mailbox.id }))}
disabled={mailbox.totalEmails === 0 || mailbox.myRights?.mayReadItems === false}
testId="mailbox-export"
/>
)}
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
@@ -4,11 +4,14 @@ import { useTranslations } from "next-intl";
import { SettingItem, SettingsSection } from "@/components/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openMailExport, openMailImport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
/** Settings > Backup & reset > Mail files: bring mail in from files, and save all of it as one file. */
export function MailBackupSettings() {
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export: shown as set by the organisation.
const exportOff = useDevicePolicy().noExport;
return (
<SettingsSection id="mail-files" title={tImport("section_title")} description={tImport("section_description")}>
<SettingItem label={tImport("settings_label")} description={tImport("settings_description")}>
@@ -16,8 +19,8 @@ export function MailBackupSettings() {
{tImport("settings_button")}
</Button>
</SettingItem>
<SettingItem label={tExport("settings_label")} description={tExport("settings_description")}>
<Button variant="outline" size="sm" onClick={() => openMailExport()} data-testid="settings-mail-export">
<SettingItem label={tExport("settings_label")} description={tExport("settings_description")} locked={exportOff}>
<Button variant="outline" size="sm" onClick={() => openMailExport()} disabled={exportOff} data-testid="settings-mail-export">
{tExport("settings_button")}
</Button>
</SettingItem>
+6
View File
@@ -3,6 +3,7 @@ import type { ExportSink } from "@/lib/mail-export/sinks";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useAuthStore } from "@/stores/auth-store";
import { useMailTransferStore } from "@/stores/mail-transfer-store";
import { downloadAllowed } from "@/lib/download-guard";
export interface StartExportInput {
format: ExportFormat;
@@ -17,6 +18,11 @@ export interface StartExportInput {
export function startExport(input: StartExportInput): void {
const auth = useAuthStore.getState();
if (!auth.client) return;
// Backstop for the device policy "No export" (the dialog is not offered then).
if (!downloadAllowed("export", auth.activeAccountId ?? null)) {
void input.sink.abort().catch(() => {});
return;
}
const client: IJMAPClient = auth.client;
const controller = new AbortController();
let ended: () => void = () => {};
@@ -396,7 +396,7 @@ describe('standard mail UI blob routing', () => {
it('exports the raw shared message through its owner', async () => {
render(<MailApp />);
await exportEmail();
expect(primary.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.stringMatching(/\.eml$/), 'message/rfc822', 'nrichmond');
expect(primary.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.stringMatching(/\.eml$/), 'message/rfc822', 'nrichmond', { kind: 'export' });
});
it.each([false, true])('routes Unified selection (automatic=%s) after Email/get replaces the stub', async (automatic) => {
@@ -409,7 +409,7 @@ describe('standard mail UI blob routing', () => {
await downloadAttachment();
await exportEmail();
expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond');
expect(other.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.any(String), 'message/rfc822', 'nrichmond');
expect(other.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.any(String), 'message/rfc822', 'nrichmond', { kind: 'export' });
expect(primary.downloadBlob).not.toHaveBeenCalled();
expect(useEmailStore.getState().selectedEmail).toMatchObject({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond', accountLabel: 'Richmond' });
});
+15 -7
View File
@@ -139,6 +139,7 @@ import { useDocumentTitle } from "@/hooks/use-document-title";
import { mailTitleContext } from "@/lib/tab-title";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
@@ -304,7 +305,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
// linksOff: the attachment of a message reported as phishing.
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable<typeof client>; linksOff?: boolean } | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable<typeof client>; linksOff?: boolean; policyAccount?: string | null } | null>(null);
// Office attachments open read-only in the configured WOPI editor (#1047).
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
const wopiStatus = useWopiStatus(true);
@@ -3931,11 +3932,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!blobClient || !name) return;
try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) {
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient, linksOff: isPhishingMarked(email) });
// The row's own account decides (Organisation > Devices): without
// downloads, whatever can be previewed is, and nothing else opens.
const downloadsOff = !canDownload(clientAccountId ?? email);
if ((mailAttachmentAction === 'preview' || downloadsOff) && isFilePreviewable(name, attachment.type)) {
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient, linksOff: isPhishingMarked(email), policyAccount: clientAccountId ?? email.sourceClientAccountId ?? null });
return;
}
if (mailAttachmentAction === 'preview' && openOfficeAttachment(
if ((mailAttachmentAction === 'preview' || downloadsOff) && openOfficeAttachment(
blobClient, { blobId: attachment.blobId, name, type: attachment.type, size: attachment.size }, accountId, clientAccountId,
)) return;
await blobClient.downloadBlob(attachment.blobId, name, attachment.type, accountId);
@@ -3990,12 +3994,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
const { mailAttachmentAction } = useSettingsStore.getState();
// The message's own account decides (Organisation > Devices).
const downloadsOff = !canDownload(clientAccountId ?? { client: blobClient });
const previews = mailAttachmentAction === 'preview' || downloadsOff;
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type, accountId, client: blobClient, linksOff: isPhishingMarked(selectedEmail) });
if (!forceDownload && previews && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type, accountId, client: blobClient, linksOff: isPhishingMarked(selectedEmail), policyAccount: clientAccountId ?? null });
return;
}
if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment(
if (!forceDownload && previews && openOfficeAttachment(
blobClient, { blobId, name, type }, accountId, clientAccountId,
)) return;
@@ -5762,6 +5769,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onDownload={handlePreviewAttachmentDownload}
getFileContent={getPreviewAttachmentContent}
linksOff={previewAttachment.linksOff}
policyAccount={previewAttachment.policyAccount ?? null}
/>
)}
+4
View File
@@ -3,6 +3,7 @@
import { useEffect, useLayoutEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { listenForPrinting, usePrintStore } from "@/stores/print-store";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
export type PrintOrientation = "portrait" | "landscape";
@@ -30,6 +31,8 @@ interface PrintRootProps {
* reaches the paper. On screen it is never shown.
*/
export function PrintRoot({ orientation, title, children }: PrintRootProps) {
// Organisation > Devices > Watermark: on every printed page.
const watermark = useWatermarkText(undefined, { strictest: true });
useLayoutEffect(() => {
if (!title) return;
const previous = document.title;
@@ -40,6 +43,7 @@ export function PrintRoot({ orientation, title, children }: PrintRootProps) {
return createPortal(
<div data-print-root="" data-orientation={orientation} className="pr-root">
<style>{`@page { size: ${orientation}; margin: 12mm; }`}</style>
<Watermark text={watermark} fixed />
{children}
</div>,
document.body,
+31 -2
View File
@@ -30,6 +30,12 @@ import { confirmMailRemoval } from '@/lib/mail-removal-confirm';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
import { isFilePreviewable } from '@/lib/file-preview';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
const FilePreviewModal = dynamic(() => import('@/components/files/file-preview-modal').then((m) => m.FilePreviewModal), { ssr: false });
interface ProEmailTabBodyProps {
tabId: string;
@@ -418,15 +424,29 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
}
}, [client, email, moveToMailbox, onClose]);
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string, _forceDownload?: boolean, source?: EmailBlobContext) => {
// An attachment shown in the preview because the message's account turned
// downloads off (Organisation > Devices); the tab downloads otherwise.
const [preview, setPreview] = useState<{ blobId: string; name: string; type?: string; context: EmailBlobContext; policyAccount: string | null } | null>(null);
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => {
const context = source ?? blobSource;
if (!context.client) return;
const policyAccount = context.clientAccountId ?? localAccountId ?? email?.sourceClientAccountId ?? null;
if (!forceDownload && !canDownload(policyAccount ?? { client: context.client }) && isFilePreviewable(name, type)) {
setPreview({ blobId, name, type, context, policyAccount });
return;
}
try {
// Refused (and said so) by the device policy when downloads are off.
await context.client.downloadBlob(blobId, name, type, context.accountId);
} catch (err) {
console.error('Download failed:', err);
}
}, [blobSource]);
}, [blobSource, localAccountId, email?.sourceClientAccountId]);
const getPreviewContent = useCallback(async () => {
if (!preview?.context.client) throw new Error('No attachment selected');
const blob = await preview.context.client.fetchBlob(preview.blobId, preview.name, preview.type, preview.context.accountId);
return { blob, contentType: preview.type || blob.type };
}, [preview]);
const handleQuickReply = useCallback(async (body: string) => {
handleReply(body);
@@ -524,6 +544,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
{preview && (
<FilePreviewModal
name={preview.name}
onClose={() => setPreview(null)}
onDownload={() => void handleDownloadAttachment(preview.blobId, preview.name, preview.type, true, preview.context)}
getFileContent={getPreviewContent}
policyAccount={preview.policyAccount}
/>
)}
</div>
);
}
+17 -6
View File
@@ -8,6 +8,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
import { useUIStore } from "@/stores/ui-store";
import { useStrictestDevices } from "@/hooks/use-device-policy";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
@@ -55,8 +56,17 @@ export function PWAInstallPrompt() {
// Stays "visible" for the push prompt's sake while a composer is open, so
// that card doesn't take its place over the Send button either.
const composing = useUIStore((state) => state.composingCount > 0);
// Organisation > Devices > No install prompt, if any account signed in
// here has it: no card, and the browser's own install banner is held back
// too. The browser's menu can still install Bulwark.
const noInstallPrompt = useStrictestDevices().noInstallPrompt;
useEffect(() => {
if (noInstallPrompt) {
const suppress = (e: Event) => e.preventDefault();
window.addEventListener("beforeinstallprompt", suppress);
return () => window.removeEventListener("beforeinstallprompt", suppress);
}
if (isDismissed()) return;
const handler = (e: Event) => {
@@ -70,19 +80,20 @@ export function PWAInstallPrompt() {
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
}, [noInstallPrompt]);
const visible = showPrompt && !noInstallPrompt;
useEffect(() => {
promptVisible = showPrompt;
promptVisible = visible;
window.dispatchEvent(
new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, {
detail: { visible: showPrompt },
detail: { visible },
}),
);
return () => {
if (showPrompt) promptVisible = false;
if (visible) promptVisible = false;
};
}, [showPrompt]);
}, [visible]);
const handleInstall = async () => {
if (!deferredPrompt) return;
@@ -107,7 +118,7 @@ export function PWAInstallPrompt() {
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt || composing) {
if (!visible || !deferredPrompt || composing) {
return null;
}
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { useMemo } from 'react';
import { devicesForAccount, getStrictestDevices, type DevicePolicy, type DeviceSubject } from '@/lib/device-policy';
import { useAccountSubjects } from '@/lib/org/account-subjects';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
type HookSubject = Exclude<DeviceSubject, { client: object }>;
function subjectKey(subject: HookSubject): string {
if (!subject) return '';
return typeof subject === 'string' ? subject : subject.sourceClientAccountId ?? '';
}
/**
* The device policy of the account a message, attachment or file belongs
* to (the active account when left out). Follows the policy, the account's
* groups and the active account as they load or change.
*/
export function useDevicePolicy(subject?: HookSubject): DevicePolicy {
const key = subjectKey(subject);
const policy = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const policySubject = usePolicyStore((s) => s.subject);
const subjects = useAccountSubjects((s) => s.subjects);
const active = useAuthStore((s) => s.activeAccountId);
return useMemo(
() => devicesForAccount(key || null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, policy, rawPolicy, policySubject, subjects, active],
);
}
/**
* Every switch any signed-in account has on: for what isn't about one
* account (the install prompt, Ctrl+P, a list that mixes accounts).
*/
export function useStrictestDevices(): DevicePolicy {
const policy = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const policySubject = usePolicyStore((s) => s.subject);
const subjects = useAccountSubjects((s) => s.subjects);
const active = useAuthStore((s) => s.activeAccountId);
const accounts = useAccountStore((s) => s.accounts);
return useMemo(
() => getStrictestDevices(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[policy, rawPolicy, policySubject, subjects, active, accounts],
);
}
+7 -3
View File
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
bundleExportFilename,
@@ -214,7 +215,8 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
}, [email.id, email.blobId, email.sourceClientAccountId, email.sourceAccountId, client, filenameOptions]);
const prefetchSingle = useCallback((target: Email) => {
if (!dragOutEnabled || !target.blobId) return;
// Dragging a message out is an export: not under the account's "No export".
if (!dragOutEnabled || !target.blobId || !canExport(target)) return;
const route = routeEmail(target, filenameOptions);
if (!route.client) return;
const previous = singleRef.current;
@@ -241,6 +243,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const selected = state.selectedEmailKeys.has(emailKeyFor(email))
? resolveSelectedEmails(state)
: threadEmails || [email];
if (!selected.every((em) => canExport(em))) return;
if (selected.length > 1) {
if (selected.some((em) => em.blobId)) {
prefetchEmailBundle(selected.map((em) => routeEmail(em, filenameOptions)), bundleOptions);
@@ -284,8 +287,9 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
.join("\r\n")
);
// Drag-out to file explorer.
if (dragOutEnabled && client) {
// Drag-out to file explorer, unless a device policy turned export off
// for one of the messages' accounts. Moving to a folder still works.
if (dragOutEnabled && client && emailsToDrag.every((em) => canExport(em))) {
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
const route = routeEmail(emailsToDrag[0], filenameOptions);
const url = singleRef.current?.key === route.key ? singleRef.current.url : null;
+9 -2
View File
@@ -12,6 +12,8 @@ import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-file
import { runBatchEmailAction } from "@/lib/email-action-toast";
import type { Email } from "@/lib/jmap/types";
import type { CopyTargetAccount } from "@/components/email/email-context-menu";
import { downloadAllowed } from "@/lib/download-guard";
import { canPrint, type DeviceSubject } from "@/lib/device-policy";
/** What follows an export, by the "After export" setting. */
export interface ExportFollowUp {
@@ -30,6 +32,9 @@ export function useEmailExport(): (email: Email, followUp?: ExportFollowUp, sour
return useCallback(async (email, followUp = {}, source) => {
const context = source ?? resolveEmailBlobContext(email, useAuthStore.getState().client);
if (!email.blobId || !context.client) return;
// The device policy of the message's account (Organisation > Devices);
// refused, the message is not archived or deleted either.
if (!downloadAllowed("export", email)) return;
const s = useSettingsStore.getState();
const options = {
template: s.emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE,
@@ -39,7 +44,7 @@ export function useEmailExport(): (email: Email, followUp?: ExportFollowUp, sour
collapseSeparators: s.filenameCollapseSeparators,
};
try {
await context.client.downloadBlob(email.blobId, emailExportFilename(email, options), "message/rfc822", context.accountId);
await context.client.downloadBlob(email.blobId, emailExportFilename(email, options), "message/rfc822", context.accountId, { kind: "export" });
} catch {
toast.error(tNotifications("export_email_error"));
return;
@@ -73,7 +78,9 @@ export const usePrintRequestStore = create<{
}));
/** Opens the print window now (in the click) and asks the reader to fill it. */
export function requestPrint(key: string): boolean {
export function requestPrint(key: string, subject?: DeviceSubject): boolean {
// Not under the device policy "No printing".
if (!canPrint(subject)) return false;
const win = window.open("", "_blank");
if (!win) return false;
// See the reader's print: no opener for a document the sender shaped.
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
canDownload,
canExport,
canPrint,
devicesForAccount,
getStrictestDevices,
watermarkAddress,
watermarkText,
} from '@/lib/device-policy';
import { DOWNLOAD_BLOCKED_EVENT, downloadAllowed, saveBlobAs } from '@/lib/download-guard';
import { openPreviewInNewWindow } from '@/lib/standalone-display';
import { saveBlob } from '@/lib/files-zip';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { resetAccountSubjects, useAccountSubjects } from '@/lib/org/account-subjects';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
// The device policies (Organisation > Devices) follow the account a message
// belongs to; what isn't about one account follows the strictest signed-in
// account. The anchor and new-tab helpers refuse as a backstop.
const ALICE = { email: 'alice@example.org', groups: ['team@example.org'] };
const CAROL = { email: 'carol@example.org', groups: [] };
// Whatever works the subject out from a client gets the same answer.
const clientA = {
getUsername: () => ALICE.email,
getSharedAccounts: () => [{ id: 'g', name: 'team@example.org', isPrimary: false }],
getAccountInfo: () => ({ isPersonal: false }),
getPrincipalTypes: async () => ({ g: 'group' }),
};
const clientC = { getUsername: () => CAROL.email, getSharedAccounts: () => [] };
const RAW: SettingsPolicy = {
...DEFAULT_POLICY,
scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noDownloads: true, noPrint: true } } } }],
};
const initialAuth = useAuthStore.getState();
const initialPolicy = usePolicyStore.getState();
beforeEach(() => {
resetAccountSubjects();
useAuthStore.setState({
activeAccountId: 'acc-a',
client: clientA as never,
username: ALICE.email,
getClientForAccount: ((id: string) => (id === 'acc-a' ? clientA : id === 'acc-c' ? clientC : undefined)) as never,
getAllConnectedClients: (() => new Map<string, unknown>([['acc-a', clientA], ['acc-c', clientC]])) as never,
});
usePolicyStore.setState({ rawPolicy: RAW, loaded: true });
usePolicyStore.getState().setPolicySubject(ALICE);
useAccountSubjects.setState({ subjects: { 'acc-c': CAROL }, settled: { 'acc-c': true } });
});
afterEach(() => {
useAuthStore.setState(initialAuth, true);
usePolicyStore.setState(initialPolicy, true);
resetAccountSubjects();
});
describe('which account decides', () => {
it('a message follows its own account', () => {
expect(canDownload({ sourceClientAccountId: 'acc-a' })).toBe(false);
expect(canDownload({ sourceClientAccountId: 'acc-c' })).toBe(true);
expect(canPrint('acc-c')).toBe(true);
expect(canExport('acc-a')).toBe(true);
});
it('the active account when nothing names one', () => {
expect(canDownload(null)).toBe(false);
expect(devicesForAccount(undefined).noPrint).toBe(true);
});
it('a JMAP client stands for its account', () => {
expect(canDownload({ client: clientC })).toBe(true);
expect(canDownload({ client: clientA })).toBe(false);
});
it('the strictest signed-in account where no message decides', () => {
usePolicyStore.getState().setPolicySubject(CAROL);
useAuthStore.setState({ activeAccountId: 'acc-c', client: clientC as never });
useAccountSubjects.setState({ subjects: { 'acc-a': ALICE }, settled: { 'acc-a': true } });
expect(canDownload(null)).toBe(true);
expect(getStrictestDevices()).toMatchObject({ noDownloads: true, noPrint: true, noExport: false });
});
});
describe('the backstop', () => {
it('refuses a download, says so, and never builds the anchor', () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const blocked = vi.fn();
window.addEventListener(DOWNLOAD_BLOCKED_EVENT, blocked);
expect(saveBlobAs(new Blob(['x']), 'a.pdf', { kind: 'attachment', subject: 'acc-a' })).toBe(false);
expect(saveBlob(new Blob(['x']), 'b.zip')).toBe(false);
expect(click).not.toHaveBeenCalled();
expect(blocked).toHaveBeenCalledTimes(2);
expect(saveBlobAs(new Blob(['x']), 'a.pdf', { kind: 'attachment', subject: 'acc-c' })).toBe(true);
expect(click).toHaveBeenCalledTimes(1);
window.removeEventListener(DOWNLOAD_BLOCKED_EVENT, blocked);
click.mockRestore();
});
it('keeps exports and downloads apart', () => {
expect(downloadAllowed('export', 'acc-a')).toBe(true);
expect(downloadAllowed('attachment', 'acc-a')).toBe(false);
});
it('opens no new tab under the policy, so the caller previews in the app', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
expect(openPreviewInNewWindow('https://example.org/x.pdf', { sourceClientAccountId: 'acc-a' })).toBe(false);
expect(open).not.toHaveBeenCalled();
expect(openPreviewInNewWindow('https://example.org/x.pdf', { sourceClientAccountId: 'acc-c' })).toBe(true);
open.mockRestore();
});
});
describe('watermark', () => {
it('names the address of the message’s account and the date', () => {
expect(watermarkAddress({ sourceClientAccountId: 'acc-c' })).toBe(CAROL.email);
expect(watermarkAddress(null)).toBe(ALICE.email);
expect(watermarkText('alice@example.org', new Date('2026-11-01T12:00:00Z'), 'en-GB')).toBe('alice@example.org · 1 Nov 2026');
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Device policies (policy.json `organisation.devices`, enterprise pack item
* 13): admin switches that make copying mail out of Bulwark harder: no
* attachment downloads or opening in a new tab, no mail or folder export, no
* printing, no install prompt, push without sender and subject, and a
* watermark.
*
* Which account's policy applies (several accounts can be signed in, with
* different domain and group scopes):
* - for a message, attachment or file: the account it belongs to
* (devicesForAccount, with the email's `sourceClientAccountId` in the
* unified views);
* - where there is no such thing (the install prompt, Ctrl+P, a page that
* mixes accounts): the strictest of every account signed in here
* (getStrictestDevices).
*
* Everything here except the push text and the office editor (both checked
* on the server, app/api/push/preview and app/api/wopi) runs in the
* browser: someone who knows the browser's tools can get around it, and
* other mail apps or a screenshot always can. The admin page and the docs
* say so.
*
* Every user-facing path asks canDownload/canExport/canPrint to hide its
* button, and the anchor and new-tab helpers (lib/download-guard.ts) refuse
* as a backstop. Never fetchBlob or fetchBlobArrayBuffer: phishing reports,
* the export engine and previews read blobs too.
*/
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { setDownloadGuard } from '@/lib/download-guard';
import { subjectForAccount } from '@/lib/org/account-subjects';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
export type DevicePolicy = OrganisationPolicy['devices'];
export const NO_DEVICE_LIMITS: DevicePolicy = DEFAULT_ORGANISATION_POLICY.devices;
const KEYS = Object.keys(NO_DEVICE_LIMITS) as (keyof DevicePolicy)[];
function devicesOf(policy: { organisation?: OrganisationPolicy } | null | undefined): DevicePolicy {
return { ...NO_DEVICE_LIMITS, ...(policy?.organisation?.devices ?? {}) };
}
/**
* A local account id, something that knows which account it came from (an
* email in a unified view, `sourceClientAccountId`), or the JMAP client of
* an account.
*/
export type DeviceSubject = string | null | undefined | { sourceClientAccountId?: string | null } | { client: object };
function accountIdOf(subject: DeviceSubject): string | null {
if (!subject) return null;
if (typeof subject === 'string') return subject;
if ('client' in subject) {
const auth = useAuthStore.getState();
if (subject.client === auth.client) return auth.activeAccountId ?? null;
try {
for (const [id, client] of auth.getAllConnectedClients?.() ?? []) if (client === subject.client) return id;
} catch {
// A stand-in auth store in tests.
}
return null;
}
return subject.sourceClientAccountId ?? null;
}
/** The device policy of one signed-in account (the active one for null). */
export function devicesForAccount(subject: DeviceSubject): DevicePolicy {
const accountId = accountIdOf(subject);
const store = usePolicyStore.getState();
const accountSubject = subjectForAccount(accountId);
const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject);
return devicesOf(policy);
}
/** Local ids of every account signed in here, the active one included. */
export function signedInAccountIds(): string[] {
const auth = useAuthStore.getState();
const ids = new Set<string>();
try {
for (const id of auth.getAllConnectedClients?.().keys() ?? []) ids.add(id);
} catch {
// A stand-in auth store in tests.
}
if (auth.activeAccountId) ids.add(auth.activeAccountId);
return [...ids];
}
/** Every switch that any signed-in account has on: for pages that aren't about one account. */
export function getStrictestDevices(): DevicePolicy {
const out: DevicePolicy = { ...devicesForAccount(null) };
for (const id of signedInAccountIds()) {
const devices = devicesForAccount(id);
for (const key of KEYS) if (devices[key]) out[key] = true;
}
return out;
}
/** Attachments and files may be downloaded or opened in a new tab. */
export function canDownload(subject?: DeviceSubject): boolean {
return !devicesForAccount(subject).noDownloads;
}
/** Messages and folders may be exported (EML, mbox, ZIP, View source, drag-out). */
export function canExport(subject?: DeviceSubject): boolean {
return !devicesForAccount(subject).noExport;
}
/** Messages and pages may be printed. */
export function canPrint(subject?: DeviceSubject): boolean {
return !devicesForAccount(subject).noPrint;
}
/** For pages without one account: nothing may be downloaded if any signed-in account forbids it. */
export function canDownloadAnywhere(): boolean {
return !getStrictestDevices().noDownloads;
}
export function canPrintAnywhere(): boolean {
return !getStrictestDevices().noPrint;
}
export function canExportAnywhere(): boolean {
return !getStrictestDevices().noExport;
}
// The backstop in the anchor and new-tab helpers asks this. Without an
// account (undefined) it takes the strictest of the signed-in accounts; null
// is the active account.
setDownloadGuard((kind, subject) => {
const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject);
return kind === 'export' ? !devices.noExport : !devices.noDownloads;
});
/** The signed-in address of the account a message belongs to, for the watermark. */
export function watermarkAddress(subject: DeviceSubject): string {
const accountId = accountIdOf(subject);
const auth = useAuthStore.getState();
if (accountId && accountId !== auth.activeAccountId) {
const email = subjectForAccount(accountId)?.email;
if (email) return email;
}
return auth.username ?? usePolicyStore.getState().subject?.email ?? '';
}
/** The text of the watermark: the user's address and today's date. */
export function watermarkText(address: string, now: Date = new Date(), locale?: string): string {
let date: string;
try {
date = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(now);
} catch {
date = now.toISOString().slice(0, 10);
}
return [address.trim(), date].filter(Boolean).join(' · ');
}
+80
View File
@@ -0,0 +1,80 @@
/**
* The backstop of the device policies (lib/device-policy.ts): every helper
* that hands a file to the user, `<a download>` or a new tab, asks here
* first. The buttons are already hidden when a policy is on; this catches a
* path that forgot, a keyboard shortcut, a plugin, or a policy that changed
* while a dialog was open.
*
* Kept free of store imports, so lib/jmap/client.ts and other low-level
* modules can use it without an import cycle: lib/device-policy.ts installs
* the check when it loads. Reading blobs (fetchBlob, fetchBlobArrayBuffer)
* is never refused here, only handing them out.
*/
/** What is being handed out: an attachment or file (noDownloads), or mail (noExport). */
export type DownloadKind = 'attachment' | 'file' | 'export';
/**
* The account the thing belongs to: a local account id, an email with
* `sourceClientAccountId`, or the JMAP client of the account; null is the
* active account. Left out (undefined): every signed-in account must allow
* it.
*/
export type DownloadSubject = string | null | undefined | { sourceClientAccountId?: string | null } | { client: object };
type Guard = (kind: DownloadKind, subject: DownloadSubject) => boolean;
let guard: Guard | null = null;
/** Installed by lib/device-policy.ts. */
export function setDownloadGuard(next: Guard | null): void {
guard = next;
}
/** The event the app listens for to say why nothing happened (components/device-policy-host.tsx). */
export const DOWNLOAD_BLOCKED_EVENT = 'bulwark:download-blocked';
/** Whether handing this out is allowed, without telling the user (the caller has another way, such as an in-app preview). */
export function downloadAllowedQuietly(kind: DownloadKind, subject?: DownloadSubject): boolean {
try {
return guard ? guard(kind, subject) : true;
} catch {
return false;
}
}
/** Whether handing this out is allowed; says so in the app when it isn't. */
export function downloadAllowed(kind: DownloadKind, subject?: DownloadSubject): boolean {
const allowed = downloadAllowedQuietly(kind, subject);
if (!allowed && typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(DOWNLOAD_BLOCKED_EVENT, { detail: { kind } }));
}
return allowed;
}
/**
* Saves `blob` (or an object URL) as `filename` through an `<a download>`.
* False, and nothing saved, when a device policy forbids it.
*/
export function saveBlobAs(
data: Blob | string,
filename: string,
options: { kind: DownloadKind; subject?: DownloadSubject; revokeAfterMs?: number },
): boolean {
if (!downloadAllowed(options.kind, options.subject)) return false;
if (typeof document === 'undefined') return false;
const url = typeof data === 'string' ? data : URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.rel = 'noopener';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// 0 revokes at once (the click has started the download by then).
if (typeof data !== 'string') {
if (options.revokeAfterMs === 0) URL.revokeObjectURL(url);
else setTimeout(() => URL.revokeObjectURL(url), options.revokeAfterMs ?? 60_000);
}
return true;
}
+4 -10
View File
@@ -1,5 +1,6 @@
import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
import { saveBlobAs, type DownloadKind, type DownloadSubject } from '@/lib/download-guard';
/*
* Downloading folders: the server has no archive download, so the browser
@@ -113,14 +114,7 @@ export async function buildZip(
return blob;
}
/** Hand a blob to the browser as a download. */
export function saveBlob(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
/** Hand a blob to the browser as a download, unless a device policy forbids it (false then). */
export function saveBlob(blob: Blob, name: string, options: { kind?: DownloadKind; subject?: DownloadSubject } = {}): boolean {
return saveBlobAs(blob, name, { kind: options.kind ?? 'file', subject: options.subject });
}
+3 -1
View File
@@ -1,5 +1,6 @@
import type { ShareChange, ShareChangeResult, ShareTarget } from "./share-batch";
import type { ShareKind } from "./share-rights";
import type { DownloadKind } from "@/lib/download-guard";
import type { ArchiveListResult, ArchiveQueryPage, RestoreResult } from "@/lib/recovery/archived-items";
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
@@ -563,7 +564,8 @@ export interface IJMAPClient {
* every other request.
*/
fetchBlobForTransfer?(blobId: string, accountId: string | undefined, signal?: AbortSignal): Promise<Uint8Array>;
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
/** Saves a blob as a download, unless the account's device policy forbids `kind` (default `attachment`). */
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string, options?: { kind?: DownloadKind }): Promise<void>;
// ── Identities ────────────────────────────────────────────────
getIdentities(): Promise<Identity[]>;
+6 -10
View File
@@ -1,5 +1,6 @@
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard";
import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
@@ -10479,17 +10480,12 @@ export class JMAPClient implements IJMAPClient {
return kept;
}
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string, options?: { kind?: DownloadKind }): Promise<void> {
// The device policy of this client's account (lib/device-policy.ts) may forbid it.
const kind = options?.kind ?? 'attachment';
if (!downloadAllowed(kind, { client: this })) return;
const blob = await this.fetchBlob(blobId, name, type, accountId);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = name || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
saveBlobAs(blob, name || 'download', { kind, subject: { client: this }, revokeAfterMs: 0 });
}
private pollingInterval: NodeJS.Timeout | null = null;
+9 -8
View File
@@ -1,4 +1,6 @@
import { hasWebLocks, holdLock } from "@/lib/mail-import/locks";
import { saveBlobAs } from "@/lib/download-guard";
import { useMailTransferStore } from "@/stores/mail-transfer-store";
/**
* Where an export's bytes go, so a large mailbox never has to fit in memory:
@@ -83,15 +85,14 @@ function buffered(write: (chunk: Uint8Array) => Promise<void>) {
};
}
/**
* Hands the finished file to the browser, unless the device policy "No
* export" of the account the export runs for is on (also when it was
* switched on while the export ran).
*/
function triggerDownload(url: string, name: string): void {
const a = document.createElement("a");
a.href = url;
a.download = name;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
const scope = useMailTransferStore.getState().job?.scope;
saveBlobAs(url, name, { kind: "export", subject: scope && scope !== "default" ? scope : null });
}
type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record<string, string[]> }[] }) => Promise<{ createWritable(): Promise<WritableLike> }>;
+9 -3
View File
@@ -11,6 +11,8 @@
import type { InstalledPlugin } from '../plugin-types';
import { emailIframeCsp, escapeHtml, sanitizeEmailHtmlForIframe } from '../email-sanitization';
import { readObject, readString, withinRate } from './host-api-input';
import { canPrintAnywhere, getStrictestDevices, watermarkAddress, watermarkText } from '../device-policy';
import { watermarkForPrint } from '../watermark';
const MAX_TITLE = 200;
const MAX_HTML = 2 * 1024 * 1024;
@@ -20,7 +22,8 @@ export const MAX_PRINTS_PER_MINUTE = 3;
const CLEANUP_MS = 60_000;
/** The document the frame prints. Exported for tests. */
export function printDocument(title: string, html: string, dir: string): string {
export function printDocument(title: string, html: string, dir: string, watermark?: string | null): string {
const mark = watermark ? watermarkForPrint(watermark) : null;
return `<!DOCTYPE html>
<html dir="${dir === 'rtl' ? 'rtl' : 'ltr'}"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(true)}">
@@ -30,7 +33,7 @@ export function printDocument(title: string, html: string, dir: string): string
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #000; background: #fff; margin: 0; }
img { max-width: 100%; height: auto; }
@page { margin: 15mm; }
</style></head><body>${sanitizeEmailHtmlForIframe(html)}</body></html>`;
</style>${mark ? mark.style : ''}</head><body>${mark ? mark.html : ''}${sanitizeEmailHtmlForIframe(html)}</body></html>`;
}
/**
@@ -44,6 +47,8 @@ export async function doUiPrint(plugin: InstalledPlugin, input: unknown): Promis
// eslint-disable-next-line no-control-regex -- a title is one line
if (/[\u0000-\u001f\u007f]/.test(title)) throw new Error(`${method}: title must be one line`);
const html = readString(page.html, method, 'html', MAX_HTML);
// Organisation > Devices > No printing, in any signed-in account.
if (!canPrintAnywhere()) throw new Error(`${method}: printing is turned off by the organisation`);
if (!withinRate(method, plugin.id, MAX_PRINTS_PER_MINUTE)) {
throw new Error(`${method}: at most ${MAX_PRINTS_PER_MINUTE} times a minute`);
}
@@ -57,7 +62,8 @@ export async function doUiPrint(plugin: InstalledPlugin, input: unknown): Promis
// Off screen but laid out: some browsers print an empty page from a
// frame with display: none.
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;visibility:hidden;';
frame.srcdoc = printDocument(title, html, document.documentElement.dir);
const watermark = getStrictestDevices().watermark ? watermarkText(watermarkAddress(null)) : null;
frame.srcdoc = printDocument(title, html, document.documentElement.dir, watermark);
await new Promise<void>((resolve, reject) => {
frame.addEventListener('load', () => resolve(), { once: true });
+10 -10
View File
@@ -6,6 +6,8 @@ import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
import { labelRawSend } from '@/lib/outgoing/classification-raw';
import { fetchConfig } from '@/hooks/use-config';
import { IS_LITE } from '../lite';
import { downloadAllowed, saveBlobAs } from '../download-guard';
import '../device-policy';
import { doAppGetSettings, doAppSearchSettings } from './host-api-app';
import { mayOfferSlot, pluginHasPermission } from './permissions';
import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client';
@@ -1314,16 +1316,14 @@ async function downloadFile(args: { content: string | ArrayBuffer | Uint8Array;
if (typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array)) {
throw new Error('ui.downloadFile: content must be a string or bytes');
}
const url = URL.createObjectURL(new Blob([content as BlobPart], { type: contentType }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoking at once can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
// A plugin that can read attachments or raw mail could hand them out
// here: refused while any signed-in account's device policy turns
// downloads or export off (Organisation > Devices).
if (!downloadAllowed('attachment') || !downloadAllowed('export')) {
throw new Error('ui.downloadFile: downloads are turned off by the organisation');
}
// Revoking at once can cancel the download in some browsers (saveBlobAs waits a minute).
saveBlobAs(new Blob([content as BlobPart], { type: contentType }), filename, { kind: 'attachment' });
}
// ─── Email keyword mutations ──────────────────────────────────
+6 -1
View File
@@ -8,6 +8,8 @@
* instead.
*/
import { downloadAllowedQuietly, type DownloadSubject } from "@/lib/download-guard";
type StandaloneNavigator = Navigator & { standalone?: boolean };
const APP_DISPLAY_MODES = ["standalone", "fullscreen", "window-controls-overlay"] as const;
@@ -32,8 +34,11 @@ export function canOpenUrlInNewWindow(url: string): boolean {
* false without opening anything when it cannot, so the caller shows the
* file in-app (or downloads it) instead of leaving the user a dead window.
*/
export function openPreviewInNewWindow(url: string): boolean {
export function openPreviewInNewWindow(url: string, subject?: DownloadSubject): boolean {
if (!canOpenUrlInNewWindow(url)) return false;
// A file in a tab of its own can be saved from there: the same device
// policy as a download. Refused, the caller previews it in the app.
if (!downloadAllowedQuietly('attachment', subject)) return false;
window.open(url, "_blank", "noopener,noreferrer");
return true;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* The watermark of the device policy `watermark` (lib/device-policy.ts): the
* user's address and the date, repeated across the reader and printouts.
* Real text elements, not a background image, so printers show it even
* when "print backgrounds" is off. A fixed layer repeats on every printed
* page.
*
* It can be removed with the browser's tools; the docs say so.
*/
import { escapeHtml } from '@/lib/email-sanitization';
/** How many copies of the text the layer holds (rows x columns). */
export const WATERMARK_ROWS = 8;
export const WATERMARK_COLUMNS = 3;
/** Styles for the layer and its copies, shared by the React overlay and the print documents. */
export const WATERMARK_CSS = `
.bulwark-watermark { position: absolute; inset: 0; overflow: hidden; pointer-events: none; user-select: none; z-index: 6;
display: grid; grid-template-columns: repeat(${WATERMARK_COLUMNS}, 1fr); align-content: space-around; justify-items: center; }
.bulwark-watermark[data-fixed] { position: fixed; z-index: 2147483000; }
.bulwark-watermark span { transform: rotate(-30deg); white-space: nowrap; font: 600 14px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: rgba(120, 120, 120, 0.22); padding: 40px 8px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@media print { .bulwark-watermark span { color: rgba(90, 90, 90, 0.28); } }
`;
/** The layer as HTML for a print document (escaped). `fixed` repeats it on every page. */
export function watermarkHtml(text: string, options: { fixed?: boolean } = {}): string {
const copy = `<span>${escapeHtml(text)}</span>`;
return `<div class="bulwark-watermark" aria-hidden="true" data-testid="watermark"${options.fixed ? ' data-fixed=""' : ''}>${copy.repeat(WATERMARK_ROWS * WATERMARK_COLUMNS)}</div>`;
}
/** The styles and layer for the head and body of a print document. */
export function watermarkForPrint(text: string): { style: string; html: string } {
return { style: `<style>${WATERMARK_CSS}</style>`, html: watermarkHtml(text, { fixed: true }) };
}
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "تُحذف الرسائل هنا بعد {count, plural, one {يوم واحد} two {يومين} few {# أيام} many {# يومًا} other {# يوم}} من نقلها إلى هنا."
},
"device_policy": {
"downloads_off": "أوقفت مؤسستك التنزيلات.",
"download_off_named": "{name}: أوقفت مؤسستك التنزيلات",
"download_blocked": "أوقفت مؤسستك التنزيلات.",
"export_blocked": "أوقفت مؤسستك تصدير البريد.",
"print_blocked": "أوقفت مؤسستك الطباعة."
},
"third_party": {
"push_needs_relay": "تحتاج الإشعارات الفورية إلى خادم ترحيل تديره مؤسستك، ولم يُعدّ أي خادم. يستمر ظهور البريد الجديد ما دام التطبيق مفتوحًا.",
"relay_desc": "لا تُعرض إلا خوادم الترحيل الخاصة بمؤسستك، لأن هذا الخادم لا يتصل بخدمات خارجية.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Els missatges d'aquí s'eliminen {count, plural, one {# dia} other {# dies}} després de moure'ls aquí."
},
"device_policy": {
"downloads_off": "La vostra organització ha desactivat les baixades.",
"download_off_named": "{name}: la vostra organització ha desactivat les baixades",
"download_blocked": "La vostra organització ha desactivat les baixades.",
"export_blocked": "La vostra organització ha desactivat l'exportació del correu.",
"print_blocked": "La vostra organització ha desactivat la impressió."
},
"third_party": {
"push_needs_relay": "Les notificacions push necessiten un repetidor push gestionat per la vostra organització, i no n'hi ha cap de configurat. El correu nou es continua mostrant mentre l'aplicació està oberta.",
"relay_desc": "Només s'ofereixen els repetidors propis de la vostra organització, perquè aquest servidor no contacta amb serveis externs.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Zprávy zde se smažou {count, plural, one {# den} few {# dny} many {# dne} other {# dní}} poté, co sem byly přesunuty."
},
"device_policy": {
"downloads_off": "Stahování vypnula vaše organizace.",
"download_off_named": "{name}: stahování vypnula vaše organizace",
"download_blocked": "Vaše organizace vypnula stahování.",
"export_blocked": "Vaše organizace vypnula export pošty.",
"print_blocked": "Tisk vypnula vaše organizace."
},
"third_party": {
"push_needs_relay": "Push oznámení potřebují push relay provozovaný vaší organizací a žádný není nastaven. Nová pošta se dál zobrazuje, dokud je aplikace otevřená.",
"relay_desc": "Nabízejí se jen vlastní relaye vaší organizace, protože tento server nekontaktuje externí služby.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Beskeder her slettes {count, plural, one {# dag} other {# dage}} efter, at de blev flyttet hertil."
},
"device_policy": {
"downloads_off": "Din organisation har slået download fra.",
"download_off_named": "{name}: din organisation har slået download fra",
"download_blocked": "Din organisation har slået download fra.",
"export_blocked": "Din organisation har slået eksport af mail fra.",
"print_blocked": "Din organisation har slået udskrivning fra."
},
"third_party": {
"push_needs_relay": "Push-notifikationer kræver et push-relæ, som din organisation driver, og der er ikke sat noget op. Ny mail vises stadig, mens appen er åben.",
"relay_desc": "Kun din organisations egne relæer tilbydes, fordi denne server ikke kontakter eksterne tjenester.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Nachrichten hier werden {count, plural, one {# Tag} other {# Tage}} nach dem Verschieben hierher gelöscht."
},
"device_policy": {
"downloads_off": "Downloads sind von Ihrer Organisation abgeschaltet.",
"download_off_named": "{name}: Downloads sind von Ihrer Organisation abgeschaltet",
"download_blocked": "Ihre Organisation hat Downloads abgeschaltet.",
"export_blocked": "Ihre Organisation hat den Export von E-Mails abgeschaltet.",
"print_blocked": "Drucken ist von Ihrer Organisation abgeschaltet."
},
"third_party": {
"push_needs_relay": "Push-Benachrichtigungen brauchen ein Push-Relay Ihrer Organisation, und es ist keines eingerichtet. Neue E-Mails werden weiterhin angezeigt, solange die App geöffnet ist.",
"relay_desc": "Es werden nur die eigenen Relays Ihrer Organisation angeboten, weil dieser Server keine externen Dienste kontaktiert.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Messages here are deleted {count, plural, one {# day} other {# days}} after they were moved here."
},
"device_policy": {
"downloads_off": "Downloads are turned off by your organisation.",
"download_off_named": "{name}: downloads are turned off by your organisation",
"download_blocked": "Your organisation turned off downloads.",
"export_blocked": "Your organisation turned off exporting mail.",
"print_blocked": "Printing is turned off by your organisation."
},
"third_party": {
"push_needs_relay": "Push notifications need a push relay run by your organisation, and none is set up. New mail still shows while the app is open.",
"relay_desc": "Only your organisation's own relays are offered, because this server doesn't contact outside services.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Los mensajes de aquí se eliminan {count, plural, one {# día} other {# días}} después de moverlos aquí."
},
"device_policy": {
"downloads_off": "Su organización ha desactivado las descargas.",
"download_off_named": "{name}: su organización ha desactivado las descargas",
"download_blocked": "Su organización ha desactivado las descargas.",
"export_blocked": "Su organización ha desactivado la exportación del correo.",
"print_blocked": "Su organización ha desactivado la impresión."
},
"third_party": {
"push_needs_relay": "Las notificaciones push necesitan un relay push gestionado por tu organización, y no hay ninguno configurado. El correo nuevo se sigue mostrando mientras la aplicación está abierta.",
"relay_desc": "Solo se ofrecen los relays propios de tu organización, porque este servidor no contacta con servicios externos.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Siin olevad kirjad kustutatakse {count, plural, one {# päev} other {# päeva}} pärast siia teisaldamist."
},
"device_policy": {
"downloads_off": "Sinu organisatsioon on allalaadimise välja lülitanud.",
"download_off_named": "{name}: sinu organisatsioon on allalaadimise välja lülitanud",
"download_blocked": "Sinu organisatsioon lülitas allalaadimise välja.",
"export_blocked": "Sinu organisatsioon lülitas kirjade eksportimise välja.",
"print_blocked": "Sinu organisatsioon on printimise välja lülitanud."
},
"third_party": {
"push_needs_relay": "Tõuketeavitused vajavad sinu organisatsiooni hallatavat push-releed, kuid ühtegi pole seadistatud. Uued kirjad kuvatakse endiselt, kuni rakendus on avatud.",
"relay_desc": "Pakutakse ainult sinu organisatsiooni enda releesid, sest see server ei võta ühendust väliste teenustega.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "پیام‌های اینجا {count, plural, one {# روز} other {# روز}} پس از انتقال به اینجا حذف می‌شوند."
},
"device_policy": {
"downloads_off": "سازمان شما بارگیری را خاموش کرده است.",
"download_off_named": "{name}: سازمان شما بارگیری را خاموش کرده است",
"download_blocked": "سازمان شما بارگیری را خاموش کرده است.",
"export_blocked": "سازمان شما خروجی گرفتن از ایمیل را خاموش کرده است.",
"print_blocked": "سازمان شما چاپ را خاموش کرده است."
},
"third_party": {
"push_needs_relay": "اعلان‌های push به یک رلهٔ push نیاز دارند که سازمان شما اداره کند، و هیچ‌کدام راه‌اندازی نشده است. تا وقتی برنامه باز است، ایمیل‌های جدید همچنان نمایش داده می‌شوند.",
"relay_desc": "فقط رله‌های خود سازمان شما پیشنهاد می‌شوند، چون این سرور با سرویس‌های بیرونی تماس نمی‌گیرد.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Les messages ici sont supprimés {count, plural, one {# jour} other {# jours}} après y avoir été déplacés."
},
"device_policy": {
"downloads_off": "Votre organisation a désactivé les téléchargements.",
"download_off_named": "{name} : votre organisation a désactivé les téléchargements",
"download_blocked": "Votre organisation a désactivé les téléchargements.",
"export_blocked": "Votre organisation a désactivé l'export des e-mails.",
"print_blocked": "Votre organisation a désactivé l'impression."
},
"third_party": {
"push_needs_relay": "Les notifications push nécessitent un relais push géré par votre organisation, et aucun n'est configuré. Les nouveaux messages s'affichent toujours tant que l'application est ouverte.",
"relay_desc": "Seuls les relais de votre organisation sont proposés, car ce serveur ne contacte pas de services extérieurs.",
+7
View File
@@ -1290,6 +1290,13 @@
"retention_notice": {
"line": "ההודעות כאן נמחקות {count, plural, one {יום אחד} two {יומיים} other {# ימים}} אחרי שהועברו לכאן."
},
"device_policy": {
"downloads_off": "הארגון שלכם כיבה הורדות.",
"download_off_named": "{name}: הארגון שלכם כיבה הורדות",
"download_blocked": "הארגון שלכם כיבה הורדות.",
"export_blocked": "הארגון שלכם כיבה ייצוא של דואר.",
"print_blocked": "הארגון שלכם כיבה הדפסה."
},
"third_party": {
"push_needs_relay": "התראות Push דורשות ממסר Push שהארגון שלך מפעיל, ולא הוגדר אף אחד. דואר חדש עדיין מוצג כל עוד האפליקציה פתוחה.",
"relay_desc": "מוצעים רק הממסרים של הארגון שלך, כי השרת הזה לא פונה לשירותים חיצוניים.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Az itt lévő üzenetek az ide helyezésük után {count, plural, one {# nappal} other {# nappal}} törlődnek."
},
"device_policy": {
"downloads_off": "A szervezete kikapcsolta a letöltést.",
"download_off_named": "{name}: a szervezete kikapcsolta a letöltést",
"download_blocked": "A szervezete kikapcsolta a letöltést.",
"export_blocked": "A szervezete kikapcsolta a levelek exportálását.",
"print_blocked": "A szervezete kikapcsolta a nyomtatást."
},
"third_party": {
"push_needs_relay": "A push-értesítésekhez a szervezeted által üzemeltetett push relé kell, és nincs beállítva egy sem. Az új levelek továbbra is megjelennek, amíg az alkalmazás nyitva van.",
"relay_desc": "Csak a szervezeted saját reléi választhatók, mert ez a szerver nem kapcsolódik külső szolgáltatásokhoz.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "I messaggi qui vengono eliminati {count, plural, one {# giorno} other {# giorni}} dopo essere stati spostati qui."
},
"device_policy": {
"downloads_off": "La tua organizzazione ha disattivato i download.",
"download_off_named": "{name}: la tua organizzazione ha disattivato i download",
"download_blocked": "La tua organizzazione ha disattivato i download.",
"export_blocked": "La tua organizzazione ha disattivato l'esportazione della posta.",
"print_blocked": "La tua organizzazione ha disattivato la stampa."
},
"third_party": {
"push_needs_relay": "Le notifiche push richiedono un relay push gestito dalla tua organizzazione, e non ne è configurato nessuno. La nuova posta compare comunque finché l'app è aperta.",
"relay_desc": "Vengono proposti solo i relay della tua organizzazione, perché questo server non contatta servizi esterni.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "ここにあるメッセージは、移動されてから{count, plural, other {#日}}後に削除されます。"
},
"device_policy": {
"downloads_off": "組織によりダウンロードは無効になっています。",
"download_off_named": "{name}: 組織によりダウンロードは無効になっています",
"download_blocked": "組織がダウンロードを無効にしています。",
"export_blocked": "組織がメールのエクスポートを無効にしています。",
"print_blocked": "組織により印刷は無効になっています。"
},
"third_party": {
"push_needs_relay": "プッシュ通知には組織が運用するプッシュリレーが必要ですが、設定されていません。アプリを開いている間は、新着メールが引き続き表示されます。",
"relay_desc": "このサーバーは外部サービスに接続しないため、組織独自のリレーのみが表示されます。",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "여기 있는 메일은 이곳으로 옮겨진 지 {count, plural, other {#일}} 후에 삭제됩니다."
},
"device_policy": {
"downloads_off": "조직에서 다운로드를 사용하지 않도록 설정했습니다.",
"download_off_named": "{name}: 조직에서 다운로드를 사용하지 않도록 설정했습니다",
"download_blocked": "조직에서 다운로드를 사용하지 않도록 설정했습니다.",
"export_blocked": "조직에서 메일 내보내기를 사용하지 않도록 설정했습니다.",
"print_blocked": "조직에서 인쇄를 사용하지 않도록 설정했습니다."
},
"third_party": {
"push_needs_relay": "푸시 알림을 받으려면 조직에서 운영하는 푸시 릴레이가 필요한데, 설정된 릴레이가 없어요. 앱이 열려 있는 동안에는 새 메일이 계속 표시돼요.",
"relay_desc": "이 서버는 외부 서비스에 연결하지 않으므로 조직 자체 릴레이만 표시돼요.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Šeit esošie ziņojumi tiek dzēsti {count, plural, zero {# dienas} one {# dienu} other {# dienas}} pēc to pārvietošanas šeit."
},
"device_policy": {
"downloads_off": "Jūsu organizācija ir izslēgusi lejupielādes.",
"download_off_named": "{name}: jūsu organizācija ir izslēgusi lejupielādes",
"download_blocked": "Jūsu organizācija izslēdza lejupielādes.",
"export_blocked": "Jūsu organizācija izslēdza pasta eksportēšanu.",
"print_blocked": "Jūsu organizācija ir izslēgusi drukāšanu."
},
"third_party": {
"push_needs_relay": "Push paziņojumiem vajadzīgs jūsu organizācijas uzturēts push relejs, un neviens nav iestatīts. Jauns pasts joprojām tiek rādīts, kamēr lietotne ir atvērta.",
"relay_desc": "Tiek piedāvāti tikai jūsu organizācijas pašas releji, jo šis serveris nesazinās ar ārējiem pakalpojumiem.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Энд байгаа захиа энд шилжүүлснээс хойш {count, plural, other {# хоногийн}} дараа устна."
},
"device_policy": {
"downloads_off": "Танай байгууллага татаж авахыг унтраасан.",
"download_off_named": "{name}: танай байгууллага татаж авахыг унтраасан",
"download_blocked": "Танай байгууллага татаж авахыг унтраасан.",
"export_blocked": "Танай байгууллага захиа экспортлохыг унтраасан.",
"print_blocked": "Танай байгууллага хэвлэхийг унтраасан."
},
"third_party": {
"push_needs_relay": "Push мэдэгдэлд таны байгууллагын ажиллуулдаг push буухиа хэрэгтэй, гэвч нэг ч тохируулагдаагүй байна. Апп нээлттэй байх үед шинэ имэйл харагдсаар байна.",
"relay_desc": "Энэ сервер гадны үйлчилгээтэй холбогддоггүй тул зөвхөн таны байгууллагын өөрийн буухиануудыг санал болгож байна.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Meldinger her slettes {count, plural, one {# dag} other {# dager}} etter at de ble flyttet hit."
},
"device_policy": {
"downloads_off": "Organisasjonen din har slått av nedlasting.",
"download_off_named": "{name}: organisasjonen din har slått av nedlasting",
"download_blocked": "Organisasjonen din har slått av nedlasting.",
"export_blocked": "Organisasjonen din har slått av eksport av e-post.",
"print_blocked": "Organisasjonen din har slått av utskrift."
},
"third_party": {
"push_needs_relay": "Push-varsler trenger et push-relé som organisasjonen din driver, og det er ikke satt opp noe. Ny e-post vises fortsatt mens appen er åpen.",
"relay_desc": "Bare organisasjonens egne reléer tilbys, fordi denne serveren ikke kontakter eksterne tjenester.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Berichten hier worden {count, plural, one {# dag} other {# dagen}} nadat ze hierheen zijn verplaatst verwijderd."
},
"device_policy": {
"downloads_off": "Uw organisatie heeft downloaden uitgezet.",
"download_off_named": "{name}: uw organisatie heeft downloaden uitgezet",
"download_blocked": "Uw organisatie heeft downloaden uitgezet.",
"export_blocked": "Uw organisatie heeft het exporteren van e-mail uitgezet.",
"print_blocked": "Uw organisatie heeft afdrukken uitgezet."
},
"third_party": {
"push_needs_relay": "Pushmeldingen hebben een pushrelay van je organisatie nodig, en er is er geen ingesteld. Nieuwe e-mail wordt nog steeds getoond zolang de app open is.",
"relay_desc": "Alleen de eigen relays van je organisatie worden aangeboden, omdat deze server geen externe diensten benadert.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Wiadomości są tu usuwane {count, plural, one {# dzień} few {# dni} many {# dni} other {# dnia}} po przeniesieniu."
},
"device_policy": {
"downloads_off": "Twoja organizacja wyłączyła pobieranie.",
"download_off_named": "{name}: Twoja organizacja wyłączyła pobieranie",
"download_blocked": "Twoja organizacja wyłączyła pobieranie.",
"export_blocked": "Twoja organizacja wyłączyła eksport poczty.",
"print_blocked": "Twoja organizacja wyłączyła drukowanie."
},
"third_party": {
"push_needs_relay": "Powiadomienia push wymagają przekaźnika push prowadzonego przez Twoją organizację, a żaden nie jest skonfigurowany. Nowe wiadomości nadal pojawiają się, gdy aplikacja jest otwarta.",
"relay_desc": "Dostępne są tylko własne przekaźniki Twojej organizacji, ponieważ ten serwer nie łączy się z usługami zewnętrznymi.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "As mensagens aqui são eliminadas {count, plural, one {# dia} other {# dias}} depois de serem movidas para cá."
},
"device_policy": {
"downloads_off": "A sua organização desativou as transferências.",
"download_off_named": "{name}: a sua organização desativou as transferências",
"download_blocked": "A sua organização desativou as transferências.",
"export_blocked": "A sua organização desativou a exportação de mensagens.",
"print_blocked": "A sua organização desativou a impressão."
},
"third_party": {
"push_needs_relay": "As notificações push precisam de um relay push mantido pela sua organização, e nenhum está configurado. Os novos e-mails continuam aparecendo enquanto o app estiver aberto.",
"relay_desc": "Só são oferecidos os relays da própria organização, porque este servidor não entra em contato com serviços externos.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Mesajele de aici sunt șterse la {count, plural, one {# zi} few {# zile} other {# de zile}} după ce au fost mutate aici."
},
"device_policy": {
"downloads_off": "Organizația dvs. a dezactivat descărcările.",
"download_off_named": "{name}: organizația dvs. a dezactivat descărcările",
"download_blocked": "Organizația dvs. a dezactivat descărcările.",
"export_blocked": "Organizația dvs. a dezactivat exportul e-mailurilor.",
"print_blocked": "Organizația dvs. a dezactivat imprimarea."
},
"third_party": {
"push_needs_relay": "Notificările push au nevoie de un releu push administrat de organizația dvs., iar niciunul nu este configurat. E-mailurile noi apar în continuare cât timp aplicația este deschisă.",
"relay_desc": "Sunt oferite doar releele proprii ale organizației dvs., deoarece acest server nu contactează servicii externe.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Письма здесь удаляются через {count, plural, one {# день} few {# дня} many {# дней} other {# дня}} после перемещения сюда."
},
"device_policy": {
"downloads_off": "Ваша организация отключила скачивание.",
"download_off_named": "{name}: ваша организация отключила скачивание",
"download_blocked": "Ваша организация отключила скачивание.",
"export_blocked": "Ваша организация отключила экспорт почты.",
"print_blocked": "Ваша организация отключила печать."
},
"third_party": {
"push_needs_relay": "Для push-уведомлений нужен push-релей вашей организации, а он не настроен. Новые письма по-прежнему видны, пока приложение открыто.",
"relay_desc": "Предлагаются только собственные релеи вашей организации, потому что этот сервер не обращается к внешним службам.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Správy tu sa vymažú {count, plural, one {# deň} few {# dni} many {# dňa} other {# dní}} po presunutí sem."
},
"device_policy": {
"downloads_off": "Vaša organizácia vypla sťahovanie.",
"download_off_named": "{name}: vaša organizácia vypla sťahovanie",
"download_blocked": "Vaša organizácia vypla sťahovanie.",
"export_blocked": "Vaša organizácia vypla export pošty.",
"print_blocked": "Vaša organizácia vypla tlač."
},
"third_party": {
"push_needs_relay": "Push oznámenia potrebujú push relay prevádzkovaný vašou organizáciou a žiadny nie je nastavený. Nová pošta sa naďalej zobrazuje, kým je aplikácia otvorená.",
"relay_desc": "Ponúkajú sa iba vlastné relaye vašej organizácie, pretože tento server nekontaktuje externé služby.",
+7
View File
@@ -673,6 +673,13 @@
"retention_notice": {
"line": "ข้อความที่นี่จะถูกลบ {count, plural, other {# วัน}} หลังจากย้ายมาที่นี่"
},
"device_policy": {
"downloads_off": "องค์กรของคุณปิดการดาวน์โหลด",
"download_off_named": "{name}: องค์กรของคุณปิดการดาวน์โหลด",
"download_blocked": "องค์กรของคุณปิดการดาวน์โหลด",
"export_blocked": "องค์กรของคุณปิดการส่งออกอีเมล",
"print_blocked": "องค์กรของคุณปิดการพิมพ์"
},
"third_party": {
"push_needs_relay": "การแจ้งเตือนแบบพุชต้องใช้ push relay ที่องค์กรของคุณดูแล แต่ยังไม่ได้ตั้งค่าไว้ อีเมลใหม่จะยังแสดงขณะที่เปิดแอปอยู่",
"relay_desc": "มีให้เลือกเฉพาะรีเลย์ขององค์กรคุณเอง เนื่องจากเซิร์ฟเวอร์นี้ไม่ติดต่อบริการภายนอก",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Buradaki iletiler buraya taşındıktan {count, plural, one {# gün} other {# gün}} sonra silinir."
},
"device_policy": {
"downloads_off": "Kuruluşunuz indirmeyi kapattı.",
"download_off_named": "{name}: kuruluşunuz indirmeyi kapattı",
"download_blocked": "Kuruluşunuz indirmeyi kapattı.",
"export_blocked": "Kuruluşunuz e-postaları dışa aktarmayı kapattı.",
"print_blocked": "Kuruluşunuz yazdırmayı kapattı."
},
"third_party": {
"push_needs_relay": "Anlık bildirimler için kuruluşunuzun işlettiği bir push rölesi gerekir ve hiçbiri ayarlanmamış. Uygulama açıkken yeni postalar yine de görünür.",
"relay_desc": "Bu sunucu dış hizmetlerle bağlantı kurmadığı için yalnızca kuruluşunuzun kendi röleleri sunulur.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "Листи тут видаляються через {count, plural, one {# день} few {# дні} many {# днів} other {# дня}} після переміщення сюди."
},
"device_policy": {
"downloads_off": "Ваша організація вимкнула завантаження.",
"download_off_named": "{name}: ваша організація вимкнула завантаження",
"download_blocked": "Ваша організація вимкнула завантаження.",
"export_blocked": "Ваша організація вимкнула експорт пошти.",
"print_blocked": "Ваша організація вимкнула друк."
},
"third_party": {
"push_needs_relay": "Для push-сповіщень потрібне push-реле вашої організації, а його не налаштовано. Нові листи й далі з'являються, поки застосунок відкритий.",
"relay_desc": "Пропонуються лише власні реле вашої організації, бо цей сервер не звертається до зовнішніх служб.",
+7
View File
@@ -1337,6 +1337,13 @@
"retention_notice": {
"line": "Thư ở đây sẽ bị xóa {count, plural, other {# ngày}} sau khi được chuyển vào đây."
},
"device_policy": {
"downloads_off": "Tổ chức của bạn đã tắt tải xuống.",
"download_off_named": "{name}: tổ chức của bạn đã tắt tải xuống",
"download_blocked": "Tổ chức của bạn đã tắt tải xuống.",
"export_blocked": "Tổ chức của bạn đã tắt xuất thư.",
"print_blocked": "Tổ chức của bạn đã tắt in."
},
"third_party": {
"push_needs_relay": "Thông báo đẩy cần có một chuyển tiếp đẩy do tổ chức của bạn điều hành và chưa có cài đặt nào. Thư mới vẫn hiển thị khi ứng dụng đang mở.",
"relay_desc": "Chỉ các rơle riêng của tổ chức bạn mới được cung cấp vì máy chủ này không liên hệ với các dịch vụ bên ngoài.",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "此處的郵件會在移入 {count, plural, other {# 天}} 後刪除。"
},
"device_policy": {
"downloads_off": "你的組織已關閉下載。",
"download_off_named": "{name}:你的組織已關閉下載",
"download_blocked": "你的組織已關閉下載。",
"export_blocked": "你的組織已關閉郵件匯出。",
"print_blocked": "你的組織已關閉列印。"
},
"third_party": {
"push_needs_relay": "推送通知需要由您的組織執行的推送中繼,但尚未設定。應用程式開啟時,新郵件仍會顯示。",
"relay_desc": "由於此伺服器不會連線至外部服務,因此只提供您組織自己的中繼。",
+7
View File
@@ -1325,6 +1325,13 @@
"retention_notice": {
"line": "此处的邮件会在移入 {count, plural, other {# 天}} 后删除。"
},
"device_policy": {
"downloads_off": "你的组织已关闭下载。",
"download_off_named": "{name}:你的组织已关闭下载",
"download_blocked": "你的组织已关闭下载。",
"export_blocked": "你的组织已关闭邮件导出。",
"print_blocked": "你的组织已关闭打印。"
},
"third_party": {
"push_needs_relay": "推送通知需要由您的组织运行的推送中继,但尚未设置。应用打开时,新邮件仍会显示。",
"relay_desc": "由于此服务器不连接外部服务,因此只提供您组织自己的中继。",
+6 -3
View File
@@ -14,6 +14,7 @@ import {
trashSubtreeIds, trashedItems,
} from '@/lib/files-trash';
import { buildZip, planZip, saveBlob, type ZipProgress } from '@/lib/files-zip';
import { downloadAllowed } from '@/lib/download-guard';
export interface FileResource {
id: string;
@@ -1340,7 +1341,7 @@ export const useFileStore = create<FileState>((set, get) => ({
const resource = resources.find(r => r.name === name);
if (!resource?.blobId) return;
await client.downloadBlob(resource.blobId, resource.name, resource.contentType);
await client.downloadBlob(resource.blobId, resource.name, resource.contentType, undefined, { kind: 'file' });
void fileHooks.onFileDownload.emit(resourceToView(resource, currentPath));
},
@@ -1354,13 +1355,15 @@ export const useFileStore = create<FileState>((set, get) => ({
downloadZip: async (ids, zipName, options = {}) => {
const { client } = get();
if (!client) return;
// Before fetching every file: the device policy may have turned downloads off.
if (!downloadAllowed('file', { client })) return;
const nodes = ids.some(isCrossAccountId)
? await client.listAllFileNodesAcrossAccounts()
: await client.listAllFileNodes();
const plan = planZip(nodes, ids);
// A node of a drive shared with the user is fetched from its owner's account.
const blob = await buildZip(plan, (node) => client.fetchBlob(node.blobId!, node.name, node.type, node.accountId), options);
saveBlob(blob, zipName);
saveBlob(blob, zipName, { subject: { client } });
},
getImageUrl: async (name: string) => {
@@ -1970,7 +1973,7 @@ export const useFileStore = create<FileState>((set, get) => ({
: await client.listAllFileNodes();
const node = nodes.find(n => n.id === id);
if (!node?.blobId) throw new Error('File not found');
await client.downloadBlob(node.blobId, node.name, node.type);
await client.downloadBlob(node.blobId, node.name, node.type, undefined, { kind: 'file' });
const folders = nodes.filter(isFolder).map(nodeToResource);
void fileHooks.onFileDownload.emit(fileToView(node, parentPath(folders, node.parentId)));
},
+4
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import type { ImportProgress } from "@/lib/mail-import/engine";
import type { ExportProgress } from "@/lib/mail-export/engine";
import { downloadAllowed } from "@/lib/download-guard";
/**
* The mail import and export dialog: what it was opened for, whether it is
@@ -68,6 +69,9 @@ export const useMailTransferStore = create<MailTransferState>()((set, get) => ({
visible: false,
job: null,
open: (request) => {
// The device policy "No export" of the active account (Organisation >
// Devices): the dialog doesn't open and the app says why. Import stays.
if (request.kind === "export" && !downloadAllowed("export", null)) return false;
if (isJobActive(get().job)) {
set({ visible: true });
return false;
+3
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { flushSync } from "react-dom";
import type { CalendarEvent, ContactCard } from "@/lib/jmap/types";
import type { DirectoryEntry } from "@/stores/contact-store";
import { canPrintAnywhere } from "@/lib/device-policy";
/**
* What a Print action asked for. Ctrl+P asks for nothing in particular: the
@@ -93,6 +94,8 @@ async function waitForPrintImages(): Promise<void> {
*/
export async function requestPrint(target: PrintTarget): Promise<void> {
if (usePrintStore.getState().printing) return;
// Organisation > Devices > No printing (the buttons are hidden then).
if (!canPrintAnywhere()) return;
startPrinting(target);
await waitForPrintImages();
if (!usePrintStore.getState().printing) return;