From 42b694fb2c26b1eacd4c7887425f53a85697bade Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 08:16:47 +0100
Subject: [PATCH] feat: save attachments of shared and group mailboxes to Files
by copying the blob on the server
---
.../__tests__/save-to-files-dialog.test.tsx | 20 +++++
.../__tests__/viewer-save-to-files.test.tsx | 73 +++++++++++++++++++
components/email/email-viewer.tsx | 8 +-
components/email/save-to-files-dialog.tsx | 21 ++++--
lib/__tests__/jmap-blob-copy.test.ts | 55 ++++++++++++++
lib/demo/demo-client.ts | 5 ++
lib/jmap/client-interface.ts | 6 ++
lib/jmap/client.ts | 20 +++++
8 files changed, 200 insertions(+), 8 deletions(-)
create mode 100644 components/email/__tests__/viewer-save-to-files.test.tsx
create mode 100644 lib/__tests__/jmap-blob-copy.test.ts
diff --git a/components/email/__tests__/save-to-files-dialog.test.tsx b/components/email/__tests__/save-to-files-dialog.test.tsx
index 0af4e64bf..87b3ea588 100644
--- a/components/email/__tests__/save-to-files-dialog.test.tsx
+++ b/components/email/__tests__/save-to-files-dialog.test.tsx
@@ -28,4 +28,24 @@ describe('SaveToFilesDialog', () => {
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(useToastStore.getState().toasts.some((toast) => toast.title === 'saved')).toBe(true);
});
+
+ // A shared mailbox's attachment: the blob is in the team's account, the
+ // file goes into the user's own Files account.
+ it('copies the blob of a shared account into the Files account first', async () => {
+ const createFileNode = vi.fn().mockResolvedValue({});
+ const copyBlobs = vi.fn().mockResolvedValue({ 'blob-1': 'blob-mine' });
+ const client = {
+ listAllFileNodes: vi.fn().mockResolvedValue(nodes),
+ getFilesAccountId: () => 'me',
+ copyBlobs,
+ createFileNode,
+ } as unknown as IJMAPClient;
+ render();
+
+ fireEvent.click(await screen.findByText('Documents'));
+ fireEvent.click(screen.getByRole('button', { name: 'save_here' }));
+
+ await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-mine', 'application/pdf', 1234, 'docs'));
+ expect(copyBlobs).toHaveBeenCalledWith('team', ['blob-1'], 'me');
+ });
});
diff --git a/components/email/__tests__/viewer-save-to-files.test.tsx b/components/email/__tests__/viewer-save-to-files.test.tsx
new file mode 100644
index 000000000..0d0a39b2d
--- /dev/null
+++ b/components/email/__tests__/viewer-save-to-files.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { Email } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// Save to Files for an attachment of a shared or group mailbox of the same
+// login: its blob is copied into the Files account (Blob/copy). Another
+// login's mail can't reach this login's Files, so it isn't offered there.
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { usePolicyStore } = await import('@/stores/policy-store');
+
+const email = {
+ id: 'e1',
+ blobId: 'b1',
+ threadId: 't1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Invoice',
+ receivedAt: '2026-01-01T00:00:00Z',
+ preview: 'See attached',
+ hasAttachment: true,
+ size: 100,
+ textBody: [],
+ htmlBody: [],
+ attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }],
+ bodyValues: {},
+} as unknown as Email;
+
+function makeClient(accountId: string) {
+ return new Proxy({ getAccountId: () => accountId, getFilesAccountId: () => accountId, supportsFiles: () => true } as Record, {
+ get: (target, prop) => (prop in target ? target[prop as string] : prop === 'then' ? undefined : vi.fn().mockResolvedValue(null)),
+ }) as unknown as IJMAPClient;
+}
+
+describe('Save to Files in the reader', () => {
+ const own = makeClient('me');
+
+ beforeEach(() => {
+ useAuthStore.setState({ client: own } as never);
+ usePolicyStore.setState({ isFeatureEnabled: () => true } as never);
+ });
+
+ it("is offered for a shared mailbox's attachment of the same login", () => {
+ render();
+ expect(screen.getAllByRole('button', { name: 'save_to_files.title' }).length).toBeGreaterThan(0);
+ });
+
+ it("isn't offered for another login's mail", () => {
+ render();
+ expect(screen.queryByRole('button', { name: 'save_to_files.title' })).toBeNull();
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index d9a474015..a2453d2e7 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -1470,8 +1470,10 @@ export function EmailViewer({
const blobClient = blobSource ? blobSource.client : legacyBlobClient;
const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId;
const filesFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
- const canSaveToFiles = filesFeatureEnabled && !!client && blobClient === client
- && !!client.supportsFiles?.() && (!blobAccountId || blobAccountId === client.getAccountId());
+ // The blob must be reachable from the Files account's session: the
+ // login's own mail, or a shared or group account of the same login, whose
+ // blob is copied over (Blob/copy). Another login's mail can't be.
+ const canSaveToFiles = filesFeatureEnabled && !!client && blobClient === client && !!client.supportsFiles?.();
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
@@ -6258,7 +6260,7 @@ export function EmailViewer({
)}
{canSaveToFiles && client && saveToFilesTarget && (
- setSaveToFilesTarget(null)} />
+ setSaveToFilesTarget(null)} />
)}
{/* View source: the raw message the server holds. */}
diff --git a/components/email/save-to-files-dialog.tsx b/components/email/save-to-files-dialog.tsx
index a33528888..907075174 100644
--- a/components/email/save-to-files-dialog.tsx
+++ b/components/email/save-to-files-dialog.tsx
@@ -20,8 +20,14 @@ export interface SaveableAttachment {
interface SaveToFilesDialogProps {
/** The attachment to save; the dialog is open while it is set. */
attachment: SaveableAttachment | null;
- /** The mail login's client: the attachment's blob is in its own account, as are its files. */
+ /** The mail login's client; its Files account takes the file. */
client: IJMAPClient;
+ /**
+ * The JMAP account the attachment's blob is in, when not the Files
+ * account (a shared or group mailbox of the same login): the blob is
+ * copied over first with Blob/copy.
+ */
+ blobAccountId?: string;
onClose: () => void;
}
@@ -30,10 +36,11 @@ const NO_CURRENT_FOLDER = "#none";
/**
* "Save to Files" for an attachment: pick a folder with the Files app's
- * folder picker. The attachment's blob is already on the server, in the same
- * account as the files, so it is filed by blobId with no download or upload.
+ * folder picker. The attachment's blob is already on the server, so it is
+ * filed by blobId with no download or upload; from another account of the
+ * login it is copied into the Files account first (Blob/copy, RFC 8620 §6.3).
*/
-export function SaveToFilesDialog({ attachment, client, onClose }: SaveToFilesDialogProps) {
+export function SaveToFilesDialog({ attachment, client, blobAccountId, onClose }: SaveToFilesDialogProps) {
const t = useTranslations("email_viewer.save_to_files");
const tFiles = useTranslations("files");
const [nodes, setNodes] = useState([]);
@@ -68,9 +75,13 @@ export function SaveToFilesDialog({ attachment, client, onClose }: SaveToFilesDi
if (!attachment?.blobId) return;
const folderName = parentId ? (folders.find((f) => f.id === parentId)?.name ?? "") : tFiles("my_files");
try {
+ const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
+ const blobId = blobAccountId && filesAccountId && blobAccountId !== filesAccountId
+ ? (await client.copyBlobs(blobAccountId, [attachment.blobId], filesAccountId))[attachment.blobId]
+ : attachment.blobId;
await client.createFileNode(
attachment.name || "attachment",
- attachment.blobId,
+ blobId,
attachment.type || "application/octet-stream",
attachment.size,
parentId,
diff --git a/lib/__tests__/jmap-blob-copy.test.ts b/lib/__tests__/jmap-blob-copy.test.ts
new file mode 100644
index 000000000..916ba40cb
--- /dev/null
+++ b/lib/__tests__/jmap-blob-copy.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+// Blob/copy (RFC 8620 §6.3): Save to Files puts an attachment of a shared
+// mailbox into the user's own Files account without a download.
+
+function createClient(): JMAPClient {
+ const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
+ Object.assign(client, {
+ apiUrl: 'https://jmap.example.com/jmap/',
+ accountId: 'a',
+ session: {
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
+ accounts: { a: { name: 'me', isPersonal: true, accountCapabilities: {} }, b: { name: 'team', isPersonal: false, accountCapabilities: {} } },
+ },
+ });
+ return client;
+}
+
+function answer(result: object) {
+ const calls: unknown[] = [];
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
+ calls.push(JSON.parse(String(init?.body)).methodCalls[0]);
+ return {
+ ok: true,
+ status: 200,
+ headers: new Headers(),
+ text: () => Promise.resolve(JSON.stringify({ methodResponses: [['Blob/copy', result, 'bc0']] })),
+ json: () => Promise.resolve({ methodResponses: [['Blob/copy', result, 'bc0']] }),
+ } as Response;
+ });
+ return calls;
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('copyBlobs', () => {
+ it('asks the server to copy the blob into the other account and returns its new id', async () => {
+ const calls = answer({ fromAccountId: 'b', accountId: 'a', copied: { Gteam1: 'Gmine1' }, notCopied: null });
+ const copied = await createClient().copyBlobs('b', ['Gteam1'], 'a');
+ expect(calls).toEqual([['Blob/copy', { fromAccountId: 'b', accountId: 'a', blobIds: ['Gteam1'] }, 'bc0']]);
+ expect(copied).toEqual({ Gteam1: 'Gmine1' });
+ });
+
+ it('fails when the server did not copy it', async () => {
+ answer({ fromAccountId: 'b', accountId: 'a', copied: null, notCopied: { Gteam1: { type: 'blobNotFound' } } });
+ await expect(createClient().copyBlobs('b', ['Gteam1'], 'a')).rejects.toThrow('blobNotFound');
+ });
+
+ it('copies nothing within one account', async () => {
+ const calls = answer({});
+ expect(await createClient().copyBlobs('a', ['G1'], 'a')).toEqual({ G1: 'G1' });
+ expect(calls).toEqual([]);
+ });
+});
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index aac590b2a..44bb9e3d7 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -1393,6 +1393,11 @@ export class DemoJMAPClient implements IJMAPClient {
return node;
}
+ async copyBlobs(_fromAccountId: string, blobIds: string[], _toAccountId: string): Promise> {
+ // One demo store holds every account's blobs.
+ return Object.fromEntries(blobIds.map((id) => [id, id]));
+ }
+
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise {
const node: FileNode = {
id: generateDemoId('file'),
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 55a13299e..549d06060 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -644,6 +644,12 @@ export interface IJMAPClient {
getFileNodes(ids: string[] | null, properties?: string[]): Promise;
createFileDirectory(name: string, parentId: string | null): Promise;
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise;
+ /**
+ * Blob/copy (RFC 8620 §6.3): copies blobs from one account of this session
+ * into another, on the server. Returns the new id of each copied blob;
+ * throws when one was not copied.
+ */
+ copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise>;
updateFileNode(id: string, updates: Partial>): Promise;
updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 0ec6b2ad7..d611a5a7d 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -9601,6 +9601,26 @@ export class JMAPClient implements IJMAPClient {
* `size` is not sent: every server works it out from the blob, and
* Stalwart 1.0 refuses a create whose size differs from the blob's.
*/
+ async copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise> {
+ if (blobIds.length === 0 || fromAccountId === toAccountId) {
+ return Object.fromEntries(blobIds.map((id) => [id, id]));
+ }
+ const response = await this.request([
+ ["Blob/copy", { fromAccountId, accountId: toAccountId, blobIds }, "bc0"],
+ ]);
+ const [name, result] = response.methodResponses?.[0] ?? [];
+ if (name !== "Blob/copy" || !result) {
+ throw jmapError(result?.description || result?.type || "Blob/copy failed", result);
+ }
+ const copied = (result.copied ?? {}) as Record;
+ const missing = blobIds.filter((id) => !copied[id]);
+ if (missing.length > 0) {
+ const error = (result.notCopied ?? {})[missing[0]];
+ throw jmapError(error?.description || error?.type || "Blob/copy did not copy the blob", error);
+ }
+ return copied;
+ }
+
async createFileNode(name: string, blobId: string, type: string, _size: number, parentId: string | null): Promise {
const parent = this.resolveFileNodeId(parentId);