fix: cancel the original before scheduling its replacement on reschedule and send now, and put it back when that fails

This commit is contained in:
Linus Rath
2026-10-29 03:20:32 +01:00
parent 3eac700aa2
commit 22f2ac5b29
10 changed files with 561 additions and 433 deletions
+2
View File
@@ -697,6 +697,8 @@
"keep_scheduled": "Keep scheduled",
"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.",
"reschedule_lost_drafts": "The message is no longer scheduled. It is back in Drafts, where you can send it again.",
"send_now_failed": "Could not send now"
},
"settings": {
@@ -40,7 +40,7 @@ vi.mock('../blob', () => ({
import { jmapClient } from '../jmap-client';
import {
cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, sendReadReceipt,
cancelScheduledSend, listScheduledEmails, RescheduleLostError, rescheduleScheduledSend, sendEmail, sendReadReceipt,
} from '../email';
import { NoLongerScheduledError, SubmissionQuotaError } from '../jmap-result';
import { prunableSubmissionIds, pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
@@ -456,7 +456,9 @@ describe('rescheduling and "Send now"', () => {
expect(argsOf(server, 'EmailSubmission/set').map((a) => a.destroy).filter(Boolean)).toEqual([[original]]);
});
it('Stalwart 1.0: a full quota makes room without touching the pending original', async () => {
// The original is cancelled and its record removed first (#1308), so the
// replacement takes the room the original had.
it('Stalwart 1.0: a full account has room for the replacement once the original is cancelled', async () => {
const server = setup('1.0', { limit: 3 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
@@ -464,24 +466,42 @@ describe('rescheduling and "Send now"', () => {
const result = await rescheduleScheduledSend(scheduled(original), 2 * 3600);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(2);
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions().filter((s) => s.undoStatus === 'pending')).toEqual([
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
]);
expect(server.submissions().some((s) => s.id === original)).toBe(false);
});
it('Stalwart 1.0: still full after making room, the original stays scheduled', async () => {
const server = setup('1.0', { limit: 1 });
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
it('Stalwart 1.0: an original that went out before the cancel is not sent a second time', async () => {
const server = setup('1.0');
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + 30_000, status: 'pending', emailId: 'mail-1' }]);
// Its HOLDFOR runs out while the lookup is under way.
server.afterEachRequest(() => server.deliver('acc-1', original));
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(SubmissionQuotaError);
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.create)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]);
});
/** A full account: the original, due soon, and two delivered records the prune can remove. */
function fullWithOriginal(dueIn: number) {
const server = setup('1.0', { limit: 3 });
it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => {
const server = setup('1.0');
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
server.afterEachRequest(() => server.cancel('acc-1', original));
const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: false });
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.create)).toBe(false);
});
/** An account at its limit once the original is gone: two delivered records the prune can remove. */
function fullAfterCancel(dueIn: number) {
const server = setup('1.0', { limit: 2 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + dueIn, status: 'pending', emailId: 'mail-1' }]);
server.seed('acc-1', [{ sendAt: now - HOUR, status: 'final' }, { sendAt: now - 2 * HOUR, status: 'final' }]);
@@ -489,59 +509,10 @@ describe('rescheduling and "Send now"', () => {
}
const isQuery = (calls: Array<[string, unknown, string]>) => calls[0][0] === 'EmailSubmission/query';
it('Stalwart 1.0: an original that went out while room was made is not sent a second time', async () => {
const { server, original } = fullWithOriginal(30_000);
// Its HOLDFOR runs out while the prune looks for records to remove.
server.afterEachRequest((calls) => {
if (isQuery(calls)) server.deliver('acc-1', original);
});
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
// The original was read again after the prune, and only the refused create was made.
expect(server.requests.at(-1)?.calls).toEqual([
['EmailSubmission/get', { accountId: 'acc-1', ids: [original], properties: ['id', 'undoStatus'] }, '0'],
]);
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'final' }]);
});
it('Stalwart 1.0: an original cancelled on another device meanwhile is left as it is', async () => {
const { server, original } = fullWithOriginal(HOUR);
server.afterEachRequest((calls) => {
if (isQuery(calls)) server.cancel('acc-1', original);
});
const err = await rescheduleScheduledSend(scheduled(original), 2 * 3600).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: false });
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'canceled' }]);
});
it('Stalwart 1.0: when the original cannot be read again, nothing is resubmitted', async () => {
const { server, original } = fullWithOriginal(HOUR);
server.beforeEachRequest((calls) => {
const [name, args] = calls[0];
if (name === 'EmailSubmission/get' && (args as { properties?: string[] }).properties?.join() === 'id,undoStatus') {
throw new Error('Network request failed');
}
});
await expect(rescheduleScheduledSend(scheduled(original), 2 * 3600)).rejects.toThrow('Network request failed');
expect(argsOf(server, 'EmailSubmission/set').filter((a) => a.create)).toHaveLength(1);
expect(server.submissions()).toEqual([{ id: original, sendAt: expect.any(Number), undoStatus: 'pending' }]);
});
it('Stalwart 1.0: the retry takes the time spent making room off the hold', async () => {
let clock = Date.parse('2026-09-25T12:00:00Z');
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const { server, original } = fullWithOriginal(HOUR);
const { server, original } = fullAfterCancel(HOUR);
const target = clock + 2 * HOUR;
// The prune takes five minutes.
server.afterEachRequest((calls) => {
@@ -561,7 +532,7 @@ describe('rescheduling and "Send now"', () => {
it('Stalwart 1.0: "Send now" that waited for room still goes out at once', async () => {
let clock = Date.parse('2026-09-25T12:00:00Z');
vi.spyOn(Date, 'now').mockImplementation(() => clock);
const { server, original } = fullWithOriginal(HOUR);
const { server, original } = fullAfterCancel(HOUR);
server.afterEachRequest((calls) => {
if (isQuery(calls)) clock += 5 * MINUTE;
});
@@ -573,6 +544,22 @@ describe('rescheduling and "Send now"', () => {
.map((a) => a.create.replacement.envelope.mailFrom.parameters.HOLDFOR);
expect(holds).toEqual(['1', '1']);
});
it('Stalwart 1.0: still full after making room, the original goes back to its old time', async () => {
const server = setup('1.0', { limit: 2 });
const now = Date.now();
const [original] = server.seed('acc-1', [{ sendAt: now + HOUR, status: 'pending', emailId: 'mail-1' }]);
// Two other held messages: nothing the prune may remove.
server.seed('acc-1', [{ sendAt: now + 2 * HOUR, status: 'pending' }, { sendAt: now + 3 * HOUR, status: 'pending' }]);
const err = await rescheduleScheduledSend(scheduled(original), 0).catch((e: unknown) => e);
// Neither the replacement nor the original found room: the message is
// back in Drafts rather than in Sent looking sent.
expect(err).toBeInstanceOf(RescheduleLostError);
expect((err as RescheduleLostError).reason).toBeInstanceOf(SubmissionQuotaError);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { drafts: true }, keywords: { $draft: true } });
});
});
describe('read receipts', () => {
@@ -36,7 +36,9 @@ vi.mock('../jmap-client', () => ({
}));
import { jmapClient } from '../jmap-client';
import { listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
import {
listScheduledEmails, RescheduleLostError, RescheduleRestoredError, rescheduleScheduledSend, sendEmail,
} from '../email';
import { RecipientsRefusedError, jmapErrorType } from '../jmap-result';
import { pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
import {
@@ -263,20 +265,24 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
const cancelled = (server: FakeSubmissionServer) => argsOf(server, 'EmailSubmission/set')
.filter((a) => a.update)
.flatMap((a) => Object.keys(a.update as object));
/** The replacements the server created, in order. */
const replacements = (server: FakeSubmissionServer) => server.requests
.flatMap(({ responses }) => responses)
.filter(([name, body]) => name === 'EmailSubmission/set' && body.created?.replacement)
.map(([, body]) => body.created.replacement.id as string);
beforeEach(() => {
// "Send now" holds the replacement for a second: the clock stands still.
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-26T10:00:00Z'));
});
// The original is cancelled before the replacement is made (#1308). A
// replacement that takes nobody puts the original back; when the throttle
// refuses that one too, the message goes back to Drafts and says so.
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.23', 7200],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: keeps the original when every recipient of the replacement is refused (hold %is)',
'Stalwart %s: moves the message back to Drafts when every recipient is refused again (hold %is)',
async (version, holdFor) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const server = setup(version);
const scheduled = scheduledTo(server, 'bob@example.org');
// A throttle used up since the message was scheduled.
@@ -284,13 +290,32 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RecipientsRefusedError);
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
// Still goes at its old time; no cancel went out, and the dead
// replacement's record is gone.
expect(argsOf(server, 'EmailSubmission/set').some((a) => a.update)).toBe(false);
expect(err).toBeInstanceOf(RescheduleLostError);
const reason = (err as RescheduleLostError).reason;
expect(reason).toBeInstanceOf(RecipientsRefusedError);
expect((reason as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { drafts: true }, keywords: { $draft: true } });
// The dead records are gone, and nothing is pending.
expect(server.submissions()).toEqual([]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: puts the original back at its old time when the replacement took nobody but the original is taken again',
async (version) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'bob@example.org');
// Refused once, for the replacement; the throttle has room again for the original.
let refusals = 1;
server.refuseRecipients(() => (refusals-- > 0 ? RATE_LIMITED : undefined));
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleRestoredError);
const restored = err as RescheduleRestoredError;
expect(restored.reason).toBeInstanceOf(RecipientsRefusedError);
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
{ id: restored.restoredSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
@@ -305,7 +330,9 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
expect(create?.calls[1]).toEqual(
['EmailSubmission/get', { accountId: 'acc-1', ids: ['#replacement'], properties: ['id', 'deliveryStatus'] }, '1'],
);
expect(create?.using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
expect(create?.using).toEqual([
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission',
]);
});
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
@@ -324,141 +351,51 @@ describe('"Send now" and "Reschedule" when the server refuses the recipients aga
},
);
it('leaves the refused replacement to the prune on a server without urn:stalwart:jmap', async () => {
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.6', 7200],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: goes ahead and names the recipient the replacement was refused while it took the others (hold %is)',
async (version, holdFor) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
const result = await rescheduleScheduledSend(scheduled, holdFor);
// The original is gone before the replacement is made, so the caller
// warns that bob does not get it.
expect(result.refusedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(cancelled(server)).toEqual([scheduled.emailSubmissionId]);
expect(server.submissions()).toEqual([
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
]);
},
);
it('leaves the dead records to the prune on a server without urn:stalwart:jmap', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
fake.stalwart = false;
const server = setup('1.0');
const scheduled = scheduledTo(server, 'bob@example.org');
server.refuseRecipients(() => RATE_LIMITED);
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RecipientsRefusedError);
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RescheduleLostError);
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['pending', 'final']);
// The replacement's and the put-back original's, both final with nobody taken.
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['final', 'final']);
});
// The replacement takes the others: cancelling the original would lose
// the message for the refused one, whom the original still reaches.
describe('when the replacement is refused a recipient the original was taken for', () => {
beforeEach(() => {
// "Send now" holds the replacement for a second, so the clock stands
// still for the withdraw to find it held.
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-26T10:00:00Z'));
});
it('files the replacement in Sent, for servers that pull a cancelled message back to Drafts', async () => {
const server = setup('1.0');
const scheduled = scheduledTo(server, 'bob@example.org');
server.emails.set('mail-1', { ...server.emails.get('mail-1')!, mailboxIds: { drafts: true }, keywords: { $draft: true } });
it.each([
['1.0', 0],
['1.0', 7200],
['0.16.23', 0],
['0.16.23', 7200],
['0.16.6', 0],
] as Array<[StalwartVersion, number]>)(
'Stalwart %s: keeps the original at its old time and withdraws the replacement (hold %is)',
async (version, holdFor) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
await rescheduleScheduledSend(scheduled, 7200);
const err = await rescheduleScheduledSend(scheduled, holdFor).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RecipientsRefusedError);
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
// Bob still gets it at the old time, and alice only once: the
// replacement she was taken for is cancelled, not just its record.
expect(cancelled(server)).toEqual(replacements(server));
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: names only whom the replacement would have lost when it took nobody',
async (version) => {
const server = setup(version, { refuseRecipient: relayOff });
const scheduled = scheduledTo(server, 'alice@example.org', 'nobody@example.net');
server.refuseRecipients((email) => relayOff(email) ?? throttledFor('alice@example.org')(email));
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// nobody@example.net never got it: the relay rule refused it back then.
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'alice@example.org', smtpReply: RATE_LIMITED }]);
// Nothing was queued for the replacement, so only its record goes.
expect(cancelled(server)).toEqual([]);
expect(server.submissions()).toEqual([
{ id: scheduled.emailSubmissionId, sendAt: Date.now() + HOUR, undoStatus: 'pending' },
]);
},
);
it('Stalwart 1.0 shapes: counts every recipient as taken when the server keeps no delivery status for the original', async () => {
const server = setup('1.0', { refuseRecipient: relayOff });
const scheduled = scheduledTo(server, 'alice@example.org', 'nobody@example.net');
vi.mocked(jmapClient.request).mockImplementationOnce(async (calls, using) => {
const res = await server.request(calls, using);
return {
methodResponses: res.methodResponses.map(([name, body, callId]) => (name === 'EmailSubmission/get'
? [name, { ...body, list: body.list.map((s: object) => ({ ...s, deliveryStatus: null })) }, callId]
: [name, body, callId])) as Array<[string, Record<string, any>, string]>,
};
});
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// Nothing says the relay rule refused nobody@example.net back then.
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
expect(cancelled(server)).toEqual(replacements(server));
expect(server.submissions().map((s) => [s.id, s.undoStatus])).toEqual([[scheduled.emailSubmissionId, 'pending']]);
});
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: goes ahead and names the refused recipient when "Send now" went out before it could be withdrawn',
async (version) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
// Its second of hold is over before the withdraw arrives.
server.afterEachRequest(() => {
for (const id of replacements(server)) server.deliver('acc-1', id);
});
const result = await rescheduleScheduledSend(scheduled, 0);
// Alice has it from the replacement: keeping the original as well
// would send her a second copy.
expect(result.refusedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
expect(cancelled(server)).toEqual([result.emailSubmissionId, scheduled.emailSubmissionId]);
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'final' }]);
},
);
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
'Stalwart %s: keeps the original when the withdraw went through and only its answer was lost',
async (version) => {
const server = setup(version);
const scheduled = scheduledTo(server, 'alice@example.org', 'bob@example.org');
server.refuseRecipients(throttledFor('bob@example.org'));
let answered = false;
server.afterEachRequest((calls) => {
const [name, args] = calls[0];
if (name === 'EmailSubmission/set' && (args as { update?: object }).update && !answered) {
answered = true;
throw new Error('Network request failed');
}
});
const err = await rescheduleScheduledSend(scheduled, 7200).catch((e: unknown) => e);
// Going ahead would cancel the original too: nobody would get it.
expect(err).toBeInstanceOf(RecipientsRefusedError);
const [replacement] = replacements(server);
expect(cancelled(server)).toEqual([replacement]);
// The canceled record is left to the prune.
expect(server.submissions().map((s) => [s.id, s.undoStatus])).toEqual([
[scheduled.emailSubmissionId, 'pending'],
[replacement, 'canceled'],
]);
},
);
expect(server.emails.get('mail-1')).toMatchObject({ mailboxIds: { sent: true }, keywords: {} });
});
});
+103 -81
View File
@@ -18,9 +18,10 @@ vi.mock('../jmap-client', () => ({
import { jmapClient } from '../jmap-client';
import {
cancelScheduledSend, listScheduledEmails, rescheduleScheduledSend, sendEmail, SendUnconfirmedError,
cancelScheduledSend, listScheduledEmails, RescheduleLostError, RescheduleRestoredError, rescheduleScheduledSend,
sendEmail, SendUnconfirmedError,
} from '../email';
import { ScheduleTooLateError } from '../jmap-result';
import { NoLongerScheduledError, ScheduleTooLateError } from '../jmap-result';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -36,12 +37,18 @@ const SCHEDULED = {
to: [{ email: 'to@example.com' }],
};
const ORIGINAL_AT = new Date(Date.now() + 3600_000).toISOString();
const LOOKUP = {
methodResponses: [
['EmailSubmission/get', {
list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }],
list: [{
id: 'sub-1',
sendAt: ORIGINAL_AT,
envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] },
}],
}, '0'],
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }] }] }, '1'],
['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }, { id: 'mb-in', role: 'inbox' }] }, '2'],
],
};
const REPLACED = [['EmailSubmission/set', {
@@ -60,8 +67,17 @@ const setCalls = () => mockRequest.mock.calls
Object.keys(args.update ?? {}),
args.destroy ?? [],
]);
/** The replacement create of the n-th EmailSubmission/set create call. */
const createdArgs = (n = 0) => mockRequest.mock.calls
.map((c) => c[0][0])
.filter(([method, args]: [string, { create?: object }]) => method === 'EmailSubmission/set' && args.create)[n][1];
const REFUSED = (description: string) => set({ notCreated: { replacement: { type: 'forbiddenToSend', description } } });
describe('rescheduleScheduledSend', () => {
afterEach(() => {
mockRequest.mockReset();
});
it('keeps Cc, Bcc and the envelope sender of the held submission (B19)', async () => {
mockRequest
.mockResolvedValueOnce({
@@ -82,20 +98,18 @@ describe('rescheduleScheduledSend', () => {
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }], cc: [{ email: 'cc@example.com' }] }] }, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
expect(result).toEqual({ emailSubmissionId: 'sub-2', sendAt: '2026-09-25T08:00:00Z' });
// What the server answered for each recipient comes with the envelope.
const [lookup] = mockRequest.mock.calls[0][0];
expect(lookup).toEqual([
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'deliveryStatus'] }, '0',
'EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope', 'sendAt'] }, '0',
]);
const args = mockRequest.mock.calls[1][0][0][1];
expect(args.create.replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '7200' } },
rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }],
});
@@ -116,104 +130,112 @@ describe('rescheduleScheduledSend', () => {
}, '1'],
],
})
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 60);
expect(mockRequest.mock.calls[1][0][0][1].create.replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'news@shop.example', parameters: { HOLDFOR: '60' } },
rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }],
});
});
it('creates the replacement before cancelling the original, then removes the canceled record', async () => {
// #1308: some servers refuse a second active submission for one Email, and
// some undo onSuccessUpdateEmail on cancel, pulling the message back out of
// Sent once the original went after its replacement.
it('cancels the original and removes its record before it creates the replacement, which files the message in Sent', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 7200);
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
expect(mockRequest.mock.calls[2][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []]]);
expect(mockRequest.mock.calls[1][0][0][1].update).toEqual({ 'sub-1': { undoStatus: 'canceled' } });
expect(createdArgs().onSuccessUpdateEmail).toEqual({
'#replacement': { mailboxIds: { 'mb-sent': true }, 'keywords/$draft': null },
});
});
it('sends now as a 1-second hold, so the replacement can still be withdrawn', async () => {
it('sends now as a 1-second hold', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'));
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({ methodResponses: REPLACED });
await rescheduleScheduledSend(SCHEDULED, 0);
const replacement = mockRequest.mock.calls[1][0][0][1].create.replacement;
expect(replacement.envelope).toEqual({
expect(createdArgs().create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '1' } },
rcptTo: [{ email: 'to@example.com' }],
});
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []], [[], [], ['sub-1']]]);
});
it('leaves the original scheduled when the replacement is refused', async () => {
it('changes nothing when the original has already gone out', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ notCreated: { replacement: { type: 'forbiddenToSend', description: 'Quota exceeded' } } }));
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Quota exceeded');
// No cancel went out.
expect(setCalls()).toEqual([[['replacement'], [], []]]);
});
it('withdraws the replacement when the original has already gone out', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend', description: 'Already sent' } } }))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
.mockResolvedValueOnce(set({ updated: { 'sub-2': null } }))
.mockResolvedValueOnce(DESTROYED('sub-2'));
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] });
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule: Already sent');
// The original went out, so its record stays for the prune; the
// withdrawn replacement's goes at once.
expect(setCalls()).toEqual([
[['replacement'], [], []], [[], ['sub-1'], []], [[], ['sub-2'], []], [[], [], ['sub-2']],
]);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
const err = await rescheduleScheduledSend(SCHEDULED, 0).catch((e: unknown) => e);
expect(err).toBeInstanceOf(NoLongerScheduledError);
expect(err).toMatchObject({ sent: true });
// No replacement: it would send the message a second time.
expect(setCalls()).toEqual([[[], ['sub-1'], []]]);
});
it('still reports the failure when the replacement cannot be withdrawn either', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
it('changes nothing and reports the failure when the cancel fails while the original is still due', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-1': { type: 'cannotUnsend' } } }))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'final' }] }, '0']] })
.mockResolvedValueOnce(set({ notUpdated: { 'sub-2': { type: 'cannotUnsend' } } }));
await expect(rescheduleScheduledSend(SCHEDULED, 0)).rejects.toThrow('Failed to cancel the previous schedule');
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});
it('keeps the replacement when a cancel whose answer got lost did go through', async () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce({ methodResponses: REPLACED })
.mockRejectedValueOnce(new Error('Network request failed'))
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'canceled' }] }, '0']] });
.mockResolvedValueOnce({ methodResponses: [['EmailSubmission/get', { list: [{ id: 'sub-1', undoStatus: 'pending' }] }, '0']] });
const result = await rescheduleScheduledSend(SCHEDULED, 7200);
await expect(rescheduleScheduledSend(SCHEDULED, 7200)).rejects.toThrow('Network request failed');
expect(setCalls()).toEqual([[[], ['sub-1'], []]]);
});
expect(result.emailSubmissionId).toBe('sub-2');
// Unconfirmed, so the original's record is left for the prune.
expect(setCalls()).toEqual([[['replacement'], [], []], [[], ['sub-1'], []]]);
it('puts the original back at its old time, under a new id, when the replacement is refused', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(ORIGINAL_AT) - 1800_000);
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.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', restoredSendAt: ORIGINAL_AT, message: 'Quota exceeded' });
// Half an hour was left of the original hold.
expect(createdArgs(1).create.replacement.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '1800' });
vi.mocked(Date.now).mockRestore();
});
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
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce(REFUSED('Quota exceeded'))
.mockResolvedValueOnce({ methodResponses: [['Email/set', { updated: { 'e-1': null } }, '0']] });
const err = await rescheduleScheduledSend(SCHEDULED, 7200).catch((e: unknown) => e);
expect(err).toBeInstanceOf(RescheduleLostError);
expect(err).toMatchObject({ inDrafts: true });
const [, restore] = mockRequest.mock.calls.at(-1)![0][0];
expect(restore.update).toEqual({
'e-1': { 'mailboxIds/mb-drafts': true, 'keywords/$draft': true, 'keywords/$seen': true, 'mailboxIds/mb-sent': null },
});
});
});
@@ -392,24 +414,24 @@ describe('hold-limit rejections', () => {
expect(jmapClient.learnHoldLimit).not.toHaveBeenCalled();
});
it('turns a refused reschedule into ScheduleTooLateError', async () => {
it('turns a refused reschedule into ScheduleTooLateError, with the original put back', async () => {
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['EmailSubmission/get', { list: [{ id: 'sub-1', envelope: { mailFrom: { email: 'me@example.com' }, rcptTo: [{ email: 'to@example.com' }] } }] }, '0'],
['Email/get', { list: [] }, '1'],
],
})
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(CANCELLED)
.mockResolvedValueOnce(DESTROYED('sub-1'))
.mockResolvedValueOnce({
methodResponses: [['EmailSubmission/set', { notCreated: { replacement: HOLD_REJECTED } }, '0']],
});
})
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-3' } } }));
const err = await rescheduleScheduledSend(SCHEDULED, 5 * 24 * 3600).catch((e) => e);
expect(err).toBeInstanceOf(ScheduleTooLateError);
expect(err).toBeInstanceOf(RescheduleRestoredError);
expect((err as RescheduleRestoredError).reason).toBeInstanceOf(ScheduleTooLateError);
expect(jmapClient.learnHoldLimit).toHaveBeenCalledWith(172_800);
// The original stays scheduled.
expect(setCalls()).toEqual([[['replacement'], [], []]]);
// The original goes at its old time again, under a new id.
expect(setCalls()).toEqual([[[], ['sub-1'], []], [[], [], ['sub-1']], [['replacement'], [], []], [['replacement'], [], []]]);
mockRequest.mockReset();
});
});
@@ -491,9 +513,9 @@ describe('scheduled sends in shared accounts (webmail #874)', () => {
mockRequest
.mockResolvedValueOnce(LOOKUP)
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }))
.mockResolvedValueOnce(set({ updated: { 's-grp': null } }))
.mockResolvedValueOnce(DESTROYED('s-grp'));
.mockResolvedValueOnce(DESTROYED('s-grp'))
.mockResolvedValueOnce(set({ created: { replacement: { id: 's-new', sendAt: FUTURE } } }));
await rescheduleScheduledSend({ ...SCHEDULED, emailSubmissionId: 's-grp', accountId: 'grp-1' }, 0);
expect(mockRequest.mock.calls.slice(2).map((c) => c[0][0][1].accountId)).toEqual(['grp-1', 'grp-1', 'grp-1', 'grp-1']);
});
@@ -389,7 +389,27 @@ export function createFakeSubmissionServer(options: {
if (emails.delete(id)) (body.destroyed ??= []).push(id);
else (body.notDestroyed ??= {})[id] = { type: 'notFound' };
}
// Pointer patches of mailboxes and keywords (a message back to Drafts).
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const email = emails.get(id);
if (!email) {
(body.notUpdated ??= {})[id] = { type: 'notFound' };
continue;
}
for (const [path, value] of Object.entries(patch)) {
const [field, key] = path.split('/');
const target = field === 'mailboxIds' ? email.mailboxIds : field === 'keywords' ? email.keywords : null;
if (!target || !key) continue;
if (value === null) delete target[key];
else target[key] = true;
}
(body.updated ??= {})[id] = null;
}
responses.push(['Email/set', body, callId]);
} else if (name === 'Mailbox/get') {
responses.push(['Mailbox/get', {
accountId, state: 'm1', list: [{ id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }], notFound: [],
}, callId]);
} else if (name === 'Email/import') {
const body: Record<string, any> = { accountId };
for (const [creationId, item] of Object.entries(args.emails as Record<string, Record<string, any>>)) {
+170 -126
View File
@@ -2144,21 +2144,53 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv
}
}
/**
* A reschedule or "Send now" whose replacement the server refused, after the
* original had been cancelled: the original was put back at its old time
* under a new submission id, which the caller now holds instead (webmail
* RescheduleRestoredError). `reason` is why the replacement failed.
*/
export class RescheduleRestoredError extends Error {
constructor(
readonly reason: unknown,
readonly restoredSubmissionId: string,
readonly restoredSendAt?: string,
) {
super(reason instanceof Error ? reason.message : String(reason));
this.name = 'RescheduleRestoredError';
}
}
/**
* A reschedule or "Send now" that failed and could not put the original back
* either: the message is no longer scheduled. It was moved back to Drafts
* (`inDrafts`) so it does not sit in Sent looking sent. `reason` is why the
* replacement failed.
*/
export class RescheduleLostError extends Error {
constructor(readonly reason: unknown, readonly inDrafts: boolean) {
super(`Reschedule failed and the message is no longer scheduled: ${reason instanceof Error ? reason.message : String(reason)}`);
this.name = 'RescheduleLostError';
}
}
/**
* Change when a scheduled message goes out, or send it now with
* `holdForSeconds` = 0. A replacement submission for the same Email, with the
* held envelope and a fresh HOLDFOR, is created first, and the original is
* cancelled only once the replacement exists (webmail parity). Both used to
* go in one /set, whose create and update the server applies independently:
* a refused replacement still cancelled the original, so the message never
* went out, and a refused cancel (the original had just gone out) still let
* the replacement go, so it was sent twice. Now a refused replacement leaves
* the original alone, and a replacement whose original can't be cancelled is
* withdrawn again. Returns the new submission id and send time, and the
* recipients the server refused for the replacement when it took the others.
* When it refused the replacement a recipient the original had been taken
* for, the original is kept at its old time, and RecipientsRefusedError
* names whom the replacement would have lost.
* `holdForSeconds` = 0. The original is cancelled first and a replacement
* for the same Email, with the held envelope and a fresh HOLDFOR, created
* after it (#1308, webmail parity). The other order failed on servers that
* allow one active submission per Email and refuse the second create, and on
* servers that undo onSuccessUpdateEmail on cancel, which pulled the message
* back to Drafts once the original was cancelled after the replacement
* existed; the replacement now files it in Sent itself.
*
* A cancel that fails changes nothing: the original still goes out, or went
* 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.
*/
export async function rescheduleScheduledSend(
scheduled: {
@@ -2174,151 +2206,163 @@ export async function rescheduleScheduledSend(
recipients?: EmailAddress[],
): Promise<{ emailSubmissionId?: string; sendAt?: string; refusedRecipients?: RefusedRecipient[] }> {
const accountId = scheduled.accountId ?? jmapClient.accountId;
// "Send now" is a 1-second hold, so the replacement can still be withdrawn.
// "Send now" is a 1-second hold, as in the webmail.
const holdFor = Math.max(1, Math.ceil(holdForSeconds));
// Reuse the held submission's envelope: it names every recipient,
// Cc and Bcc included, and the envelope sender a catch-all From went
// out through. Rebuilding it from the Email's To dropped Cc and Bcc.
// The Email's To/Cc/Bcc are the fallback when the server has none. What
// the server answered for each recipient when it took the original comes
// along: whom a refusal of the replacement costs the message depends on it.
// The Email's To/Cc/Bcc are the fallback when the server has none. The
// account's Drafts and Sent come along for filing the replacement and for
// a message that ends up not scheduled at all.
const lookup = await jmapClient.request(
[
['EmailSubmission/get', {
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'deliveryStatus'],
accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope', 'sendAt'],
}, '0'],
['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'],
['Mailbox/get', { accountId, ids: null, properties: ['id', 'role'] }, '2'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
);
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
deliveryStatus?: Record<string, { delivered?: string } | null> | null;
sendAt?: string;
}> | undefined)?.[0];
const envelope = original?.envelope;
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);
const rcpt = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const create = {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: {
email: envelope?.mailFrom?.email || scheduled.from?.[0]?.email,
parameters: { HOLDFOR: String(holdFor) },
},
rcptTo: rcpt,
},
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
// Without an answer the replacement is made without filing, and a message
// that can't be put back stays where it is.
const [mailboxAnswer, mailboxBody] = lookup.methodResponses.find(([, , callId]) => callId === '2') ?? [];
const roles = mailboxAnswer === 'Mailbox/get'
? ((mailboxBody?.list ?? []) as Array<{ id: string; role?: string | null }>)
: [];
const draftsId = roles.find((m) => m.role === 'drafts')?.id;
const sentId = roles.find((m) => m.role === 'sent')?.id;
type Created = { id: string; sendAt?: string; delivery: { recipients: RefusedRecipient[]; all: boolean } };
// `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
// after making room takes the time spent off it.
const holdSeconds = () => (until === undefined ? undefined : Math.max(1, Math.ceil((until - Date.now()) / 1000)));
const send = () => {
const seconds = holdSeconds();
return jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
emailId: scheduled.emailId,
identityId: scheduled.identityId,
envelope: {
mailFrom: seconds === undefined
? { email: mailFrom }
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
rcptTo,
},
},
},
...(draftsId && sentId ? {
onSuccessUpdateEmail: {
'#replacement': { mailboxIds: { [sentId]: true }, [keywordPointer('$draft')]: null },
},
} : {}),
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
SUBMISSION_USING,
);
};
let res = await send();
// A full submission quota (Stalwart 1.0): make room and try once more.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
res = await send();
}
// Only the submission call counts: the implied Email/set of
// onSuccessUpdateEmail can fail while the submission was created.
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
return {
id: created.id,
sendAt: created.sendAt,
delivery: refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1')),
};
};
// A submission that took no recipient goes nowhere; its record only counts
// against the limit. The recipients it refused, or null when it took some.
const withdrawIfDead = async (created: Created): Promise<RefusedRecipient[] | null> => {
if (!created.delivery.all) return null;
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId);
return created.delivery.recipients;
};
// HOLDFOR counts from when the server takes the request.
const sendAt = Date.now() + holdFor * 1000;
const createReplacement = (seconds: number) => jmapClient.request(
[
['EmailSubmission/set', {
accountId,
create: {
replacement: {
...create,
envelope: { ...create.envelope, mailFrom: { ...create.envelope.mailFrom, parameters: { HOLDFOR: String(seconds) } } },
},
},
}, '0'],
deliveryStatusCall(accountId, 'replacement', '1'),
],
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
);
let res = await createReplacement(holdFor);
// A full submission quota (Stalwart 1.0): make room and try once more. The
// original is still pending, so making room never touches it.
if (submissionCreateError(res, 'replacement')?.type === 'overQuota') {
await makeRoomForSubmission(accountId);
// Making room can take a while - a prune, a walk through the change log -
// and meanwhile the original may have gone out: its time came, or it was
// "Send now" of a message due any moment. A replacement would then send
// it a second time. A read that fails throws, and nothing changes.
const originalStatus = await readSubmissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (originalStatus !== 'pending') throw new NoLongerScheduledError(originalStatus === 'final');
// The time spent is taken off the hold; a moment already past goes out
// at once.
res = await createReplacement(Math.max(1, Math.ceil((sendAt - Date.now()) / 1000)));
}
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
const notCreated = body.notCreated?.replacement as { description?: string; type?: string } | undefined;
if (notCreated) throw submissionError(notCreated, 'Failed to reschedule', accountId);
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
if (!created?.id) throw new Error('Failed to reschedule: the server returned no submission');
const holdUntil = Date.now() + holdFor * 1000;
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken ("452 4.4.5 Rate limit exceeded")
// can refuse some or all of them. Cancelling the original would lose the
// message for a recipient it took and the replacement didn't, so the
// original is kept then - it still goes at its old time to everyone it
// took - and the replacement is withdrawn. A recipient the original was
// refused for as well loses nothing, so the reschedule goes ahead; without
// a delivery status of the original, every recipient counts as taken.
// Should the replacement not be withdrawn, it goes ahead too, and the
// caller names the refused recipients: keeping both would send the others
// two copies (webmail aa99f038).
const delivery = refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1'));
const refusedBefore = new Set(Object.entries(original?.deliveryStatus ?? {})
.filter(([, status]) => status?.delivered === 'no')
.map(([address]) => address.toLowerCase()));
const lost = delivery.recipients.filter((recipient) => !refusedBefore.has(recipient.email.toLowerCase()));
if (lost.length > 0 && await withdrawReplacement(created.id, accountId, delivery.all)) {
throw new RecipientsRefusedError(lost);
}
// Either cancel below also removes the record it canceled, so a reschedule
// leaves only the replacement behind.
// Cancel first. Should it fail, nothing has changed and the original still
// goes out - unless it has gone out already ("Send now" of a message due
// any moment), or was cancelled on another device. Either cancel also
// removes the record it canceled.
try {
await cancelScheduledSend(scheduled.emailSubmissionId, accountId);
} catch (err) {
// A cancel whose answer got lost may still have gone through. Otherwise
// the original is still due or has just gone out, and the replacement
// must not go as well.
if ((await submissionUndoStatus(scheduled.emailSubmissionId, accountId)) !== 'canceled') {
try {
await cancelScheduledSend(created.id, accountId);
} catch (withdrawErr) {
console.warn('[email] could not withdraw the replacement scheduled send:', withdrawErr);
}
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to cancel the previous schedule: ${reason}`);
const status = await submissionUndoStatus(scheduled.emailSubmissionId, accountId);
if (status === 'final' || status === 'canceled') throw new NoLongerScheduledError(status === 'final');
throw err;
}
let replacement: Created;
try {
replacement = await createSubmission(holdUntil);
// The recipients are checked again for the replacement, and a throttle
// used up since the original was taken can refuse some or all of them.
// When it took nobody the reschedule failed; when it took some, the
// caller names the refused ones.
const refused = await withdrawIfDead(replacement);
if (refused) throw new RecipientsRefusedError(refused);
} catch (err) {
// 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;
let restored: Created | undefined;
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);
}
if (restored && !(await withdrawIfDead(restored).catch(() => null))) {
throw new RescheduleRestoredError(err, restored.id, restored.sendAt ?? original?.sendAt);
}
// Neither went through: the message is not scheduled any more. It goes
// back to Drafts rather than sit in Sent looking sent.
let inDrafts = false;
if (draftsId) {
try {
await restoreEmailToDraft(scheduled.emailId, draftsId, sentId, accountId);
inDrafts = true;
} catch (draftErr) {
console.warn('[email] could not move the unscheduled message back to Drafts:', draftErr);
}
}
throw new RescheduleLostError(err, inDrafts);
}
return {
emailSubmissionId: created.id,
sendAt: created.sendAt,
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
emailSubmissionId: replacement.id,
sendAt: replacement.sendAt,
...(replacement.delivery.recipients.length > 0 ? { refusedRecipients: replacement.delivery.recipients } : {}),
};
}
/**
* Withdraw the replacement of a reschedule that keeps its original instead;
* true once it can no longer go out. A replacement the server took for
* nobody (`tookNobody`) holds no mail, so only its record goes, on Stalwart
* (elsewhere the prune takes it once it is final). Any other is cancelled,
* which "Send now" may be too late for: its replacement is held for a second
* only. A cancel that failed counts when a read shows it went through with
* only its answer lost.
*/
async function withdrawReplacement(replacementId: string, accountId: string, tookNobody: boolean): Promise<boolean> {
if (tookNobody) {
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([replacementId], accountId);
return true;
}
try {
await cancelScheduledSend(replacementId, accountId);
return true;
} catch (err) {
if ((await submissionUndoStatus(replacementId, accountId)) === 'canceled') return true;
console.warn('[email] could not withdraw a replacement some recipients were refused for:', err);
return false;
}
}
/**
* A submission's `undoStatus`; undefined when the server no longer has the
* record. Throws when it can't be read.
+57
View File
@@ -0,0 +1,57 @@
// What a failed "Send now" or reschedule leaves behind, and how to say so.
// The original is cancelled before its replacement is made (#1308), so a
// failure can end three ways: nothing changed (the cancel failed), the
// original was put back under a new submission id (RescheduleRestoredError),
// or the message is no longer scheduled and back in Drafts
// (RescheduleLostError). The undo bar and the Scheduled list both follow
// the message to its new id, or drop it.
import { RescheduleLostError, RescheduleRestoredError } from '../api/email';
import { ScheduleTooLateError } from '../api/jmap-result';
import type { TranslateFn } from '../stores/locale-store';
import { rescheduleFailureNotice } from './send-failure-message';
/** The submission a failed reschedule put back, which now stands for the message; null when none did. */
export function restoredSubmission(err: unknown): { emailSubmissionId: string; sendAt?: string } | null {
return err instanceof RescheduleRestoredError
? { emailSubmissionId: err.restoredSubmissionId, sendAt: err.restoredSendAt }
: null;
}
/** Whether the message is not scheduled any more after the failure. */
export function rescheduleLostMessage(err: unknown): boolean {
return err instanceof RescheduleLostError;
}
/**
* The alert or toast for a failed reschedule. `formatWhen` formats an ISO
* time for "still scheduled for …"; `scheduledAt` is the time it was
* scheduled for before.
*/
export function rescheduleErrorNotice(
err: unknown,
t: TranslateFn,
failTitle: string,
formatWhen?: (iso: string) => string,
scheduledAt?: string,
): { title: string; message: string } {
if (err instanceof RescheduleLostError) {
return {
title: failTitle,
message: err.inDrafts
? t('scheduled.reschedule_lost_drafts', 'The message is no longer scheduled. It is back in Drafts, where you can send it again.')
: t('scheduled.reschedule_lost', 'The message is no longer scheduled. Check Drafts and Sent before sending it again.'),
};
}
if (err instanceof RescheduleRestoredError) {
const when = err.restoredSendAt ?? scheduledAt;
if (err.reason instanceof ScheduleTooLateError) {
return {
title: t('email_composer.schedule_too_late_title', 'Too far ahead'),
message: t('email_composer.schedule_too_late_body', 'That is later than this server allows. Pick an earlier time.'),
};
}
return rescheduleFailureNotice(err.reason, t, failTitle, when && formatWhen ? formatWhen(when) : undefined);
}
return rescheduleFailureNotice(err, t, failTitle, scheduledAt && formatWhen ? formatWhen(scheduledAt) : undefined);
}
+21
View File
@@ -30,6 +30,7 @@ import { spacing, typography, componentSizes, radius, type ThemePalette } from '
import { useColors } from '../theme/colors';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
@@ -84,6 +85,26 @@ export default function ScheduledScreen({ navigation }: Props) {
try {
await action();
} catch (e) {
// A failed reschedule or "Send now" put the original back under a new
// id, or could not, and the message is back in Drafts (#1308).
const restored = restoredSubmission(e);
if (restored || rescheduleLostMessage(e)) {
if (restored) {
setItems((prev) => prev.map((i) => (sameScheduledSend(i, item)
? { ...i, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? i.sendAt }
: i)));
const pending = useSendUndoStore.getState().pending;
if (isPendingSend(pending, item, jmapClient.accountId)) {
useSendUndoStore.getState().setPending({ ...pending!, emailSubmissionId: restored.emailSubmissionId });
}
} else {
clearUndoFor(item);
removeItem(item);
}
const { title, message } = rescheduleErrorNotice(e, t, failTitle, formatWhen, item.sendAt);
Alert.alert(title, message);
return;
}
if (e instanceof ScheduleTooLateError) {
Alert.alert(
t('email_composer.schedule_too_late_title', 'Too far ahead'),
+44 -18
View File
@@ -1,11 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../api/email', () => ({
cancelScheduledSend: vi.fn(async () => undefined),
rescheduleScheduledSend: vi.fn(async () => ({ emailSubmissionId: 'sub-2' })),
restoreEmailToDraft: vi.fn(async () => undefined),
getFullEmail: vi.fn(),
}));
// The error classes are the real ones: the store tells them apart.
vi.mock('../../api/email', async (importOriginal) => {
const { RescheduleLostError, RescheduleRestoredError } = await importOriginal<typeof import('../../api/email')>();
return {
RescheduleLostError,
RescheduleRestoredError,
cancelScheduledSend: vi.fn(async () => undefined),
rescheduleScheduledSend: vi.fn(async () => ({ emailSubmissionId: 'sub-2' })),
restoreEmailToDraft: vi.fn(async () => undefined),
getFullEmail: vi.fn(),
};
});
// The login the client serves: me@example.com on mail.example.com, whose
// own JMAP account is 'primary'. Tests switch it to another server.
@@ -38,6 +44,7 @@ vi.mock('../../api/submissions', () => ({
}));
import * as emailApi from '../../api/email';
import { RescheduleLostError, RescheduleRestoredError } from '../../api/email';
import * as submissions from '../../api/submissions';
import { NoLongerScheduledError, RecipientsRefusedError, SubmissionQuotaError } from '../../api/jmap-result';
import type { Email, Mailbox } from '../../api/types';
@@ -221,12 +228,15 @@ describe('send-undo-store', () => {
expect(prune).toHaveBeenCalledTimes(2);
});
it('explains a send now refused for a full submission quota (Stalwart 1.0)', async () => {
// The original is cancelled before the release is made (#1308): when the
// release is refused, the original is put back under a new id, which
// Undo cancels from then on.
it('explains a send now refused for a full submission quota (Stalwart 1.0), and follows the put-back original', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new SubmissionQuotaError(
api.rescheduleScheduledSend.mockRejectedValueOnce(new RescheduleRestoredError(new SubmissionQuotaError(
'There are too many email submissions, please delete some before adding a new one.',
true,
));
), 'sub-9', '2026-09-24T10:00:10Z'));
useSendUndoStore.getState().setPending(entry());
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
@@ -236,11 +246,13 @@ describe('send-undo-store', () => {
title: 'Could not send now',
message: expect.stringContaining('ask your administrator to raise the email submission limit'),
}]);
// Still held: it goes out when the delay ends.
expect(useSendUndoStore.getState().pending).not.toBeNull();
// Still held: it goes out when the delay ends, and Undo cancels the put-back one.
expect(useSendUndoStore.getState().pending).toMatchObject({ emailSubmissionId: 'sub-9', emailId: 'mail-1' });
await useSendUndoStore.getState().undo();
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-9', undefined);
});
it('drops the entry when the message went out while send now made room (Stalwart 1.0)', async () => {
it('drops the entry when the message went out before send now could cancel it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new NoLongerScheduledError(true));
useSendUndoStore.getState().setPending(entry());
@@ -257,15 +269,14 @@ describe('send-undo-store', () => {
});
// A throttle used up since the send was taken (Stalwart 0.16 and 1.0 check
// the recipients again for the release): the original was kept, and a
// the recipients again for the release): the original was put back, and a
// "not sent" would invite sending it a second time.
it('says the held message is still scheduled when the server refused a recipient of the release', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new RecipientsRefusedError([
api.rescheduleScheduledSend.mockRejectedValueOnce(new RescheduleRestoredError(new RecipientsRefusedError([
{ email: 'you@example.com', smtpReply: '452 4.4.5 Rate limit exceeded, try again later.' },
]));
const pending = entry();
useSendUndoStore.getState().setPending(pending);
]), 'sub-9'));
useSendUndoStore.getState().setPending(entry());
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
@@ -275,7 +286,22 @@ describe('send-undo-store', () => {
message: 'Nothing was changed: the server refused you@example.com (452 4.4.5 Rate limit exceeded, try again later.).',
}]);
// It still goes out when the delay ends, and can still be undone.
expect(useSendUndoStore.getState()).toMatchObject({ pending, busy: false });
expect(useSendUndoStore.getState()).toMatchObject({ pending: { emailSubmissionId: 'sub-9' }, busy: false });
});
it('drops the entry and points to Drafts when neither the release nor the original could be made', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
api.rescheduleScheduledSend.mockRejectedValueOnce(new RescheduleLostError(new Error('Quota exceeded'), true));
useSendUndoStore.getState().setPending(entry());
await expect(useSendUndoStore.getState().sendNow()).resolves.toBe(false);
expect(useToastStore.getState().toasts).toMatchObject([{
type: 'error',
title: 'Could not send now',
message: expect.stringContaining('back in Drafts'),
}]);
expect(useSendUndoStore.getState()).toMatchObject({ pending: null, busy: false });
});
it('names the recipients the server refused for the release while the others get it', async () => {
+17 -5
View File
@@ -17,7 +17,8 @@ import { draftContextFromEmail } from '../lib/draft-context';
import { composerLoginMoved, servedLoginOf } from '../lib/draft-login';
import { mailboxesOfAccount } from '../lib/mailbox-tree';
import { useAccountStore } from './account-store';
import { partlyRefusedWarning, rescheduleFailureNotice } from '../lib/send-failure-message';
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
import { partlyRefusedWarning } from '../lib/send-failure-message';
import type { ComposeDraftContext } from '../navigation/types';
import { t } from './locale-store';
import { toast } from './toast-store';
@@ -182,11 +183,22 @@ export const useSendUndoStore = create<SendUndoState>((set, get) => ({
// The message stays held and goes out when the delay ends; say why it
// did not go now: a full submission quota, say, or recipients refused
// for the release that the held send had been taken for.
const { title, message } = rescheduleFailureNotice(err, t, t('scheduled.send_now_failed', 'Could not send now'));
const { title, message } = rescheduleErrorNotice(err, t, t('scheduled.send_now_failed', 'Could not send now'));
toast.error(title, message);
// Making room for the release took long enough for the message to go
// out, or be cancelled on another device: nothing is left to undo.
set(err instanceof NoLongerScheduledError ? { pending: null, busy: false } : { busy: false });
// The original was cancelled first and put back under a new id: Undo
// cancels that one now.
const restored = restoredSubmission(err);
if (restored) {
set({
pending: { ...entry, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? entry.sendAt },
busy: false,
});
return false;
}
// It went out, was cancelled on another device, or is no longer
// scheduled at all: nothing is left to undo.
const gone = err instanceof NoLongerScheduledError || rescheduleLostMessage(err);
set(gone ? { pending: null, busy: false } : { busy: false });
return false;
}
},