mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
fix: keep inline images of drafts and sent mail on Stalwart 1.0
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { getFullEmail, getFullEmails, saveDraftVersion } from '../email';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
// A draft with an inline image as Stalwart 1.0.0 returns it (measured
|
||||
// 2026-10-29): the image sits in mixed next to the alternative, so it is in
|
||||
// textBody and htmlBody but not in `attachments`.
|
||||
const IMAGE = { partId: '4', blobId: 'b-img', type: 'image/png', disposition: 'inline', cid: 'img1@live.test', name: 'img1.png', size: 70 };
|
||||
const on10 = {
|
||||
id: 'd1',
|
||||
attachments: [],
|
||||
textBody: [{ partId: '2', blobId: 'b-text', type: 'text/plain' }, IMAGE],
|
||||
htmlBody: [{ partId: '3', blobId: 'b-html', type: 'text/html' }, IMAGE],
|
||||
};
|
||||
// The same message on 0.16.27: the image is in all three lists.
|
||||
const on016 = { ...on10, attachments: [IMAGE] };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('inline images that Stalwart 1.0 lists only in the body lists', () => {
|
||||
it('getFullEmail takes them into attachments', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['Email/get', { list: [on10], notFound: [] }, '0']] });
|
||||
const email = await getFullEmail('d1');
|
||||
expect(email.attachments).toEqual([IMAGE]);
|
||||
});
|
||||
|
||||
it('getFullEmails does too, and lists a 0.16 part once', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Email/get', { list: [on10, { ...on016, id: 'd2' }], notFound: [] }, '0']],
|
||||
});
|
||||
const [a, b] = await getFullEmails(['d1', 'd2']);
|
||||
expect(a.attachments?.map((x) => x.cid)).toEqual(['img1@live.test']);
|
||||
expect(b.attachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saveDraftVersion reads the new version\'s inline part back from the body lists', async () => {
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, unknown>, string]>) => {
|
||||
const get = calls.find(([m]) => m === 'Email/get')![1];
|
||||
expect(get.properties).toEqual(expect.arrayContaining(['attachments', 'textBody', 'htmlBody']));
|
||||
return {
|
||||
methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'd1' } } }, '0'],
|
||||
['Email/get', { list: [on10] }, '1'],
|
||||
],
|
||||
};
|
||||
});
|
||||
const saved = await saveDraftVersion({
|
||||
from: [{ email: 'me@example.org' }], to: [], subject: 's',
|
||||
htmlBody: '<p><img src="cid:img1@live.test"></p>',
|
||||
attachments: [{ blobId: 'up-1', type: 'image/png', name: 'img1.png', disposition: 'inline', cid: 'img1@live.test' }],
|
||||
}, 'drafts');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['b-img']);
|
||||
});
|
||||
});
|
||||
@@ -267,9 +267,16 @@ live('live Stalwart', () => {
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
// Each save re-pointed the image at the new version's part (on 1.0
|
||||
// the read-back finds it in the body lists only).
|
||||
expect(liveBlobId(state, seeded)).not.toBe(seeded);
|
||||
// Without the rebase the seeded part blob is gone with D0. 0.16 says
|
||||
// "blobId … does not exist", 1.0 "One or more blobIds do not exist";
|
||||
// 1.0 sometimes still resolves it for a moment after the destroy.
|
||||
const stale = await saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts)
|
||||
.then((v) => v, (e: Error) => e);
|
||||
if (stale instanceof Error) expect(stale.message).toMatch(/does not exist|do not exist|blobNotFound/);
|
||||
else created.push(stale.id);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
|
||||
+11
-6
@@ -16,6 +16,7 @@ import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||||
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
|
||||
import { hasTruncatedDisplayedBody } from '../lib/email-body';
|
||||
import { withInlineBodyParts } from '../lib/email-inline-parts';
|
||||
import { stalwartStateWentBack } from './stalwart-state';
|
||||
import { isTransientNetworkError } from '../lib/network-error';
|
||||
|
||||
@@ -936,8 +937,10 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom
|
||||
['Email/get', { accountId, ids: [id], ...FULL_BODY_ARGS }, '0'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/get');
|
||||
const email = (body.list as Email[] | undefined)?.[0];
|
||||
if (!email) throw new Error(`Email ${id} not found`);
|
||||
const fetched = (body.list as Email[] | undefined)?.[0];
|
||||
if (!fetched) throw new Error(`Email ${id} not found`);
|
||||
// Stalwart 1.0 lists an inline image in mixed only in the body lists.
|
||||
const email = withInlineBodyParts(fetched);
|
||||
await refetchTruncatedBodyValues([email], accountId);
|
||||
return email;
|
||||
}
|
||||
@@ -972,7 +975,7 @@ export async function getFullEmailsWithState(
|
||||
['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/get');
|
||||
out.list.push(...((body.list as Email[]) ?? []));
|
||||
out.list.push(...((body.list as Email[]) ?? []).map(withInlineBodyParts));
|
||||
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
|
||||
out.state = body.state as string | undefined;
|
||||
}
|
||||
@@ -1908,7 +1911,8 @@ export async function saveDraftVersion(
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
// The body lists too: Stalwart 1.0 lists the inline images there only.
|
||||
properties: ['id', 'attachments', 'textBody', 'htmlBody'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
@@ -1920,8 +1924,9 @@ export async function saveDraftVersion(
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
const saved = (got.list as Array<Pick<Email, 'id' | 'attachments' | 'textBody' | 'htmlBody'>> | undefined)
|
||||
?.find((e) => e.id === id);
|
||||
attachments = (saved ? withInlineBodyParts(saved).attachments ?? [] : []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user