mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: save the attachment itself to Files on Stalwart 0.16, under a name the folder has free
This commit is contained in:
@@ -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'];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user