feat: save attachments of shared and group mailboxes to Files by copying the blob on the server

This commit is contained in:
Linus Rath
2026-10-28 08:16:47 +01:00
parent ed9fa2de45
commit 42b694fb2c
8 changed files with 200 additions and 8 deletions
@@ -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(<SaveToFilesDialog attachment={attachment} client={client} blobAccountId="team" onClose={vi.fn()} />);
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');
});
});
@@ -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<string, unknown>, {
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(<EmailViewer email={email} blobSource={{ client: own, accountId: 'team' }} mailboxes={[]} selectedMailbox="inbox" />);
expect(screen.getAllByRole('button', { name: 'save_to_files.title' }).length).toBeGreaterThan(0);
});
it("isn't offered for another login's mail", () => {
render(<EmailViewer email={email} blobSource={{ client: makeClient('other'), accountId: 'other' }} mailboxes={[]} selectedMailbox="inbox" />);
expect(screen.queryByRole('button', { name: 'save_to_files.title' })).toBeNull();
});
});
+5 -3
View File
@@ -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 && (
<SaveToFilesDialog attachment={saveToFilesTarget} client={client} onClose={() => setSaveToFilesTarget(null)} />
<SaveToFilesDialog attachment={saveToFilesTarget} client={client} blobAccountId={blobAccountId} onClose={() => setSaveToFilesTarget(null)} />
)}
{/* View source: the raw message the server holds. */}
+16 -5
View File
@@ -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<FileNode[]>([]);
@@ -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,
+55
View File
@@ -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([]);
});
});
+5
View File
@@ -1393,6 +1393,11 @@ export class DemoJMAPClient implements IJMAPClient {
return node;
}
async copyBlobs(_fromAccountId: string, blobIds: string[], _toAccountId: string): Promise<Record<string, string>> {
// 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<FileNode> {
const node: FileNode = {
id: generateDemoId('file'),
+6
View File
@@ -644,6 +644,12 @@ export interface IJMAPClient {
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
/**
* 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<Record<string, string>>;
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
+20
View File
@@ -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<Record<string, string>> {
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<string, string>;
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<FileNode> {
const parent = this.resolveFileNodeId(parentId);