fix: save the attachment itself to Files on Stalwart 0.16, under a name the folder has free

This commit is contained in:
Linus Rath
2026-10-29 04:54:11 +01:00
parent c8600e859a
commit 39e82f99e4
4 changed files with 137 additions and 12 deletions
@@ -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'];
+11
View File
@@ -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,
+47 -2
View File
@@ -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<Array<Record<string, unknown>>> => []),
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');
});
});
+34 -10
View File
@@ -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<FileNode> {
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);
}