mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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 { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
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/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
+48
-5
@@ -1679,31 +1679,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
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);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1711,7 +1744,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
initialDraftPartBlobState, liveBlobId, matchDraftParts, rebaseDraftPartBlobs, withSnapshotBody,
|
||||
} from '../draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../../api/email';
|
||||
|
||||
const inline = (blobId: string, cid: string, name = 'img.png', size = 10): OutgoingAttachment => ({
|
||||
blobId, cid, name, size, type: 'image/png', disposition: 'inline',
|
||||
});
|
||||
const file = (blobId: string, name: string, size: number): OutgoingAttachment => ({
|
||||
blobId, name, size, type: 'application/pdf', disposition: 'attachment',
|
||||
});
|
||||
|
||||
describe('matchDraftParts', () => {
|
||||
it('pairs inline parts by Content-ID, brackets and case aside', () => {
|
||||
const pairs = matchDraftParts(
|
||||
[inline('old-a', 'a@x'), inline('old-b', 'b@x')],
|
||||
[
|
||||
{ blobId: 'new-b', cid: '<B@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'new-a', cid: '<a@x>', name: 'img.png', size: 10 },
|
||||
],
|
||||
);
|
||||
expect(pairs).toEqual(new Map([['old-a', 'new-a'], ['old-b', 'new-b']]));
|
||||
});
|
||||
|
||||
it('pairs file attachments by name and size, claiming each new part once', () => {
|
||||
const pairs = matchDraftParts(
|
||||
[file('old-1', 'q.pdf', 5), file('old-2', 'q.pdf', 5), file('old-3', 'r.pdf', 7)],
|
||||
[
|
||||
{ blobId: 'new-r', name: 'r.pdf', size: 7 },
|
||||
{ blobId: 'new-q1', name: 'q.pdf', size: 5 },
|
||||
{ blobId: 'new-q2', name: 'q.pdf', size: 5 },
|
||||
],
|
||||
);
|
||||
expect(pairs.get('old-1')).toBe('new-q1');
|
||||
expect(pairs.get('old-2')).toBe('new-q2');
|
||||
expect(pairs.get('old-3')).toBe('new-r');
|
||||
});
|
||||
|
||||
it('leaves a part it cannot find unpaired', () => {
|
||||
expect(matchDraftParts([file('old', 'gone.pdf', 1)], [{ blobId: 'n', name: 'other.pdf', size: 1 }]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebaseDraftPartBlobs (webmail #849, second half)', () => {
|
||||
it('follows the draft across successive saves so no save sends a destroyed part', () => {
|
||||
// Re-opened draft D0 with one inline image and one PDF.
|
||||
let state = initialDraftPartBlobState();
|
||||
const sentAt = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'd0-pdf'), 'q.pdf', 5)];
|
||||
|
||||
// Save 1 sends D0's parts (D0 is still alive), creates D1, destroys D0.
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d0-img', 'd0-pdf']);
|
||||
state = rebaseDraftPartBlobs(state, sentAt(), [
|
||||
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd1-pdf', name: 'q.pdf', size: 5 },
|
||||
]);
|
||||
// Save 2 / send must reference D1's parts, not D0's dead ones.
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d1-img', 'd1-pdf']);
|
||||
state = rebaseDraftPartBlobs(state, sentAt(), [
|
||||
{ blobId: 'd2-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd2-pdf', name: 'q.pdf', size: 5 },
|
||||
]);
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d2-img', 'd2-pdf']);
|
||||
});
|
||||
|
||||
it('re-points uploads too: an upload expires once nothing references it', () => {
|
||||
let state = initialDraftPartBlobState();
|
||||
const sent = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'upload-1'), 'new.pdf', 3)];
|
||||
state = rebaseDraftPartBlobs(state, sent(), [
|
||||
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd1-new', name: 'new.pdf', size: 3 },
|
||||
]);
|
||||
expect(sent().map((a) => a.blobId)).toEqual(['d1-img', 'd1-new']);
|
||||
});
|
||||
|
||||
it('leaves the state alone when nothing was sent', () => {
|
||||
const state = initialDraftPartBlobState();
|
||||
expect(rebaseDraftPartBlobs(state, [], [{ blobId: 'p', name: 'a.pdf', size: 1 }])).toBe(state);
|
||||
});
|
||||
|
||||
it('keeps the old id when the read-back came back empty (next save fails loudly, nothing is lost)', () => {
|
||||
const state = initialDraftPartBlobState();
|
||||
const next = rebaseDraftPartBlobs(state, [inline('d0-img', 'img@x')], []);
|
||||
expect(liveBlobId(next, 'd0-img')).toBe('d0-img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSnapshotBody', () => {
|
||||
it('replaces only the body of a composer snapshot', () => {
|
||||
const snap = JSON.stringify({ to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>raw</p>', att: ['b1'] });
|
||||
expect(JSON.parse(withSnapshotBody(snap, '<p>normalised</p>'))).toEqual({
|
||||
to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>normalised</p>', att: ['b1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the key order, so an unchanged composer still compares equal', () => {
|
||||
const make = (body: string) => JSON.stringify({ to: [], cc: [], bcc: [], subject: 'S', body, att: [] });
|
||||
expect(withSnapshotBody(make('<p>raw</p>'), '<p>x</p>')).toBe(make('<p>x</p>'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Keep a re-opened draft's attachments pointing at blobs that still exist.
|
||||
//
|
||||
// A draft's attachments and inline images are MIME parts of that draft, and a
|
||||
// part's blobId lives only as long as the message that holds it: once a save
|
||||
// creates the next version and destroys the previous one, the server forgets
|
||||
// the old part blobs (Stalwart answers `blobNotFound`, "blobId … does not
|
||||
// exist on this server"). The composer was seeded with those part blobIds, so
|
||||
// without a re-point the second save of a re-opened draft - and any send
|
||||
// after its first save - failed (webmail #849, second half).
|
||||
//
|
||||
// Upload blobs are only a reprieve: the server keeps an unreferenced upload
|
||||
// for a limited time, so every attachment is re-pointed at the parts of the
|
||||
// version each save creates.
|
||||
|
||||
import type { Attachment } from '../api/types';
|
||||
import type { OutgoingAttachment } from '../api/email';
|
||||
import { stripMessageIdBrackets } from './email-threading';
|
||||
|
||||
const normCid = (cid?: string | null) => (cid ? stripMessageIdBrackets(cid).trim().toLowerCase() : '');
|
||||
|
||||
/**
|
||||
* Pair each sent attachment with the part of the newly created version that
|
||||
* carries the same content: inline parts by Content-ID, the rest by name and
|
||||
* size. Each new part is claimed at most once. Returns sent blobId → new blobId.
|
||||
*/
|
||||
export function matchDraftParts(
|
||||
sent: readonly OutgoingAttachment[],
|
||||
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const claimed = new Set<number>();
|
||||
const claim = (s: OutgoingAttachment, test: (p: (typeof fresh)[number]) => boolean) => {
|
||||
if (out.has(s.blobId)) return;
|
||||
const i = fresh.findIndex((p, idx) => !claimed.has(idx) && !!p.blobId && test(p));
|
||||
if (i === -1) return;
|
||||
claimed.add(i);
|
||||
out.set(s.blobId, fresh[i].blobId);
|
||||
};
|
||||
for (const s of sent) {
|
||||
const cid = normCid(s.cid);
|
||||
if (cid) claim(s, (p) => normCid(p.cid) === cid);
|
||||
}
|
||||
for (const s of sent) {
|
||||
claim(s, (p) => (p.name || '') === (s.name || '') && (s.size == null || p.size === s.size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DraftPartBlobState {
|
||||
/** blobId an attachment entry holds → blobId to send for it now. */
|
||||
live: Map<string, string>;
|
||||
}
|
||||
|
||||
export function initialDraftPartBlobState(): DraftPartBlobState {
|
||||
return { live: new Map() };
|
||||
}
|
||||
|
||||
/** The blobId to put on the wire for an entry that holds `blobId`. */
|
||||
export function liveBlobId(state: DraftPartBlobState, blobId: string): string {
|
||||
return state.live.get(blobId) ?? blobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a save: point every attachment that was sent at the matching part of
|
||||
* the version just created. Those parts live exactly as long as the saved
|
||||
* draft, so the next save or the send always references live blobs - whether
|
||||
* the entry came from the previous version (dies with it) or from an upload
|
||||
* (an unreferenced upload expires on the server). Blobs that could not be
|
||||
* matched keep their id: the next save then fails loudly (and, with
|
||||
* create-before-destroy, never loses the draft) instead of silently dropping
|
||||
* an attachment.
|
||||
*/
|
||||
export function rebaseDraftPartBlobs(
|
||||
state: DraftPartBlobState,
|
||||
sent: readonly OutgoingAttachment[],
|
||||
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
|
||||
): DraftPartBlobState {
|
||||
const pairs = matchDraftParts(sent, fresh);
|
||||
if (pairs.size === 0) return state;
|
||||
const live = new Map(state.live);
|
||||
const current = new Set(state.live.values());
|
||||
for (const [held, sentId] of state.live) {
|
||||
const next = pairs.get(sentId);
|
||||
if (next) live.set(held, next);
|
||||
}
|
||||
for (const [sentId, next] of pairs) {
|
||||
// An entry sent under its own blobId for the first time.
|
||||
if (!current.has(sentId)) live.set(sentId, next);
|
||||
}
|
||||
return { live };
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's dirty snapshot with its body replaced. Opening a draft whose
|
||||
* inline images get hydrated rewrites the editor body (normalised DOM HTML,
|
||||
* data URLs for `cid:` images); that rewrite is not an edit, so the baseline
|
||||
* has to follow it - comparing against the raw server HTML instead made every
|
||||
* re-opened draft with inline images look edited, and the autosave replaced
|
||||
* it (and destroyed the opened version) two seconds after opening.
|
||||
*/
|
||||
export function withSnapshotBody(snapshot: string, body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshot) as Record<string, unknown>;
|
||||
return JSON.stringify({ ...parsed, body });
|
||||
} catch {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import { toast } from '../stores/toast-store';
|
||||
import { type EmailTemplate } from '../stores/templates-store';
|
||||
import { getIdentities } from '../api/identity';
|
||||
import {
|
||||
sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
|
||||
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
|
||||
} from '../api/email';
|
||||
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
|
||||
import { uploadBlob, uploadBytes } from '../api/blob';
|
||||
@@ -64,6 +64,9 @@ import {
|
||||
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
||||
} from '../lib/reply-identity';
|
||||
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
|
||||
import {
|
||||
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
|
||||
} from '../lib/draft-part-blobs';
|
||||
import {
|
||||
signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
|
||||
insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature,
|
||||
@@ -677,6 +680,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// Track inline-image placeholders that haven't yet been rewritten to cid:
|
||||
// until send time. Maps cid → blobId/type/name/size.
|
||||
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
|
||||
// A re-opened draft's attachments and inline images are parts of that
|
||||
// draft: their blobIds die with each version a save replaces (#849), and an
|
||||
// upload expires once nothing references it. This maps the blobId each
|
||||
// entry holds to the matching part of the latest saved version, which
|
||||
// `buildOutgoing` sends. Kept out of the attachment state so the dirty
|
||||
// snapshot does not change on every save.
|
||||
const draftPartBlobsRef = React.useRef(initialDraftPartBlobState());
|
||||
|
||||
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
|
||||
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
|
||||
@@ -836,6 +846,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
}
|
||||
}
|
||||
if (cancelled || map.size === 0) return;
|
||||
// No change message since the editor loaded = the user has not typed
|
||||
// yet, so the editor still holds the seeded body (normalised by the
|
||||
// WebView DOM, which is why it can differ from the raw server HTML).
|
||||
const untouched = !plainTextMode && latestRef.current.bodyHtml === initialBodyHtml;
|
||||
let live = latestRef.current.bodyHtml;
|
||||
try {
|
||||
live = (await editorRef.current?.getHtml()) ?? live;
|
||||
@@ -846,8 +860,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
editorRef.current?.setHtml(next);
|
||||
}
|
||||
// The hydrated images are part of the untouched baseline, not an edit.
|
||||
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
|
||||
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
|
||||
if (untouched) {
|
||||
// Rebase on the body the composer now holds: comparing it with the
|
||||
// raw server HTML made every re-opened draft with inline images look
|
||||
// edited, and the autosave replaced (and destroyed) it unasked.
|
||||
baselineRef.current = withSnapshotBody(baselineRef.current, next);
|
||||
if (lastSavedRef.current) lastSavedRef.current = withSnapshotBody(lastSavedRef.current, next);
|
||||
} else {
|
||||
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
|
||||
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1229,7 +1251,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
.map((cid) => inlineRegistryRef.current.get(cid))
|
||||
.filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error)
|
||||
.map<OutgoingAttachment>((e) => ({
|
||||
blobId: e.blobId!,
|
||||
blobId: liveBlobId(draftPartBlobsRef.current, e.blobId!),
|
||||
type: e.type,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
@@ -1240,7 +1262,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const fileAttachments = attachments
|
||||
.filter((a) => !a.inline && a.blobId && !a.error)
|
||||
.map<OutgoingAttachment>((a) => ({
|
||||
blobId: a.blobId!,
|
||||
blobId: liveBlobId(draftPartBlobsRef.current, a.blobId!),
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
@@ -1300,8 +1322,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
setDraftStatus('saving');
|
||||
try {
|
||||
const outgoing = buildOutgoing(identity, html, { forDraft: true });
|
||||
const id = await createDraft(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
|
||||
const saved = await saveDraftVersion(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
|
||||
const id = saved.id;
|
||||
draftIdRef.current = id;
|
||||
// The previous version and its part blobs are gone: point every
|
||||
// attachment at the parts of the version just created.
|
||||
draftPartBlobsRef.current = rebaseDraftPartBlobs(
|
||||
draftPartBlobsRef.current, outgoing.attachments ?? [], saved.attachments,
|
||||
);
|
||||
lastSavedRef.current = snapshot;
|
||||
lastSaveAtRef.current = Date.now();
|
||||
setDraftStatus('saved');
|
||||
|
||||
Reference in New Issue
Block a user