mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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');
|
expect(saved.attachments.map((a) => a.name)).toContain('from-files.txt');
|
||||||
}, 60_000);
|
}, 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 () => {
|
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 upload = await uploadBytes(new TextEncoder().encode(`copy ${RUN}`), 'text/plain');
|
||||||
const call = ['Blob/copy', { fromAccountId: singleton.accountId, accountId: singleton.accountId, blobIds: [upload.blobId] }, '0'];
|
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;
|
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. */
|
/** One FileNode/set create in `accountId`, in the shape that server takes. */
|
||||||
async function createFileNodeIn(
|
async function createFileNodeIn(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
const { files, email } = vi.hoisted(() => ({
|
const { files, email, blob, client } = vi.hoisted(() => ({
|
||||||
files: {
|
files: {
|
||||||
filesAccountId: vi.fn(() => 'me'),
|
filesAccountId: vi.fn(() => 'me'),
|
||||||
copyFileNode: vi.fn(async (node: { blobId: string }, parentId: string | null, name: string) => ({
|
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 }) =>
|
fileNodeKind: vi.fn((node: { nodeType?: string; blobId?: string | null }) =>
|
||||||
node.nodeType ?? (node.blobId == null ? 'directory' : 'file')),
|
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: {
|
email: {
|
||||||
// As the real one: the ids as they are within one account.
|
// As the real one: the ids as they are within one account.
|
||||||
copyBlobs: vi.fn(async (from: string, ids: string[], to: string) =>
|
copyBlobs: vi.fn(async (from: string, ids: string[], to: string) =>
|
||||||
Object.fromEntries(ids.map((id) => [id, from === to ? id : `copy-of-${id}`]))),
|
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/files', () => files);
|
||||||
vi.mock('../../api/email', () => email);
|
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 type { FileNode } from '../../api/types';
|
||||||
import {
|
import {
|
||||||
@@ -92,4 +106,35 @@ describe('Save to Files', () => {
|
|||||||
expect.objectContaining({ blobId: 'copy-of-att-1' }), null, 'invoice.pdf',
|
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)
|
// 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
|
// and an attachment filed in Files (webmail save-to-files-dialog). Both
|
||||||
// downloads or uploads anything: both refer to a blob the server already
|
// refer to a blob the server already has where they can. Stalwart 0.16.27
|
||||||
// has. Stalwart 0.16.27 and 1.0.0 take a FileNode's blob as an attachment
|
// and 1.0.0 take a FileNode's blob as an attachment, and 1.0.0 an
|
||||||
// and an attachment's blob for a FileNode within one account; across
|
// attachment's blob for a FileNode; across accounts the blob is copied first
|
||||||
// accounts the blob is copied first (Blob/copy), as the webmail does.
|
// (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 { 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 { jmapClient } from '../api/jmap-client';
|
||||||
import type { FileNode } from '../api/types';
|
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. */
|
/** The Files trash the webmail keeps (lib/files-trash): hidden like any dot folder. */
|
||||||
const TRASH_FOLDER_NAME = '.Trash';
|
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).
|
* 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
|
* `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
|
* 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(
|
export async function saveAttachmentToFiles(
|
||||||
attachment: { blobId: string; name?: string | null; type?: string | null },
|
attachment: { blobId: string; name?: string | null; type?: string | null },
|
||||||
@@ -81,8 +90,23 @@ export async function saveAttachmentToFiles(
|
|||||||
blobAccountId?: string,
|
blobAccountId?: string,
|
||||||
): Promise<FileNode> {
|
): Promise<FileNode> {
|
||||||
const from = blobAccountId ?? jmapClient.accountId;
|
const from = blobAccountId ?? jmapClient.accountId;
|
||||||
const blobId = (await copyBlobs(from, [attachment.blobId], filesAccountId()))[attachment.blobId];
|
const to = filesAccountId();
|
||||||
const name = attachment.name || 'attachment';
|
const type = attachment.type || 'application/octet-stream';
|
||||||
const node: FileNode = { id: '', name, type: attachment.type || 'application/octet-stream', blobId, nodeType: 'file' };
|
// 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);
|
return copyFileNode(node, parentId, name);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user