Merge branch 'fx/compose2' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 05:03:02 +01:00
18 changed files with 679 additions and 73 deletions
+3
View File
@@ -768,8 +768,11 @@
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
"cancel_failed": "Cancel failed",
"cancel_to_drafts_body": "The message will not be sent. It goes back to your Drafts.",
"cancelled_not_moved": "Send cancelled, but not moved to Drafts",
"cancelled_not_moved_body": "The message will not be sent. It is still in Sent.",
"edit_failed": "Could not open the message for editing",
"keep_scheduled": "Keep scheduled",
"login_switched": "These scheduled messages belong to another account. Switch back to it to change them.",
"no_longer_scheduled": "This message is no longer scheduled.",
"reschedule_failed": "Reschedule failed",
"reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.",
+122 -1
View File
@@ -107,7 +107,7 @@ describe('rescheduleScheduledSend', () => {
expect(result).toEqual({ emailSubmissionId: 'sub-2', sendAt: '2026-09-25T08:00:00Z' });
const [lookup] = mockRequest.mock.calls[0][0];
expect(lookup).toEqual([
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'sendAt'] }, '0',
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'sendAt', 'identityId'] }, '0',
]);
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '7200' } },
@@ -218,6 +218,127 @@ describe('rescheduleScheduledSend', () => {
vi.mocked(Date.now).mockRestore();
});
describe('answers that never came', () => {
const status = (undoStatus: string) => ({
methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus }] }, '0']],
});
const records = (list: Array<Record<string, unknown>>) => ({
methodResponses: [
['EmailSubmission/query', { ids: list.map((s) => s.id) }, '0'],
['EmailSubmission/get', { list }, '1'],
],
});
const creates = () => setCalls().filter(([created]) => created.length > 0).length;
it('goes on to the replacement when the cancel went through but its answer was lost', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockRejectedValueOnce(new Error('Request timed out'))
.mockResolvedValueOnce(status('canceled'))
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
const result = await rescheduleScheduledSend(SCHEDULED, 0);
expect(result.emailSubmissionId).toBe('sub-2');
// The canceled record goes, as after a confirmed cancel.
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []]]);
});
it('still makes no replacement when the server refused the cancel and the original was cancelled elsewhere', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'invalidProperties' } } }))
.mockResolvedValueOnce(status('canceled'));
const err = await rescheduleScheduledSend(SCHEDULED, 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(creates()).toBe(0);
});
it('takes a replacement the server made for the answer that was lost, and does not put the original back', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockRejectedValueOnce(new Error('Request timed out'))
.mockResolvedValueOnce(records([
{ id: 'sub-1', emailId: 'e-1', undoStatus: 'canceled' },
{ id: 'sub-2', emailId: 'e-1', undoStatus: 'final', sendAt: '2026-09-25T08:00:00Z' },
]));
const result = await rescheduleScheduledSend(SCHEDULED, 0);
expect(result).toEqual({ emailSubmissionId: 'sub-2', sendAt: '2026-09-25T08:00:00Z' });
expect(creates()).toBe(1);
const [query] = mockRequest.mock.calls.at(-1)![0];
expect(query).toEqual(['EmailSubmission/query', { accountId: 'acc-1', filter: { emailIds: ['e-1'] } }, '0']);
});
it('puts the original back when the lost create left nothing on the server', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockRejectedValueOnce(new Error('Request timed out'))
.mockResolvedValueOnce(records([{ id: 'sub-1', emailId: 'e-1', undoStatus: 'canceled' }]))
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect(err).toMatchObject({ restoredSubmissionId: 'sub-3' });
});
it('counts a put-back the server made for the answer that was lost as restored, and leaves the message in Sent', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockRejectedValueOnce(new Error('Network request failed'))
.mockResolvedValueOnce(records([{ id: 'sub-3', emailId: 'e-1', undoStatus: 'pending', sendAt: ORIGINAL_AT }]));
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect(err).toMatchObject({ restoredSubmissionId: 'sub-3', restoredSendAt: ORIGINAL_AT });
expect(mockRequest.mock.calls.some((c) => c[0][0][0] === 'Email/set')).toBe(false);
});
});
// A group send's undo entry carried the login's own identity id, which
// names nothing in the group account: both creates were refused and the
// message went back to Drafts unsent.
it("submits the replacement and the put-back through the original's identity, not the caller's", async () => {
const lookup = structuredClone(LOOKUP);
(lookup.methodResponses[0][1] as { list: Array<Record<string, unknown>> }).list[0].identityId = 'grp-identity';
mockRequest
.mockResolvedValueOnce(lookup)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
await rescheduleScheduledSend({ ...SCHEDULED, identityId: 'own-identity', accountId: 'grp-1' }, 0).catch(() => undefined);
expect(createdArgs(0).create.replacement.identityId).toBe('grp-identity');
expect(createdArgs(1).create.replacement.identityId).toBe('grp-identity');
});
it("keeps the caller's identity when the server does not name one", async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 0);
expect(createdArgs().create.replacement.identityId).toBe('id-1');
});
it('moves the message back to Drafts when neither the replacement nor the original can be made', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRequest
+109 -3
View File
@@ -22,7 +22,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { JMAPClient } from '../jmap-client';
import {
destroyEmails, getFullEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, saveDraftVersion, sendEmail,
cancelScheduledSend, destroyEmails, getFullEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, saveDraftVersion, sendEmail,
} from '../email';
import { uploadBytes } from '../blob';
import { deleteFileNodes } from '../files';
@@ -69,6 +69,11 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
}, 60_000);
afterAll(async () => {
// A failed test may leave a send scheduled; it must not outlive its message.
const held = await listScheduledEmails().catch(() => []);
for (const s of held.filter((h) => createdEmails.includes(h.emailId))) {
await cancelScheduledSend(s.emailSubmissionId).catch(() => undefined);
}
const res = await singleton.request([['Email/query', { accountId: singleton.accountId, filter: { subject: tag } }, '0']]);
const leftovers = [...new Set([...(res.methodResponses[0][1].ids as string[] ?? []), ...createdEmails])];
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
@@ -105,8 +110,8 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
expect(filed.mailboxIds[sent]).toBe(true);
// Cancel send: nothing goes out, and the message is a draft again.
const inDrafts = await cancelScheduledToDrafts(second, await getMailboxes(), singleton.accountId);
expect(inDrafts).toBe(true);
const cancelled = await cancelScheduledToDrafts(second, await getMailboxes(), singleton.accountId);
expect(cancelled).toEqual({ inDrafts: true });
expect((await listScheduledEmails()).some((s) => s.emailId === result.emailId)).toBe(false);
const [restored] = await getFullEmails([result.emailId!]);
expect(restored.mailboxIds[drafts]).toBe(true);
@@ -115,6 +120,62 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
}, 90_000);
});
describe('scheduled sends whose answers get lost', () => {
/** Let the first call matching `pick` reach the server, then lose its answer. */
const loseAnswerOf = (pick: (method: string, args: Record<string, unknown>) => boolean) => {
const real = singleton.request.bind(singleton);
let lost = false;
return vi.spyOn(singleton, 'request').mockImplementation(async (calls, using) => {
const res = await real(calls, using);
const [method, args] = calls[0] as unknown as [string, Record<string, unknown>];
if (!lost && pick(method, args)) {
lost = true;
throw new Error('Request timed out');
}
return res;
});
};
const scheduleOne = async (label: string) => {
const [identity] = await getIdentities();
const result = await sendEmail(
{ from: [{ email: USER }], to: [{ email: USER }], subject: `${label} ${tag}`, textBody: 'live' },
identity.id, sent, 3600, { draftsMailboxId: drafts },
);
createdEmails.push(result.emailId!);
return (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!;
};
const pendingOf = async (emailId: string) => (await listScheduledEmails()).filter((s) => s.emailId === emailId);
it('makes the replacement when the cancel went through and its answer was lost', async () => {
const first = await scheduleOne('lost cancel');
const spy = loseAnswerOf((method, args) => method === 'EmailSubmission/set' && !!args.update);
let moved: Awaited<ReturnType<typeof rescheduleScheduledSend>>;
try {
moved = await rescheduleScheduledSend(first, 7200);
} finally {
spy.mockRestore();
}
const pending = await pendingOf(first.emailId);
expect(pending.map((s) => s.emailSubmissionId)).toEqual([moved.emailSubmissionId]);
await cancelScheduledToDrafts(pending[0], await getMailboxes(), singleton.accountId);
}, 90_000);
it('takes the replacement the server made when the answer to its create was lost, and schedules it once', async () => {
const first = await scheduleOne('lost create');
const spy = loseAnswerOf((method, args) => method === 'EmailSubmission/set' && !!args.create);
let moved: Awaited<ReturnType<typeof rescheduleScheduledSend>>;
try {
moved = await rescheduleScheduledSend(first, 7200);
} finally {
spy.mockRestore();
}
const pending = await pendingOf(first.emailId);
expect(pending.map((s) => s.emailSubmissionId)).toEqual([moved.emailSubmissionId]);
expect(new Date(pending[0].sendAt).getTime()).toBeGreaterThan(new Date(first.sendAt).getTime() + 3_000_000);
await cancelScheduledToDrafts(pending[0], await getMailboxes(), singleton.accountId);
}, 90_000);
});
describe('Files and mail share blobs', () => {
it('saves an attachment to Files and attaches a file from Files, by blob', async () => {
const upload = await uploadBytes(new TextEncoder().encode(`hello ${RUN}`), 'text/plain');
@@ -138,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'];
+70 -11
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client';
import {
assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, RecipientsRefusedError,
assertSetResult, batched, failedIdsOf, JMAPMethodError, JMAPSetError, NoLongerScheduledError, parseHoldLimit, RecipientsRefusedError,
requireMethodResult, ScheduleTooLateError, type RefusedRecipient,
} from './jmap-result';
import { keywordPointer, mailboxPointer } from './patch-pointer';
@@ -2151,7 +2151,8 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
// A SetError: the server answered and kept the submission as it was.
throw new JMAPSetError(failure.type ?? 'setError', failure.description ?? failure.type ?? 'Failed to cancel scheduled send');
}
// The canceled record is dead weight against Stalwart 1.0's submission
// limit. It goes in a request of its own, and only once the cancel is
@@ -2206,9 +2207,10 @@ export class RescheduleLostError extends Error {
* out already (NoLongerScheduledError). A replacement the server refuses, or
* takes for no recipient, puts the original back at its old time under a new
* id (RescheduleRestoredError); when that fails too the message goes back to
* Drafts (RescheduleLostError). Returns the new submission id and send time,
* and the recipients the server refused for the replacement when it took
* the others.
* Drafts (RescheduleLostError). A cancel or create whose answer was lost is
* checked against the server's records first. Returns the new submission id
* and send time, and the recipients the server refused for the replacement
* when it took the others.
*/
export async function rescheduleScheduledSend(
scheduled: {
@@ -2235,7 +2237,7 @@ export async function rescheduleScheduledSend(
const lookup = await jmapClient.request(
[
['EmailSubmission/get', {
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt'],
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt', 'identityId'],
}, '0'],
['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'],
['Mailbox/get', { accountId, ids: null, properties: ['id', 'role'] }, '2'],
@@ -2245,8 +2247,14 @@ export async function rescheduleScheduledSend(
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
sendAt?: string;
identityId?: string;
}> | undefined)?.[0];
const envelope = original?.envelope;
// The original's identity is one of the submission account's. The caller's
// may be the composer's, from the login's own account: for a group
// identity's message that id names nothing in the group account, and the
// replacement and the put-back were both refused (webmail parity).
const identityId = original?.identityId || scheduled.identityId;
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
@@ -2262,6 +2270,9 @@ export async function rescheduleScheduledSend(
const sentId = roles.find((m) => m.role === 'sent')?.id;
type Created = { id: string; sendAt?: string; delivery: { recipients: RefusedRecipient[]; all: boolean } };
// Creates that got no answer (timeout, dropped connection): the server
// may have taken them all the same.
const unanswered = new Set<unknown>();
// `until`: when the submission goes out (ms); undefined, at once.
const createSubmission = async (until: number | undefined): Promise<Created> => {
// HOLDFOR counts from when the server takes the request, so a retry
@@ -2276,7 +2287,7 @@ export async function rescheduleScheduledSend(
create: {
replacement: {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
identityId,
envelope: {
mailFrom: seconds === undefined
? { email: mailFrom }
@@ -2294,7 +2305,10 @@ export async function rescheduleScheduledSend(
deliveryStatusCall(accountId, 'replacement', '1'),
],
SUBMISSION_USING,
);
).catch((err: unknown) => {
unanswered.add(err);
throw err;
});
};
let res = await send();
// A full submission quota (Stalwart 1.0): make room and try once more.
@@ -2335,8 +2349,17 @@ export async function rescheduleScheduledSend(
await cancelScheduledSend(scheduled.emailSubmissionId, accountId);
} catch (err) {
const status = await submissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (status === 'final' || status === 'canceled') throw new NoLongerScheduledError(status === 'final');
throw err;
// A cancel the server answered and refused changed nothing, so a
// canceled original was cancelled elsewhere. One whose answer was lost
// (timeout, dropped connection) may have gone through: then the
// original is cancelled by this call and the replacement still has to
// be made, or the message sits in Sent unsent.
const refusedCancel = err instanceof JMAPMethodError;
if (status === 'final' || (status === 'canceled' && refusedCancel)) {
throw new NoLongerScheduledError(status === 'final');
}
if (status !== 'canceled') throw err;
await destroyEmailSubmissions([scheduled.emailSubmissionId], accountId);
}
let replacement: Created;
@@ -2349,6 +2372,13 @@ export async function rescheduleScheduledSend(
const refused = await withdrawIfDead(replacement);
if (refused) throw new RecipientsRefusedError(refused);
} catch (err) {
// A replacement whose answer was lost may have been taken: putting the
// original back as well would send the message twice. The server's
// record of it stands in for the answer.
if (unanswered.has(err)) {
const taken = await liveSubmissionOf(scheduled.emailId, accountId);
if (taken) return { emailSubmissionId: taken.id, sendAt: taken.sendAt };
}
// Put the original back at its old time: HOLDFOR with the seconds left,
// at once when that time has passed meanwhile.
const originalTime = original?.sendAt ? Date.parse(original.sendAt) : NaN;
@@ -2356,7 +2386,9 @@ export async function rescheduleScheduledSend(
try {
restored = await createSubmission(Number.isFinite(originalTime) && originalTime > Date.now() ? originalTime : undefined);
} catch (restoreErr) {
console.warn('[email] could not put the original scheduled send back:', restoreErr);
const taken = unanswered.has(restoreErr) ? await liveSubmissionOf(scheduled.emailId, accountId) : undefined;
if (taken) restored = { id: taken.id, sendAt: taken.sendAt, delivery: { recipients: [], all: false } };
else console.warn('[email] could not put the original scheduled send back:', restoreErr);
}
if (restored && !(await withdrawIfDead(restored).catch(() => null))) {
throw new RescheduleRestoredError(err, restored.id, restored.sendAt ?? original?.sendAt);
@@ -2395,6 +2427,33 @@ async function readSubmissionUndoStatus(emailSubmissionId: string, accountId: st
return list?.find((s) => s.id === emailSubmissionId)?.undoStatus;
}
/**
* A submission of `emailId` that is still held or went out, for a create
* whose answer was lost. Undefined when there is none or it can't be read.
*/
async function liveSubmissionOf(emailId: string, accountId: string): Promise<{ id: string; sendAt?: string } | undefined> {
try {
const res = await jmapClient.request(
[
['EmailSubmission/query', { accountId, filter: { emailIds: [emailId] } }, '0'],
['EmailSubmission/get', {
accountId,
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
properties: ['id', 'emailId', 'undoStatus', 'sendAt'],
}, '1'],
],
SUBMISSION_ONLY_USING,
);
const list = requireMethodResult(res, '1', 'EmailSubmission/get').list as
Array<{ id?: string; emailId?: string; undoStatus?: string; sendAt?: string }> | undefined;
// "Send now" goes out a second later: a final one is the replacement too.
const live = list?.find((s) => s.id && s.emailId === emailId && (s.undoStatus === 'pending' || s.undoStatus === 'final'));
return live?.id ? { id: live.id, sendAt: live.sendAt } : undefined;
} catch {
return undefined;
}
}
/** A submission's `undoStatus`, or undefined when it can't be read. */
async function submissionUndoStatus(emailSubmissionId: string, accountId: string): Promise<string | undefined> {
try {
+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,
+29 -22
View File
@@ -22,7 +22,7 @@ import { resolveReplyIdentity } from '../../lib/reply-identity';
import { signatureIdentityFor, signPlainTextReply } from '../../lib/signature-utils';
import { pickEmailBody, plainTextBody } from '../../lib/email-body';
import { htmlToPlainText } from '../../lib/compose-html';
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
import { sendTargetFor } from '../../lib/compose-send-account';
import { emailDisplayDate } from '../../lib/email-date';
import { buildQuoteHeader, quoteHeaderLabels } from '../../lib/quote-header';
@@ -66,16 +66,19 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
const from = email.from?.[0];
if (!from?.email || email.keywords?.$draft) return null;
const refuseAfterSwitch = () => {
if (!loginMovedFrom(ownerLoginRef.current)) return false;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t('email_viewer.quick_reply_login_switched', 'This message is in another account. Switch back to it to reply.'),
);
return true;
};
const send = async () => {
const body = text.trim();
if (!body || sending) return;
if (loginMovedFrom(ownerLoginRef.current)) {
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t('email_viewer.quick_reply_login_switched', 'This message is in another account. Switch back to it to reply.'),
);
return;
}
if (refuseAfterSwitch()) return;
const ownEmails = identities.map((i) => i.email).filter(Boolean);
// The own identity the message was delivered to (or, for our own message,
// the one that sent it). Never the catch-all From rewrite: this box has no
@@ -88,16 +91,20 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
Alert.alert(t('common.error', 'Error'), t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
return;
}
// Sent/Drafts of the account the reply is submitted from: the message's.
const scoped = mailboxesOfAccount(mailboxes, jmapAccountId);
const sent = scoped.find((m) => m.role === 'sent');
const drafts = scoped.find((m) => m.role === 'drafts');
if (!sent) {
Alert.alert(t('common.error', 'Error'), t('email_composer.no_sent_folder', 'No Sent folder found'));
return;
}
setSending(true);
try {
// The account, identity and folders the reply goes out through, as the
// composer picks them (#1090): a group address sends from the group's
// account with that account's identity id. The message's own account
// with the login's identity id named nothing there, or another
// identity.
const target = await sendTargetFor(identity, identity.email, mailboxes);
if (!target.sentMailboxId) {
Alert.alert(t('common.error', 'Error'), t('email_composer.no_sent_folder', 'No Sent folder found'));
return;
}
// The lookup may have outlasted a switch of logins.
if (refuseAfterSwitch()) return;
const recipients = buildReplyRecipients(
{ from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc },
'reply',
@@ -116,10 +123,10 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
labels: quoteHeaderLabels(t),
});
const threading = computeReplyThreadingHeaders(email);
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, target.accountId);
// Where the reply goes, read before it goes: Undo must reach that login
// and account whatever the app switches to meanwhile.
const sentThrough = { login: currentSendLogin(), accountId: jmapAccountId ?? jmapClient.connectedAccountId ?? undefined };
const sentThrough = { login: currentSendLogin(), accountId: target.accountId ?? jmapClient.connectedAccountId ?? undefined };
const result = await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
@@ -135,15 +142,15 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
inReplyTo: threading?.inReplyTo,
references: threading?.references,
},
identity.id,
sent.originalId ?? sent.id,
target.identityId,
target.sentMailboxId,
holdFor,
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
{ draftsMailboxId: target.draftsMailboxId, accountId: target.accountId },
);
// Held for the undo-send delay: the undo bar offers Undo / Send now.
// Recorded before the flag below so its round trip doesn't eat the window.
useSendUndoStore.getState().recordHeldSend(result, holdFor, {
identityId: identity.id,
identityId: target.identityId,
...sentThrough,
from: [{ name: identity.name, email: identity.email }],
});
+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');
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { inlineImageType } from '../compose-inline-type';
describe('inlineImageType', () => {
it('keeps a plain image type, without its parameters', () => {
expect(inlineImageType('image/jpeg', null)).toBe('image/jpeg');
expect(inlineImageType('Image/JPEG; name="a.jpg"', null)).toBe('image/jpeg');
expect(inlineImageType('image/svg+xml', 'image/png')).toBe('image/svg+xml');
});
it('drops a hostile declared type for the sniffed one', () => {
expect(inlineImageType('image/x"><meta http-equiv="refresh">', 'image/gif')).toBe('image/gif');
expect(inlineImageType('image/png"onload="x', null)).toBe('image/png');
expect(inlineImageType('image/', 'image/webp')).toBe('image/webp');
});
it('falls back to the sniffed type, then PNG, for a part that is not labelled an image', () => {
expect(inlineImageType('application/octet-stream', 'image/jpeg')).toBe('image/jpeg');
expect(inlineImageType(undefined, null)).toBe('image/png');
});
// The composer took the part's type as it came for the data URI, the
// re-upload and the part sent with the reply.
it('is what the composer uses for a hydrated inline image', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
expect(source).toMatch(/const mime = inlineImageType\(att\.type, sniffImageMime\(bytes\)\);/);
expect(source).not.toMatch(/startsWith\('image\/'\) \? att\.type/);
});
});
+11 -2
View File
@@ -36,7 +36,7 @@ beforeEach(() => vi.clearAllMocks());
describe('Cancel send of a scheduled message', () => {
it('cancels the submission and moves an own message back into the own Drafts', async () => {
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).resolves.toBe(true);
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).resolves.toEqual({ inDrafts: true });
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'own');
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', 'own');
});
@@ -57,10 +57,19 @@ describe('Cancel send of a scheduled message', () => {
it('says so when the account has no Drafts folder', async () => {
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await expect(cancelScheduledToDrafts(item, [mailbox('sent', 'own-sent')], 'own')).resolves.toBe(false);
await expect(cancelScheduledToDrafts(item, [mailbox('sent', 'own-sent')], 'own')).resolves.toEqual({ inDrafts: false });
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
});
// The cancel went through, so it must not read as "Cancel failed" with the
// row still listed as scheduled.
it('reports a cancelled send it could not move back to Drafts, without failing', async () => {
const moveError = new Error('Network request failed');
api.restoreEmailToDraft.mockRejectedValueOnce(moveError);
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).resolves.toEqual({ inDrafts: false, moveError });
});
it('names the own account as no override', () => {
expect(scheduledAccountOverride({ accountId: 'own' }, 'own')).toBeUndefined();
expect(scheduledAccountOverride({ accountId: 'team' }, 'own')).toBe('team');
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// A group or shared identity sends from the group's account with that
// account's identity id (lib/compose-send-account). Both places below used
// the login's own identity id, which names nothing in the group account:
// "Send now" from the undo bar lost the message, and a quick reply was
// refused or went out through another identity.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, rel), 'utf8');
describe('send target wiring', () => {
it("records the target's identity for the undo bar of a composer send", () => {
const source = read('../../screens/ComposeScreen.tsx');
const held = source.slice(source.indexOf('recordHeldSend(result, holdForSeconds'));
expect(held).toMatch(/^recordHeldSend\(result, holdForSeconds, \{[\s\S]*?identityId: target\.identityId,/);
expect(held.slice(0, 300)).not.toContain('primaryIdentity.id');
});
it('sends a quick reply through the target the composer would pick', () => {
const source = read('../../components/email/QuickReplyBox.tsx');
expect(source).toMatch(/const target = await sendTargetFor\(identity, identity\.email, mailboxes\)/);
const call = source.slice(source.indexOf('await sendEmail('));
expect(call).toMatch(/\},\s+target\.identityId,\s+target\.sentMailboxId,/);
expect(call).toMatch(/accountId: target\.accountId \}/);
expect(source).toMatch(/recordHeldSend\(result, holdFor, \{\s*identityId: target\.identityId,/);
expect(source).not.toMatch(/identity\.id,\s+sent\./);
});
});
@@ -181,4 +181,14 @@ describe('unfilledPlaceholdersInMessage', () => {
subject: '{{subject}}', body, plainTextMode: true, signature, words: EN,
})).toEqual(['{{subject}}', '[TIME]']);
});
// The plain text of a link is "text <href>": a URL's {{id}} or [TODO]
// stopped the send though the sender never sees it.
it('leaves link targets out and keeps the link text', () => {
const body = '<p>See <a href="https://example.com/wiki/[TODO]?u={{id}}">the [DRAFT] page</a>'
+ " and <a class=x href='https://example.com/TODO'>this</a>.</p>";
expect(unfilledPlaceholdersInMessage({
subject: 'Hello', body, plainTextMode: false, signature, words: EN,
})).toEqual(['[DRAFT]']);
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// The Scheduled screen is a component the node test environment can't
// render; these check its wiring to the helpers tested elsewhere.
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ScheduledScreen.tsx'), 'utf8');
const body = (name: string) => {
const start = source.indexOf(`const ${name} = `);
expect(start).toBeGreaterThan(-1);
return source.slice(start, source.indexOf('\n };', start));
};
describe('Scheduled screen', () => {
// A cancel that went through, with the move to Drafts failing after it,
// read "Cancel failed" and left the row listed as scheduled.
it('drops the row of a send cancelled but not moved to Drafts, and says so', () => {
for (const name of ['onCancel', 'onEdit']) {
const handler = body(name);
expect(handler).toMatch(/\{ (inDrafts, )?moveError \} = await cancelScheduledToDrafts\(/);
const handled = handler.slice(handler.indexOf('cancelScheduledToDrafts('), handler.indexOf('scheduled.cancelled_not_moved\''));
expect(handled).toMatch(/removeItem\(item\);/);
expect(handled).toMatch(/if \(moveError\) \{/);
}
});
// The list loaded once, on mount: after a deep-link switch of logins it
// still showed the first login's rows, and Cancel or Send now acted on the
// new login's submissions with the same ids.
it('remembers the login the rows were read for and acts on them only while it is active', () => {
const load = source.slice(source.indexOf('const load = React.useCallback('));
expect(load).toMatch(/^const load = React\.useCallback\(async \(\) => \{[\s\S]*?const owner = servedLoginOf\(jmapClient\);[\s\S]*?await listScheduledEmails\(\);[\s\S]*?if \(loginMovedFrom\(owner\)\) return;\s+ownerRef\.current = owner;\s+setItems\(list\);/);
expect(body('runAction')).toMatch(/^const runAction = async [^\n]*\{\s+if \(refuseOtherLogin\(\)\) return;/);
expect(source).toMatch(/const openView = React\.useCallback\(\(item: ScheduledEmail\) => \{\s+if \(refuseOtherLogin\(\)\) return;/);
expect(source).toMatch(/if \(!loginMovedFrom\(ownerRef\.current\)\) return false;/);
});
it('reads the list again for every session and drops the rows of another login', () => {
expect(source).toMatch(/if \(ownerRef\.current !== null && loginMovedFrom\(ownerRef\.current\)\) \{[\s\S]*?setItems\(\[\]\);[\s\S]*?void load\(\);\s+\}, \[activeAccountId, session, load\]\);/);
});
it('closes the view of a message that is no longer listed', () => {
expect(source).toMatch(/if \(viewing && !items\.some\(\(i\) => sameScheduledSend\(i, viewing\)\)\) closeView\(\);\s+\}, \[items, viewing, closeView\]\);/);
});
});
+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);
}
+17
View File
@@ -0,0 +1,17 @@
// The media type of a quoted original's inline image, as the composer shows
// it (a data: URI) and attaches it again on send. The server hands the part's
// Content-Type over verbatim, parameters, quotes and `>` included, so only a
// plain `image/<subtype>` is taken from it (the rule of lib/email-html's
// inline image check); otherwise the bytes decide.
const PLAIN_IMAGE_TYPE = /^image\/[a-z0-9.+-]+$/;
/**
* `declared` without parameters when it is a plain image type ("image/jpeg;
* name=x" is "image/jpeg"), else `sniffed`, else image/png.
*/
export function inlineImageType(declared: string | null | undefined, sniffed: string | null | undefined): string {
const essence = (declared ?? '').toLowerCase().split(';')[0].trim();
if (PLAIN_IMAGE_TYPE.test(essence)) return essence;
return sniffed ?? 'image/png';
}
+28 -12
View File
@@ -12,26 +12,42 @@ export function scheduledAccountOverride(item: Pick<ScheduledEmail, 'accountId'>
return item.accountId !== ownAccountId ? item.accountId : undefined;
}
/** Where a cancelled scheduled message is now. */
export interface CancelledToDrafts {
/** Back in Drafts. False: still in Sent, but no longer scheduled. */
inDrafts: boolean;
/**
* Why the move to Drafts failed after the cancel went through. The send is
* cancelled all the same: the row goes, and the user hears it stayed in
* Sent rather than "Cancel failed".
*/
moveError?: unknown;
}
/**
* Cancel the scheduled send and move the message back into Drafts. Resolves
* with whether it is in Drafts now: false when the account has no Drafts
* folder, where it stays in Sent. A failed cancel throws and moves nothing.
* Cancel the scheduled send and move the message back into Drafts. It stays
* in Sent when the account has no Drafts folder or the move fails (see
* CancelledToDrafts). A failed cancel throws and moves nothing.
*/
export async function cancelScheduledToDrafts(
item: Pick<ScheduledEmail, 'accountId' | 'emailSubmissionId' | 'emailId'>,
mailboxes: Mailbox[],
ownAccountId: string,
): Promise<boolean> {
): Promise<CancelledToDrafts> {
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
const scope = mailboxesOfAccount(mailboxes, scheduledAccountOverride(item, ownAccountId));
const drafts = scope.find((m) => m.role === 'drafts');
const sent = scope.find((m) => m.role === 'sent');
if (!drafts) return false;
await restoreEmailToDraft(
item.emailId,
drafts.originalId ?? drafts.id,
sent ? (sent.originalId ?? sent.id) : undefined,
item.accountId,
);
return true;
if (!drafts) return { inDrafts: false };
try {
await restoreEmailToDraft(
item.emailId,
drafts.originalId ?? drafts.id,
sent ? (sent.originalId ?? sent.id) : undefined,
item.accountId,
);
} catch (moveError) {
return { inDrafts: false, moveError };
}
return { inDrafts: true };
}
+7 -1
View File
@@ -143,6 +143,9 @@ export function placeholderWordsFor(locale: string): PlaceholderWords[] {
return lists;
}
/** The href of an `<a>` tag, with the tag up to it in $1. */
const LINK_HREF = /(<a\b[^>]*?)\shref\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
/**
* The unfilled placeholders of a message as the composer holds it: the body
* without its signature (a placeholder there would stop every message) and
@@ -161,7 +164,10 @@ export function unfilledPlaceholdersInMessage(message: {
const signature = getPlainTextSignature(message.signature ?? undefined);
authored = signature ? message.body.split(signature).join('') : message.body;
} else {
authored = stripEmbeddedSignature(message.body);
// Without the link targets: the plain text writes a link as
// "text <href>", and a URL is no fill-in the sender left ("{{id}}" in a
// tracking link, "[TODO]" in a wiki path).
authored = stripEmbeddedSignature(message.body).replace(LINK_HREF, '$1');
}
const bodyText = extractUserAuthoredText(authored, {
plainTextMode: message.plainTextMode,
+7 -2
View File
@@ -68,6 +68,7 @@ import {
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check';
import { sendTargetFor } from '../lib/compose-send-account';
import { inlineImageType } from '../lib/compose-inline-type';
import { attachmentFromFileNode } from '../lib/compose-files';
import { supportsFiles } from '../api/files';
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
@@ -862,7 +863,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
try {
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type, seedOwnerAccountId);
const bytes = new Uint8Array(buf);
const mime = att.type?.toLowerCase().startsWith('image/') ? att.type : (sniffImageMime(bytes) ?? 'image/png');
// Never the part's type as it came: it goes into the data URI and
// into the part sent with the reply.
const mime = inlineImageType(att.type, sniffImageMime(bytes));
map.set(cid, `data:${mime};base64,${bytesToBase64(bytes)}`);
let blobId = att.blobId;
if (seedOwnerAccountId) blobId = (await uploadBytesForLogin(ownerLoginRef.current, bytes, mime)).blobId;
@@ -2407,7 +2410,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
} else if (result.scheduled) {
// Undo-send window: the undo bar offers Undo / Send now.
useSendUndoStore.getState().recordHeldSend(result, holdForSeconds, {
identityId: primaryIdentity.id,
// The identity the message went out through, as the submission
// account names it: "Send now" submits again with it.
identityId: target.identityId,
accountId: submissionAccountId,
login: ownerLoginRef.current,
from: outgoing.from,
+69 -7
View File
@@ -30,6 +30,9 @@ import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/sched
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
import { cancelScheduledToDrafts, scheduledAccountOverride } from '../lib/compose-scheduled';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { loginMovedFrom } from '../lib/compose-login';
import { servedLoginOf } from '../lib/draft-login';
import { useAuthStore } from '../stores/auth-store';
import { ScheduledMessageView } from '../components/compose/ScheduledMessageView';
import type { Email } from '../api/types';
@@ -56,7 +59,23 @@ export default function ScheduledScreen({ navigation }: Props) {
const [viewEmail, setViewEmail] = React.useState<Email | null>(null);
const [viewError, setViewError] = React.useState<string | null>(null);
const viewSeq = React.useRef(0);
// The login the rows were read for. Submission and email ids repeat across
// logins: after a switch (a notification or link for another account) the
// rows on screen are the old login's, and acting on them would cancel or
// send whatever the new one has under the same ids.
const ownerRef = React.useRef<string | null>(null);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const session = useAuthStore((s) => s.session);
const refuseOtherLogin = React.useCallback(() => {
if (!loginMovedFrom(ownerRef.current)) return false;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t('scheduled.login_switched', 'These scheduled messages belong to another account. Switch back to it to change them.'),
);
return true;
}, [t]);
const openView = React.useCallback((item: ScheduledEmail) => {
if (refuseOtherLogin()) return;
const seq = ++viewSeq.current;
setViewing(item);
setViewEmail(null);
@@ -65,7 +84,7 @@ export default function ScheduledScreen({ navigation }: Props) {
(email) => { if (viewSeq.current === seq) setViewEmail(email); },
(e: unknown) => { if (viewSeq.current === seq) setViewError(e instanceof Error ? e.message : String(e)); },
);
}, []);
}, [refuseOtherLogin]);
const closeView = React.useCallback(() => {
viewSeq.current += 1;
setViewing(null);
@@ -87,23 +106,49 @@ export default function ScheduledScreen({ navigation }: Props) {
return extra > 0 ? `${name} +${extra}` : name;
};
// Only the latest read is shown: one started before a switch of logins may
// finish after the next one.
const loadSeq = React.useRef(0);
const load = React.useCallback(async () => {
const seq = ++loadSeq.current;
const owner = servedLoginOf(jmapClient);
setLoading(true);
setError(null);
try {
setItems(await listScheduledEmails());
const list = await listScheduledEmails();
if (seq !== loadSeq.current) return;
// Switched during the read: the next session reads again.
if (loginMovedFrom(owner)) return;
ownerRef.current = owner;
setItems(list);
} catch (e) {
setError(e instanceof Error ? e.message : t('email_list.error', 'Error'));
if (seq === loadSeq.current) setError(e instanceof Error ? e.message : t('email_list.error', 'Error'));
} finally {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
}, [t]);
// Read on mount and again for every session; rows, the open view and the
// menus of another login go at once.
React.useEffect(() => {
if (ownerRef.current !== null && loginMovedFrom(ownerRef.current)) {
ownerRef.current = null;
setItems([]);
setActionsFor(null);
setRescheduleFor(null);
setCustomStage(null);
}
void load();
}, [load]);
}, [activeAccountId, session, load]);
// A message that is gone from the list (sent, cancelled, read again after
// a switch) closes its view.
React.useEffect(() => {
if (viewing && !items.some((i) => sameScheduledSend(i, viewing))) closeView();
}, [items, viewing, closeView]);
const runAction = async (item: ScheduledEmail, action: () => Promise<void>, failTitle: string) => {
if (refuseOtherLogin()) return;
setBusyId(scheduledSendKey(item));
try {
await action();
@@ -194,9 +239,17 @@ export default function ScheduledScreen({ navigation }: Props) {
style: 'destructive',
onPress: () => {
void runAction(item, async () => {
const inDrafts = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
const { inDrafts, moveError } = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
clearUndoFor(item);
removeItem(item);
if (moveError) {
// Not scheduled any more either way: it just stayed in Sent.
toast.warning(
t('scheduled.cancelled_not_moved', 'Send cancelled, but not moved to Drafts'),
{ message: t('scheduled.cancelled_not_moved_body', 'The message will not be sent. It is still in Sent.') },
);
return;
}
toast.success(
t('scheduled.send_cancelled', 'Send cancelled'),
inDrafts
@@ -252,8 +305,17 @@ export default function ScheduledScreen({ navigation }: Props) {
const onEdit = (item: ScheduledEmail) => {
void runAction(item, async () => {
// A message held in a shared account goes back to that account's Drafts.
await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
const { moveError } = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
clearUndoFor(item);
if (moveError) {
// Opening it in the composer would edit the copy in Sent.
removeItem(item);
toast.warning(
t('scheduled.cancelled_not_moved', 'Send cancelled, but not moved to Drafts'),
{ message: t('scheduled.cancelled_not_moved_body', 'The message will not be sent. It is still in Sent.') },
);
return;
}
const email = await getFullEmail(item.emailId, item.accountId);
removeItem(item);
navigation.replace('Compose', { draft: draftContextFromEmail(email, scheduledAccountOverride(item, jmapClient.accountId)) });