mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
feat: save attachments of shared and group mailboxes to Files by copying the blob on the server
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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[] }>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user