diff --git a/src/api/__tests__/live-stalwart-compose.test.ts b/src/api/__tests__/live-stalwart-compose.test.ts index 5b2b922e..123f26e3 100644 --- a/src/api/__tests__/live-stalwart-compose.test.ts +++ b/src/api/__tests__/live-stalwart-compose.test.ts @@ -199,6 +199,51 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => { expect(saved.attachments.map((a) => a.name)).toContain('from-files.txt'); }, 60_000); + const text = (buf: ArrayBuffer) => new TextDecoder().decode(new Uint8Array(buf)); + const nodeBlob = async (id: string) => { + const res = await singleton.request( + [['FileNode/get', { accountId: singleton.accountId, ids: [id] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.FILES], + ); + return res.methodResponses[0][1].list[0] as { blobId: string; name: string }; + }; + + // 0.16 made a FileNode from a part's blobId hold the whole raw message. + it("files a message's attachment part as the attachment's bytes, under a free name, and attaches it back", async () => { + const content = `part ${RUN}`; + const upload = await uploadBytes(new TextEncoder().encode(content), 'text/plain'); + const draft = await saveDraftVersion({ + from: [{ email: USER }], to: [{ email: USER }], subject: `part ${tag}`, textBody: 'see attached', + attachments: [{ blobId: upload.blobId, name: 'part.txt', type: 'text/plain', disposition: 'attachment' }], + }, drafts); + createdEmails.push(draft.id); + const [full] = await getFullEmails([draft.id]); + const part = full.attachments!.find((a) => a.name === 'part.txt')!; + expect(part.blobId).toBeTruthy(); + + const name = `${tag}-part.txt`; + const first = await saveAttachmentToFiles({ blobId: part.blobId!, name, type: 'text/plain' }, null); + createdNodes.push(first.id); + const stored = await nodeBlob(first.id); + expect(text(await singleton.fetchBlobArrayBuffer(stored.blobId, name, 'text/plain'))).toBe(content); + + // The same name again: the server would refuse it (alreadyExists). + const second = await saveAttachmentToFiles({ blobId: part.blobId!, name, type: 'text/plain' }, null); + createdNodes.push(second.id); + expect(second.name).toBe(`${tag}-part (1).txt`); + + // And back: the file attached to a new draft carries the same bytes. + const back = await attachmentFromFileNode({ ...stored, id: first.id, nodeType: 'file', type: 'text/plain' }); + const reply = await saveDraftVersion({ + from: [{ email: USER }], to: [{ email: USER }], subject: `back ${tag}`, textBody: 'see attached', + attachments: [{ blobId: back.blobId, name: 'back.txt', type: 'text/plain', disposition: 'attachment' }], + }, drafts); + createdEmails.push(reply.id); + const [again] = await getFullEmails([reply.id]); + const attached = again.attachments!.find((a) => a.name === 'back.txt')!; + expect(text(await singleton.fetchBlobArrayBuffer(attached.blobId!, 'back.txt', 'text/plain'))).toBe(content); + }, 60_000); + it('copies blobs with Blob/copy only when the blob capability is in `using`', async () => { const upload = await uploadBytes(new TextEncoder().encode(`copy ${RUN}`), 'text/plain'); const call = ['Blob/copy', { fromAccountId: singleton.accountId, accountId: singleton.accountId, blobIds: [upload.blobId] }, '0']; diff --git a/src/api/files.ts b/src/api/files.ts index d618f601..8907d2e2 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -426,6 +426,17 @@ function usesDraftFileNodeCreate(accountId: string): boolean { return !!cap && typeof cap === 'object' && 'supportedNodeTypes' in cap; } +/** + * Whether a FileNode made from a message part's blobId holds that part. + * Stalwart 1.0.0 stores the part; 0.16 stores the whole raw message the part + * is in under the part's name (seen on 0.16.27), so there the part's bytes + * have to be uploaded as a blob of their own first. Told apart as for the + * create shape: only 1.0.0 implements File Storage draft 18. + */ +export function fileNodeTakesPartBlobs(accountId: string = filesAccountId()): boolean { + return usesDraftFileNodeCreate(accountId); +} + /** One FileNode/set create in `accountId`, in the shape that server takes. */ async function createFileNodeIn( accountId: string, diff --git a/src/lib/__tests__/compose-files.test.ts b/src/lib/__tests__/compose-files.test.ts index 07010c62..11c060ca 100644 --- a/src/lib/__tests__/compose-files.test.ts +++ b/src/lib/__tests__/compose-files.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { files, email } = vi.hoisted(() => ({ +const { files, email, blob, client } = vi.hoisted(() => ({ files: { filesAccountId: vi.fn(() => 'me'), copyFileNode: vi.fn(async (node: { blobId: string }, parentId: string | null, name: string) => ({ @@ -8,16 +8,30 @@ const { files, email } = vi.hoisted(() => ({ })), fileNodeKind: vi.fn((node: { nodeType?: string; blobId?: string | null }) => node.nodeType ?? (node.blobId == null ? 'directory' : 'file')), + // Stalwart 1.0.0 unless a test says otherwise. + fileNodeTakesPartBlobs: vi.fn(() => true), + getAllFileNodes: vi.fn(async (): Promise>> => []), + getFileNameRules: vi.fn(() => null), }, email: { // As the real one: the ids as they are within one account. copyBlobs: vi.fn(async (from: string, ids: string[], to: string) => Object.fromEntries(ids.map((id) => [id, from === to ? id : `copy-of-${id}`]))), }, + blob: { + uploadBytes: vi.fn(async (_bytes: Uint8Array, type: string, accountId?: string) => ({ + blobId: `uploaded-to-${accountId}`, size: 3, type, + })), + }, + client: { + accountId: 'me', + fetchBlobArrayBuffer: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), + }, })); vi.mock('../../api/files', () => files); vi.mock('../../api/email', () => email); -vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'me' } })); +vi.mock('../../api/blob', () => blob); +vi.mock('../../api/jmap-client', () => ({ jmapClient: client })); import type { FileNode } from '../../api/types'; import { @@ -92,4 +106,35 @@ describe('Save to Files', () => { expect.objectContaining({ blobId: 'copy-of-att-1' }), null, 'invoice.pdf', ); }); + + // 0.16 filed the whole raw message under the attachment's name. + it("uploads the attachment's bytes into the Files account on Stalwart 0.16", async () => { + files.fileNodeTakesPartBlobs.mockReturnValue(false); + try { + await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, 'd1', 'team'); + } finally { + files.fileNodeTakesPartBlobs.mockReturnValue(true); + } + expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('att-1', 'invoice.pdf', 'application/pdf', 'team'); + expect(blob.uploadBytes).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]), 'application/pdf', 'me'); + expect(email.copyBlobs).not.toHaveBeenCalled(); + expect(files.copyFileNode).toHaveBeenCalledWith( + expect.objectContaining({ blobId: 'uploaded-to-me' }), 'd1', 'invoice.pdf', + ); + }); + + it('numbers the name when the folder holds one like it', async () => { + files.getAllFileNodes.mockResolvedValueOnce([ + { id: 'x', name: 'invoice.pdf', parentId: 'd1' }, + { id: 'y', name: 'invoice (1).pdf', parentId: 'd1' }, + { id: 'z', name: 'other.pdf', parentId: null }, + { id: 's', name: 'other (1).pdf', parentId: null, isShared: true }, + ]); + await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, 'd1'); + expect(files.copyFileNode).toHaveBeenLastCalledWith(expect.anything(), 'd1', 'invoice (2).pdf'); + + files.getAllFileNodes.mockResolvedValueOnce([{ id: 'x', name: 'invoice.pdf', parentId: 'd1' }]); + await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, null); + expect(files.copyFileNode).toHaveBeenLastCalledWith(expect.anything(), null, 'invoice.pdf'); + }); }); diff --git a/src/lib/compose-files.ts b/src/lib/compose-files.ts index bd56ad95..52649d98 100644 --- a/src/lib/compose-files.ts +++ b/src/lib/compose-files.ts @@ -1,14 +1,21 @@ // Mail and Files meet here: a file from Files attached to a message (#1179) -// and an attachment filed in Files (webmail save-to-files-dialog). Neither -// downloads or uploads anything: both refer to a blob the server already -// has. Stalwart 0.16.27 and 1.0.0 take a FileNode's blob as an attachment -// and an attachment's blob for a FileNode within one account; across -// accounts the blob is copied first (Blob/copy), as the webmail does. +// and an attachment filed in Files (webmail save-to-files-dialog). Both +// refer to a blob the server already has where they can. Stalwart 0.16.27 +// and 1.0.0 take a FileNode's blob as an attachment, and 1.0.0 an +// attachment's blob for a FileNode; across accounts the blob is copied first +// (Blob/copy), as the webmail does. On 0.16 a FileNode made from an +// attachment's blob holds the whole message, so there the attachment is +// downloaded and uploaded again. +import { uploadBytes } from '../api/blob'; import { copyBlobs } from '../api/email'; -import { copyFileNode, fileNodeKind, filesAccountId } from '../api/files'; +import { + copyFileNode, fileNodeKind, fileNodeTakesPartBlobs, filesAccountId, getAllFileNodes, getFileNameRules, +} from '../api/files'; import { jmapClient } from '../api/jmap-client'; import type { FileNode } from '../api/types'; +import { acceptedFileName } from './file-name-rules'; +import { getUniqueName } from './filenode-name'; /** The Files trash the webmail keeps (lib/files-trash): hidden like any dot folder. */ const TRASH_FOLDER_NAME = '.Trash'; @@ -73,7 +80,9 @@ export async function attachmentFromFileNode( * File an attachment in the user's Files, in `parentId` (null: My files). * `blobAccountId` is the account the message is in when it is not the * login's own: a shared or group mailbox's blob is copied into the Files - * account first. + * account first. On Stalwart 0.16 the attachment's bytes are uploaded into + * the Files account instead (see fileNodeTakesPartBlobs). A name the folder + * has already gets a number. */ export async function saveAttachmentToFiles( attachment: { blobId: string; name?: string | null; type?: string | null }, @@ -81,8 +90,23 @@ export async function saveAttachmentToFiles( blobAccountId?: string, ): Promise { const from = blobAccountId ?? jmapClient.accountId; - const blobId = (await copyBlobs(from, [attachment.blobId], filesAccountId()))[attachment.blobId]; - const name = attachment.name || 'attachment'; - const node: FileNode = { id: '', name, type: attachment.type || 'application/octet-stream', blobId, nodeType: 'file' }; + const to = filesAccountId(); + const type = attachment.type || 'application/octet-stream'; + // A name the folder holds already is refused (alreadyExists): the copy + // gets " (1)", " (2)", ... as an upload does. + const taken = new Set( + (await getAllFileNodes()) + .filter((n) => !n.isShared && (n.parentId ?? null) === parentId) + .map((n) => n.name), + ); + const name = getUniqueName(acceptedFileName(attachment.name || 'attachment', getFileNameRules()), taken); + let blobId: string; + if (fileNodeTakesPartBlobs(to)) { + blobId = (await copyBlobs(from, [attachment.blobId], to))[attachment.blobId]; + } else { + const bytes = await jmapClient.fetchBlobArrayBuffer(attachment.blobId, name, type, from); + blobId = (await uploadBytes(new Uint8Array(bytes), type, to)).blobId; + } + const node: FileNode = { id: '', name, type, blobId, nodeType: 'file' }; return copyFileNode(node, parentId, name); }