mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
fix: stop reporting mail as sent in the app when the server refused its recipients
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. The app read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce. Port of
the webmail's e0b0dfe0.
- the send reads the new submission's delivery status in the same
request (`EmailSubmission/get` of `#sub-1`, its creation id; the
create's own answer has none), also with the retry after a full
submission quota, before the record is destroyed
- every recipient refused: the send fails with the server's reply per
address, the copy filed in Sent is removed and the previous draft
kept, so the composer or the quick reply keeps the text and the
address can be fixed; held sends (undo send, scheduled) are refused
when they are created, so this covers them too
- some recipients refused: the others get the message, and a warning
names the refused ones with their replies
- "Send now" and "Reschedule" check the replacement the same way and
keep the original, which still goes out at its old time, when every
recipient of the replacement is refused; a partial refusal there gets
the warning as well
- the submission prune leaves a shared account that refuses its records
with `forbidden` alone while the app runs, for that login, with a
debug log instead of a warning, and the scheduled list skips it:
0.16.23 lists an account that only shared an address book or a
calendar with the submission capability
`RecipientsRefusedError` and `RefusedRecipient` are added to
`jmap-result.ts`. Five new strings in all 27 languages, four of them
with the webmail's translations.
This commit is contained in:
@@ -57,6 +57,8 @@ const OUTGOING = {
|
||||
textBody: 'Hi',
|
||||
};
|
||||
const VIA_DRAFTS = { draftsMailboxId: 'drafts' };
|
||||
/** A send's request: the message, its submission and the submission's delivery status. */
|
||||
const SEND = ['Email/set', 'EmailSubmission/set', 'EmailSubmission/get'];
|
||||
const RECEIPT = {
|
||||
to: 'sender@remote.example',
|
||||
fromEmail: 'me@example.com',
|
||||
@@ -118,7 +120,7 @@ describe('an immediate send removes its own submission record', () => {
|
||||
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set'], ['EmailSubmission/set']]);
|
||||
expect(server.methods()).toEqual([SEND, ['EmailSubmission/set']]);
|
||||
expect(argsOf(server, 'EmailSubmission/set')[1]).toEqual({ accountId: 'acc-1', destroy: [result.emailSubmissionId] });
|
||||
expect(server.requests[1].using).toEqual(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
||||
expect(server.submissions()).toEqual([]);
|
||||
@@ -134,7 +136,7 @@ describe('an immediate send removes its own submission record', () => {
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' });
|
||||
|
||||
expect(result.filingWarning).toBeUndefined();
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set'], ['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.methods()).toEqual([SEND, ['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.requests[1].calls).toEqual([
|
||||
['Email/set', { accountId: 'acc-1', destroy: ['old-draft'] }, '0'],
|
||||
['EmailSubmission/set', { accountId: 'acc-1', destroy: [result.emailSubmissionId] }, '1'],
|
||||
@@ -153,7 +155,7 @@ describe('an immediate send removes its own submission record', () => {
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS);
|
||||
|
||||
expect(result.scheduled).toBe(true);
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.methods()).toEqual([SEND]);
|
||||
expect(server.submissions()).toEqual([{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' }]);
|
||||
},
|
||||
);
|
||||
@@ -164,7 +166,7 @@ describe('an immediate send removes its own submission record', () => {
|
||||
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.methods()).toEqual([SEND]);
|
||||
expect(server.submissions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -215,11 +217,12 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => {
|
||||
notCreated: { 'sub-1': { type: 'overQuota', description: OVER_QUOTA_DESCRIPTION } },
|
||||
}), '1']);
|
||||
// ...then the prune made room and the copy already in Drafts went out.
|
||||
// The retry asks for the delivery status again.
|
||||
expect(server.methods()).toEqual([
|
||||
['Email/set', 'EmailSubmission/set'],
|
||||
SEND,
|
||||
['EmailSubmission/query', 'EmailSubmission/get'],
|
||||
['EmailSubmission/set'],
|
||||
['EmailSubmission/set'],
|
||||
['EmailSubmission/set', 'EmailSubmission/get'],
|
||||
['EmailSubmission/set'],
|
||||
]);
|
||||
const retry = argsOf(server, 'EmailSubmission/set')[2];
|
||||
@@ -240,7 +243,8 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => {
|
||||
// The query can't see them; the log is read from "n" through each newState.
|
||||
expect(argsOf(server, 'EmailSubmission/query')).toHaveLength(1);
|
||||
expect(argsOf(server, 'EmailSubmission/changes').map((a) => a.sinceState)).toEqual(['n', 'r2', 'r4']);
|
||||
expect(argsOf(server, 'EmailSubmission/get').at(-1)).toMatchObject({ ids: ['s1', 's2', 's3', 's4', 's5'] });
|
||||
const gets = argsOf(server, 'EmailSubmission/get').filter((a) => !String(a.ids?.[0]).startsWith('#'));
|
||||
expect(gets.at(-1)).toMatchObject({ ids: ['s1', 's2', 's3', 's4', 's5'] });
|
||||
expect(server.submissions()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -294,7 +298,7 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => {
|
||||
|
||||
expect(err).not.toBeInstanceOf(SubmissionQuotaError);
|
||||
expect((err as Error).message).toContain('Account quota exceeded.');
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.methods()).toEqual([SEND]);
|
||||
});
|
||||
|
||||
it('Stalwart 0.16.23 does not enforce the limit: no retry is ever needed', async () => {
|
||||
@@ -303,7 +307,7 @@ describe('a full submission quota on a send (Stalwart 1.0)', () => {
|
||||
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', 10, VIA_DRAFTS);
|
||||
|
||||
expect(server.methods()).toEqual([['Email/set', 'EmailSubmission/set']]);
|
||||
expect(server.methods()).toEqual([SEND]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { FakeSubmissionServer } from './fake-submission-server';
|
||||
|
||||
// Stalwart runs the SMTP RCPT stage while it creates a submission, held or
|
||||
// not, and records each refused recipient as `delivered: "no"` with its
|
||||
// reply: a relay it doesn't allow ("550 5.1.2 Relay not allowed.") or a
|
||||
// throttle that is used up ("452 4.4.5 Rate limit exceeded, try again
|
||||
// later."). The create still succeeds - undoStatus final when it took nobody -
|
||||
// and onSuccessUpdateEmail files the message in Sent. Seen live on 0.16.23 and
|
||||
// 1.0; 0.16.6 records it the same way (submission/set.rs). These tests run the
|
||||
// send paths against a fake Stalwart (./fake-submission-server) that models
|
||||
// the RCPT stage and `#creationId` in EmailSubmission/get.
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
server: undefined as unknown as FakeSubmissionServer,
|
||||
stalwart: true,
|
||||
accounts: ['acc-1'],
|
||||
username: 'me@example.com',
|
||||
}));
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
serverUrl: 'https://mail.example.com',
|
||||
get username() { return fake.username; },
|
||||
request: vi.fn((calls: never, using?: string[]) => fake.server.request(calls, using)),
|
||||
hasCapability: vi.fn(() => true),
|
||||
hasAccountCapability: vi.fn((urn: string) => urn !== 'urn:stalwart:jmap' || fake.stalwart),
|
||||
getSubmissionAccountIds: vi.fn(() => fake.accounts),
|
||||
hasDelayedSend: vi.fn(() => true),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
learnHoldLimit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
|
||||
import { RecipientsRefusedError, jmapErrorType } from '../jmap-result';
|
||||
import { pruneEmailSubmissions, resetSubmissionPrune } from '../submissions';
|
||||
import {
|
||||
createFakeSubmissionServer, RATE_LIMITED, RELAY_DENIED, type StalwartVersion,
|
||||
} from './fake-submission-server';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
const OUTGOING = {
|
||||
from: [{ email: 'me@example.com' }],
|
||||
to: [{ email: 'you@example.com' }],
|
||||
subject: 'Hello',
|
||||
textBody: 'Hi',
|
||||
};
|
||||
const VIA_DRAFTS = { draftsMailboxId: 'drafts' };
|
||||
/** Stalwart's relay rule on the test servers: anything outside refused. */
|
||||
const relayOff = (email: string) => (email.endsWith('@example.net') ? RELAY_DENIED : undefined);
|
||||
|
||||
function setup(version: StalwartVersion, options: Omit<Parameters<typeof createFakeSubmissionServer>[0], 'version'> = {}) {
|
||||
fake.server = createFakeSubmissionServer({ version, ...options });
|
||||
return fake.server;
|
||||
}
|
||||
|
||||
function addDraft(server: FakeSubmissionServer, id: string) {
|
||||
server.emails.set(id, { id, mailboxIds: { drafts: true }, keywords: { $draft: true, $seen: true } });
|
||||
}
|
||||
|
||||
function to(...addresses: string[]) {
|
||||
return { ...OUTGOING, to: addresses.map((email) => ({ email })) };
|
||||
}
|
||||
|
||||
/** The arguments of every call to `method`, in order. */
|
||||
function argsOf(server: FakeSubmissionServer, method: string): Array<Record<string, any>> {
|
||||
return server.requests.flatMap(({ calls }) => calls.filter(([name]) => name === method).map(([, args]) => args));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetSubmissionPrune();
|
||||
Object.assign(fake, { stalwart: true, accounts: ['acc-1'], username: 'me@example.com' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// A record get reports as pending, with its time ahead, is never destroyed.
|
||||
expect(fake.server?.violations ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
describe('a send whose recipients the server refuses', () => {
|
||||
it.each(['1.0', '0.16.23', '0.16.10', '0.16.6'] as StalwartVersion[])(
|
||||
'Stalwart %s: fails when every recipient was refused, and keeps the previous draft',
|
||||
async (version) => {
|
||||
const server = setup(version, { refuseRecipient: relayOff });
|
||||
addDraft(server, 'old-draft');
|
||||
|
||||
const err = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' })
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(RecipientsRefusedError);
|
||||
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
||||
// The copy onSuccessUpdateEmail filed in Sent is gone, the previous
|
||||
// draft is not ...
|
||||
expect([...server.emails.keys()]).toEqual(['old-draft']);
|
||||
// ... and neither is the record, which can neither go out nor be cancelled.
|
||||
expect(server.submissions()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
|
||||
'Stalwart %s: a held send (undo send, scheduled) is refused when it is created',
|
||||
async (version) => {
|
||||
const server = setup(version, { refuseRecipient: relayOff });
|
||||
|
||||
const err = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', 30, VIA_DRAFTS).catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(RecipientsRefusedError);
|
||||
// The create answered final: nothing was queued to hold.
|
||||
expect(server.requests[0].responses[1][1].created['sub-1']).toMatchObject({ undoStatus: 'final' });
|
||||
expect(server.emails.size).toBe(0);
|
||||
expect(server.submissions()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('Stalwart 1.0: takes a used-up throttle for a refusal too', async () => {
|
||||
setup('1.0', { refuseRecipient: () => RATE_LIMITED });
|
||||
|
||||
const err = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e);
|
||||
|
||||
expect((err as RecipientsRefusedError).recipients).toEqual([{ email: 'you@example.com', smtpReply: RATE_LIMITED }]);
|
||||
});
|
||||
|
||||
it.each(['1.0', '0.16.23', '0.16.6'] as StalwartVersion[])(
|
||||
'Stalwart %s: names the refused recipients and sends to the others',
|
||||
async (version) => {
|
||||
const server = setup(version, { refuseRecipient: relayOff });
|
||||
addDraft(server, 'old-draft');
|
||||
|
||||
const result = await sendEmail(
|
||||
to('you@example.com', 'nobody@example.net'), 'id-1', 'sent', undefined, { ...VIA_DRAFTS, draftId: 'old-draft' },
|
||||
);
|
||||
|
||||
expect(result.refusedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
||||
expect(result.filingWarning).toBeUndefined();
|
||||
// Sent as usual: the previous draft goes, the message stays in Sent.
|
||||
expect([...server.emails.keys()]).toEqual([result.emailId]);
|
||||
expect(server.emails.get(result.emailId!)?.mailboxIds).toEqual({ sent: true });
|
||||
},
|
||||
);
|
||||
|
||||
it('Stalwart 1.0: reads the delivery status in the send request, before the record is destroyed', async () => {
|
||||
const server = setup('1.0', { refuseRecipient: relayOff });
|
||||
|
||||
const result = await sendEmail(to('you@example.com', 'nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(server.requests[0].calls[2]).toEqual(
|
||||
['EmailSubmission/get', { accountId: 'acc-1', ids: ['#sub-1'], properties: ['id', 'deliveryStatus'] }, '2'],
|
||||
);
|
||||
expect(server.requests[0].using).toEqual([
|
||||
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission',
|
||||
]);
|
||||
expect(server.requests.at(-1)?.calls).toEqual([
|
||||
['EmailSubmission/set', { accountId: 'acc-1', destroy: [result.emailSubmissionId] }, '0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: asks again with the retry after a full submission quota', async () => {
|
||||
const server = setup('1.0', { limit: 1, refuseRecipient: relayOff });
|
||||
server.seed('acc-1', [{ sendAt: Date.now() - HOUR, status: 'final' }]);
|
||||
|
||||
const err = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(RecipientsRefusedError);
|
||||
const retry = server.requests.find(({ calls }) => calls[0][0] === 'EmailSubmission/set' && calls[0][1].create);
|
||||
expect(retry?.calls.map(([name]) => name)).toEqual(['EmailSubmission/set', 'EmailSubmission/get']);
|
||||
// Only the seeded record's message is left.
|
||||
expect([...server.emails.keys()].filter((id) => !id.startsWith('seed'))).toEqual([]);
|
||||
expect(server.submissions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves the record to the prune on a server without urn:stalwart:jmap', async () => {
|
||||
fake.stalwart = false;
|
||||
const server = setup('1.0', { refuseRecipient: relayOff });
|
||||
|
||||
await expect(sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS))
|
||||
.rejects.toBeInstanceOf(RecipientsRefusedError);
|
||||
|
||||
expect(server.emails.size).toBe(0);
|
||||
// Final: nothing was queued, so the prune may take it.
|
||||
expect(server.submissions()).toEqual([{ id: expect.any(String), sendAt: expect.any(Number), undoStatus: 'final' }]);
|
||||
});
|
||||
|
||||
it('sends as before when the server tells nothing about the recipients', async () => {
|
||||
const server = setup('1.0', { refuseRecipient: relayOff });
|
||||
vi.mocked(jmapClient.request).mockImplementationOnce(async (calls, using) => {
|
||||
const res = await server.request(calls, using);
|
||||
return {
|
||||
methodResponses: res.methodResponses.map((r) => (r[0] === 'EmailSubmission/get'
|
||||
? ['error', { type: 'unknownMethod' }, r[2]] as [string, Record<string, any>, string]
|
||||
: r)),
|
||||
};
|
||||
});
|
||||
|
||||
const result = await sendEmail(to('nobody@example.net'), 'id-1', 'sent', undefined, VIA_DRAFTS);
|
||||
|
||||
expect(result.emailSubmissionId).toBeDefined();
|
||||
expect(result.refusedRecipients).toBeUndefined();
|
||||
// The get's own failure is neither a failed send nor a filing problem.
|
||||
expect(result.filingWarning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes a server that keeps no delivery status for one that took everyone', async () => {
|
||||
setup('1.0');
|
||||
vi.mocked(jmapClient.request).mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
|
||||
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
|
||||
['EmailSubmission/get', { list: [{ id: 's-1', deliveryStatus: null }], notFound: [] }, '2'],
|
||||
],
|
||||
});
|
||||
fake.stalwart = false;
|
||||
|
||||
const result = await sendEmail(OUTGOING, 'id-1', 'sent');
|
||||
|
||||
expect(result).toMatchObject({ emailId: 'e-new', emailSubmissionId: 's-1' });
|
||||
expect(result.refusedRecipients).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a refused create with the create's own reason, not the get's (1.0 and 0.16.23 answer the same)", async () => {
|
||||
setup('1.0');
|
||||
vi.mocked(jmapClient.request)
|
||||
.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
|
||||
['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties', description: 'Identity not found.', properties: ['identityId'] } } }, '1'],
|
||||
['error', { type: 'invalidResultReference', description: 'Id reference "sub-1" does not exist or is invalid.' }, '2'],
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ methodResponses: [['Email/set', { destroyed: ['e-new'] }, '0']] });
|
||||
|
||||
const err = await sendEmail(OUTGOING, 'id-x', 'sent', undefined, VIA_DRAFTS).catch((e: unknown) => e);
|
||||
|
||||
expect(err).not.toBeInstanceOf(RecipientsRefusedError);
|
||||
expect((err as Error).message).toBe('Identity not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('"Send now" and "Reschedule" when the server refuses the recipients again', () => {
|
||||
/** A scheduled message to `recipients`, held for an hour. */
|
||||
function scheduledTo(server: FakeSubmissionServer, ...recipients: string[]) {
|
||||
server.emails.set('mail-1', {
|
||||
id: 'mail-1', mailboxIds: { sent: true }, keywords: {}, to: recipients.map((email) => ({ email })),
|
||||
});
|
||||
const [original] = server.seed('acc-1', [{ sendAt: Date.now() + HOUR, status: 'pending', emailId: 'mail-1' }]);
|
||||
return { emailSubmissionId: original, emailId: 'mail-1', identityId: 'id-1', from: [{ email: 'me@example.com' }] };
|
||||
}
|
||||
|
||||
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)',
|
||||
async (version, holdFor) => {
|
||||
// A throttle used up since the message was scheduled.
|
||||
const server = setup(version, { refuseRecipient: () => RATE_LIMITED });
|
||||
const scheduled = scheduledTo(server, 'bob@example.org');
|
||||
|
||||
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(server.submissions()).toEqual([
|
||||
{ id: scheduled.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('Stalwart 1.0: reads the replacement\'s delivery status with its create', async () => {
|
||||
const server = setup('1.0');
|
||||
const scheduled = scheduledTo(server, 'bob@example.org');
|
||||
|
||||
await rescheduleScheduledSend(scheduled, 0);
|
||||
|
||||
const create = server.requests.find(({ calls }) => calls[0][1].create);
|
||||
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']);
|
||||
});
|
||||
|
||||
it.each(['1.0', '0.16.23'] as StalwartVersion[])(
|
||||
'Stalwart %s: names the recipients refused for the replacement and replaces the original',
|
||||
async (version) => {
|
||||
const server = setup(version, { refuseRecipient: relayOff });
|
||||
const scheduled = scheduledTo(server, 'bob@example.org', 'nobody@example.net');
|
||||
|
||||
const result = await rescheduleScheduledSend(scheduled, 7200);
|
||||
|
||||
expect(result.refusedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
||||
expect(server.submissions()).toEqual([
|
||||
{ id: result.emailSubmissionId, sendAt: expect.any(Number), undoStatus: 'pending' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves the refused replacement to the prune on a server without urn:stalwart:jmap', async () => {
|
||||
fake.stalwart = false;
|
||||
const server = setup('1.0', { refuseRecipient: () => RATE_LIMITED });
|
||||
const scheduled = scheduledTo(server, 'bob@example.org');
|
||||
|
||||
await expect(rescheduleScheduledSend(scheduled, 0)).rejects.toBeInstanceOf(RecipientsRefusedError);
|
||||
|
||||
expect(server.submissions().map((s) => s.undoStatus)).toEqual(['pending', 'final']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared accounts that refuse the submission prune', () => {
|
||||
const queriedAccounts = (server: FakeSubmissionServer) => argsOf(server, 'EmailSubmission/query').map((q) => q.accountId);
|
||||
|
||||
// 0.16.23 lists an account that shared only an address book or a calendar
|
||||
// with the submission capability, then refuses its records: "You are not an
|
||||
// owner of account e" (live, userc).
|
||||
it('Stalwart 0.16.23: leaves such an account alone while the app runs, without a warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const server = setup('0.16.23', { forbiddenAccounts: ['e'] });
|
||||
fake.accounts = ['acc-1', 'e'];
|
||||
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
await listScheduledEmails();
|
||||
|
||||
expect(queriedAccounts(server)).toEqual(['acc-1', 'e', 'acc-1', 'acc-1']);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Stalwart 0.16.23: asks again for another login on the same server', async () => {
|
||||
const server = setup('0.16.23', { forbiddenAccounts: ['e'] });
|
||||
fake.accounts = ['acc-1', 'e'];
|
||||
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
fake.username = 'usera@example.com';
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
|
||||
expect(queriedAccounts(server)).toEqual(['acc-1', 'e', 'acc-1', 'e']);
|
||||
});
|
||||
|
||||
it('Stalwart 1.0: keeps pruning a group account the user belongs to', async () => {
|
||||
const server = setup('1.0');
|
||||
fake.accounts = ['acc-1', 'team'];
|
||||
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
|
||||
expect(queriedAccounts(server)).toEqual(['acc-1', 'team', 'acc-1', 'team']);
|
||||
});
|
||||
|
||||
it('never leaves out the primary account, and warns about it', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const server = setup('1.0', { forbiddenAccounts: ['acc-1'] });
|
||||
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
|
||||
expect(queriedAccounts(server)).toEqual(['acc-1', 'acc-1']);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(jmapErrorType(warn.mock.calls[0][1])).toBe('forbidden');
|
||||
});
|
||||
|
||||
it('keeps asking an account that failed for another reason', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const server = setup('1.0');
|
||||
fake.accounts = ['acc-1', 'team'];
|
||||
server.beforeEachRequest((calls) => {
|
||||
if ((calls[0][1] as { accountId?: string }).accountId === 'team') throw new Error('Network request failed');
|
||||
});
|
||||
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
server.beforeEachRequest(() => undefined);
|
||||
await pruneEmailSubmissions({ force: true });
|
||||
expect(queriedAccounts(server)).toEqual(['acc-1', 'acc-1', 'acc-1', 'team']);
|
||||
});
|
||||
});
|
||||
@@ -553,10 +553,12 @@ describe('email operations', () => {
|
||||
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0][0]).toBe('Email/set');
|
||||
expect(calls[1][0]).toBe('EmailSubmission/set');
|
||||
expect(calls[1][1].create['sub-1'].emailId).toBe('#draft');
|
||||
// The new submission's delivery status, named by its creation id.
|
||||
expect(calls[2]).toEqual(['EmailSubmission/get', { accountId: 'acc-1', ids: ['#sub-1'], properties: ['id', 'deliveryStatus'] }, '2']);
|
||||
});
|
||||
|
||||
it('should use htmlBody when provided', async () => {
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
// notFound for a record that is gone;
|
||||
// - EmailSubmission/changes from "n": the whole log in pages of maxChanges,
|
||||
// a record created and destroyed within it left out;
|
||||
// - the SMTP RCPT stage, which runs while a submission is created, held or
|
||||
// not: a refused recipient is recorded in deliveryStatus as
|
||||
// `delivered: "no"` with the server's reply, the others as `queued`; with
|
||||
// nobody taken nothing is queued, the create answers `final` and sendAt is
|
||||
// the time of the create (seen live on 0.16.23 and 1.0);
|
||||
// - `#creationId` in EmailSubmission/get `ids`, naming a submission created
|
||||
// earlier in the same request, and Stalwart's invalidResultReference when
|
||||
// that create failed;
|
||||
// - a shared account that refuses this login's submission calls with
|
||||
// `forbidden` (0.16.23 lists one that only shared an address book);
|
||||
// - Email/set, Email/import and Email/get, just enough for the send paths.
|
||||
// It records the one thing the client must never do - destroy a submission
|
||||
// that get reports as pending with sendAt in the future - in `violations`.
|
||||
@@ -24,8 +34,18 @@ export type StalwartVersion = '0.16.6' | '0.16.10' | '0.16.23' | '1.0';
|
||||
export const OVER_QUOTA_DESCRIPTION =
|
||||
'There are too many email submissions, please delete some before adding a new one.';
|
||||
|
||||
// Stalwart's replies to a recipient it refuses at RCPT (live, 0.16.23 and 1.0).
|
||||
export const RELAY_DENIED = '550 5.1.2 Relay not allowed.';
|
||||
export const RATE_LIMITED = '452 4.4.5 Rate limit exceeded, try again later.';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
interface DeliveryStatus {
|
||||
delivered: string;
|
||||
smtpReply: string;
|
||||
displayed: string;
|
||||
}
|
||||
|
||||
interface Submission {
|
||||
id: string;
|
||||
seq: number;
|
||||
@@ -35,6 +55,9 @@ interface Submission {
|
||||
canceled: boolean;
|
||||
/** Still in the queue with sendAt passed (a delivery being retried). */
|
||||
retrying: boolean;
|
||||
/** False when the RCPT stage refused every recipient: nothing was queued. */
|
||||
taken: boolean;
|
||||
deliveryStatus?: Record<string, DeliveryStatus>;
|
||||
envelope?: unknown;
|
||||
}
|
||||
|
||||
@@ -71,6 +94,10 @@ export function createFakeSubmissionServer(options: {
|
||||
maxChanges?: number;
|
||||
/** Leave `total` out of query answers, as a server may. */
|
||||
omitTotal?: boolean;
|
||||
/** The SMTP reply the RCPT stage refuses a recipient with; undefined takes it. */
|
||||
refuseRecipient?: (email: string) => string | undefined;
|
||||
/** Accounts whose EmailSubmission calls this login is refused (`forbidden`). */
|
||||
forbiddenAccounts?: string[];
|
||||
}) {
|
||||
const { version } = options;
|
||||
const limit = options.limit ?? 500;
|
||||
@@ -107,13 +134,14 @@ export function createFakeSubmissionServer(options: {
|
||||
|
||||
function status(s: Submission): 'pending' | 'final' | 'canceled' {
|
||||
if (s.canceled) return 'canceled';
|
||||
if (!s.taken) return 'final';
|
||||
if (s.retrying) return 'pending';
|
||||
return s.sendAt > Date.now() ? 'pending' : 'final';
|
||||
}
|
||||
|
||||
function addSubmission(accountId: string, fields: Omit<Submission, 'id' | 'seq'>): Submission {
|
||||
function addSubmission(accountId: string, fields: Omit<Submission, 'id' | 'seq' | 'taken'> & { taken?: boolean }): Submission {
|
||||
seq += 1;
|
||||
const submission: Submission = { id: `s${seq}`, seq, ...fields };
|
||||
const submission: Submission = { id: `s${seq}`, seq, ...fields, taken: fields.taken ?? true };
|
||||
recordsOf(accountId).set(submission.id, submission);
|
||||
logOf(accountId).push({ id: submission.id, kind: 'created' });
|
||||
return submission;
|
||||
@@ -128,6 +156,7 @@ export function createFakeSubmissionServer(options: {
|
||||
sendAt: utcDate(s.sendAt),
|
||||
undoStatus: status(s),
|
||||
envelope: s.envelope ?? null,
|
||||
deliveryStatus: s.deliveryStatus ?? null,
|
||||
};
|
||||
if (!properties) return full;
|
||||
return Object.fromEntries(['id', ...properties].map((p) => [p, full[p]]));
|
||||
@@ -156,7 +185,7 @@ export function createFakeSubmissionServer(options: {
|
||||
function createdAnswer(s: Submission): Record<string, unknown> {
|
||||
if (version === '0.16.6') return { id: s.id };
|
||||
if (version === '0.16.10') return { id: s.id, sendAt: utcDate(s.sendAt), undoStatus: 'final' };
|
||||
return { id: s.id, sendAt: utcDate(s.sendAt), undoStatus: 'pending' };
|
||||
return { id: s.id, sendAt: utcDate(s.sendAt), undoStatus: s.taken ? 'pending' : 'final' };
|
||||
}
|
||||
|
||||
function holdSeconds(create: Record<string, any>): number {
|
||||
@@ -164,7 +193,26 @@ export function createFakeSubmissionServer(options: {
|
||||
return hold ? Number(hold) : 0;
|
||||
}
|
||||
|
||||
function submissionSet(args: Record<string, any>, callId: string, createdEmails: Map<string, string>): Response[] {
|
||||
/** The RCPT stage: each recipient of the envelope, or else of the message, taken or refused. */
|
||||
function rcptStage(create: Record<string, any>, email: StoredEmail): Record<string, DeliveryStatus> {
|
||||
const envelope = (create.envelope?.rcptTo as Array<{ email: string }> | undefined)?.map((r) => r.email);
|
||||
const headers = [email.to, email.cc, email.bcc]
|
||||
.flatMap((list) => (Array.isArray(list) ? list as Array<{ email: string }> : []))
|
||||
.map((r) => r.email);
|
||||
return Object.fromEntries((envelope ?? headers).map((address) => {
|
||||
const reply = options.refuseRecipient?.(address);
|
||||
return [address, reply
|
||||
? { delivered: 'no', smtpReply: reply, displayed: 'unknown' }
|
||||
: { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' }];
|
||||
}));
|
||||
}
|
||||
|
||||
function submissionSet(
|
||||
args: Record<string, any>,
|
||||
callId: string,
|
||||
createdEmails: Map<string, string>,
|
||||
createdSubmissions: Map<string, string>,
|
||||
): Response[] {
|
||||
const accountId = args.accountId as string;
|
||||
const records = recordsOf(accountId);
|
||||
const body: Record<string, any> = { accountId, newState: `s${seq}` };
|
||||
@@ -185,14 +233,22 @@ export function createFakeSubmissionServer(options: {
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
// Held or not, the RCPT stage runs now. With nobody taken, nothing
|
||||
// is queued, and the record's sendAt is the time of the create.
|
||||
const deliveryStatus = rcptStage(create, emails.get(emailId)!);
|
||||
const statuses = Object.values(deliveryStatus);
|
||||
const taken = statuses.length === 0 || statuses.some((d) => d.delivered !== 'no');
|
||||
const submission = addSubmission(accountId, {
|
||||
emailId,
|
||||
identityId: String(create.identityId),
|
||||
sendAt: Date.now() + holdSeconds(create) * 1000,
|
||||
sendAt: Date.now() + (taken ? holdSeconds(create) * 1000 : 0),
|
||||
canceled: false,
|
||||
retrying: false,
|
||||
taken,
|
||||
deliveryStatus,
|
||||
envelope: create.envelope,
|
||||
});
|
||||
createdSubmissions.set(creationId, submission.id);
|
||||
(body.created ??= {})[creationId] = createdAnswer(submission);
|
||||
const update = args.onSuccessUpdateEmail?.[`#${creationId}`];
|
||||
if (update) {
|
||||
@@ -276,10 +332,13 @@ export function createFakeSubmissionServer(options: {
|
||||
const responses: Response[] = [];
|
||||
requests.push({ calls, using, responses });
|
||||
const createdEmails = new Map<string, string>();
|
||||
const createdSubmissions = new Map<string, string>();
|
||||
for (const [name, rawArgs, callId] of calls) {
|
||||
const args = rawArgs as Record<string, any>;
|
||||
const accountId = args.accountId as string;
|
||||
if (name === 'Email/set') {
|
||||
if (name.startsWith('EmailSubmission/') && options.forbiddenAccounts?.includes(accountId)) {
|
||||
responses.push(['error', { type: 'forbidden', description: `You are not an owner of account ${accountId}` }, callId]);
|
||||
} else if (name === 'Email/set') {
|
||||
const body: Record<string, any> = { accountId };
|
||||
for (const [creationId, create] of Object.entries((args.create ?? {}) as Record<string, Record<string, any>>)) {
|
||||
if (emailCreateFailure) {
|
||||
@@ -341,9 +400,28 @@ export function createFakeSubmissionServer(options: {
|
||||
responses.push(['EmailSubmission/query', body, callId]);
|
||||
} else if (name === 'EmailSubmission/get') {
|
||||
const ref = args['#ids'] as { resultOf: string } | undefined;
|
||||
const ids = ref
|
||||
? (responses.find(([, , id]) => id === ref.resultOf)?.[1].ids as string[])
|
||||
: (args.ids as string[]);
|
||||
let ids: string[] = [];
|
||||
let unresolved: string | undefined;
|
||||
if (ref) {
|
||||
const [sourceName, source] = responses.find(([, , id]) => id === ref.resultOf) ?? [];
|
||||
if (sourceName && sourceName !== 'error') ids = source!.ids as string[];
|
||||
else unresolved = `Result reference to ${ref.resultOf} not found.`;
|
||||
} else {
|
||||
// "#<creation id>" names a submission created earlier in this
|
||||
// request; a create that failed leaves the whole get refused.
|
||||
for (const id of args.ids as string[]) {
|
||||
const created = id.startsWith('#') ? createdSubmissions.get(id.slice(1)) : id;
|
||||
if (!created) {
|
||||
unresolved = `Id reference "${id.slice(1)}" does not exist or is invalid.`;
|
||||
break;
|
||||
}
|
||||
ids.push(created);
|
||||
}
|
||||
}
|
||||
if (unresolved) {
|
||||
responses.push(['error', { type: 'invalidResultReference', description: unresolved }, callId]);
|
||||
continue;
|
||||
}
|
||||
const records = recordsOf(accountId);
|
||||
responses.push(['EmailSubmission/get', {
|
||||
accountId,
|
||||
@@ -355,7 +433,7 @@ export function createFakeSubmissionServer(options: {
|
||||
notFound: ids.filter((id) => !records.has(id)),
|
||||
}, callId]);
|
||||
} else if (name === 'EmailSubmission/set') {
|
||||
responses.push(...submissionSet(args, callId, createdEmails));
|
||||
responses.push(...submissionSet(args, callId, createdEmails, createdSubmissions));
|
||||
} else if (name === 'EmailSubmission/changes') {
|
||||
responses.push(submissionChanges(args, callId));
|
||||
} else {
|
||||
|
||||
+78
-21
@@ -1,13 +1,13 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import {
|
||||
assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, requireMethodResult,
|
||||
ScheduleTooLateError,
|
||||
assertSetResult, batched, failedIdsOf, JMAPSetError, NoLongerScheduledError, parseHoldLimit, RecipientsRefusedError,
|
||||
requireMethodResult, ScheduleTooLateError, type RefusedRecipient,
|
||||
} from './jmap-result';
|
||||
import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import {
|
||||
destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy,
|
||||
submissionCreateError, submissionDestroyCall, submissionQuotaError, SUBMISSION_ONLY_USING,
|
||||
SUBMISSION_SCAN_MAX_PAGES,
|
||||
deliveryStatusCall, destroyEmailSubmissions, isStalwartAccount, makeRoomForSubmission, readSubmissionDestroy,
|
||||
refusedRecipients, submissionAccountIds, submissionCreateError, submissionDestroyCall, submissionQuotaError,
|
||||
SUBMISSION_ONLY_USING, SUBMISSION_SCAN_MAX_PAGES,
|
||||
} from './submissions';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
|
||||
@@ -1517,6 +1517,12 @@ export interface SendEmailResult {
|
||||
emailSubmissionId?: string;
|
||||
/** Post-send filing/cleanup problem (message did go out). */
|
||||
filingWarning?: string;
|
||||
/**
|
||||
* Recipients the server refused while it took the submission; the others
|
||||
* get the message. When it refused every one, the send throws
|
||||
* RecipientsRefusedError instead.
|
||||
*/
|
||||
refusedRecipients?: RefusedRecipient[];
|
||||
}
|
||||
|
||||
function toMessageIdList(value: string[] | string | undefined): string[] | undefined {
|
||||
@@ -1693,6 +1699,9 @@ export async function sendEmail(
|
||||
[
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
['EmailSubmission/set', submissionArgs, '1'],
|
||||
// Which recipients the server took. A refused one is lost without a
|
||||
// bounce while the create succeeds and files the message in Sent.
|
||||
deliveryStatusCall(accountId, 'sub-1', '2'),
|
||||
],
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
@@ -1705,15 +1714,23 @@ export async function sendEmail(
|
||||
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
|
||||
await makeRoomForSubmission(accountId);
|
||||
const retry = await jmapClient.request(
|
||||
[['EmailSubmission/set', {
|
||||
...submissionArgs,
|
||||
create: { 'sub-1': { ...submissionCreate, emailId: copyId } },
|
||||
}, '1']],
|
||||
[
|
||||
['EmailSubmission/set', {
|
||||
...submissionArgs,
|
||||
create: { 'sub-1': { ...submissionCreate, emailId: copyId } },
|
||||
}, '1'],
|
||||
deliveryStatusCall(accountId, 'sub-1', '2'),
|
||||
],
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
res = { ...retry, methodResponses: [copyCreated, ...(retry.methodResponses ?? [])] };
|
||||
}
|
||||
|
||||
// The get goes on its own: it fails whenever the create did, and must not
|
||||
// count as a failed send or a filing problem either way.
|
||||
const deliveryAnswer = res.methodResponses.find(([, , callId]) => callId === '2');
|
||||
res = { ...res, methodResponses: res.methodResponses.filter(([, , callId]) => callId !== '2') };
|
||||
|
||||
let emailId: string | undefined;
|
||||
let emailSubmissionId: string | undefined;
|
||||
let sendAt: string | undefined;
|
||||
@@ -1781,6 +1798,25 @@ export async function sendEmail(
|
||||
throw failure;
|
||||
}
|
||||
|
||||
// Every recipient refused: nothing was queued, so nothing goes out, held or
|
||||
// not, and no bounce will tell. That is a failed send. The copy made for it
|
||||
// goes, as after any failed send - onSuccessUpdateEmail filed it in Sent,
|
||||
// where it passes for a sent message - while the previous draft version
|
||||
// stays and the composer keeps the text, so the address can be fixed and
|
||||
// the message sent again. The record can neither go out nor be cancelled.
|
||||
const delivery = emailSubmissionId ? refusedRecipients(deliveryAnswer) : { recipients: [], all: false };
|
||||
if (delivery.all) {
|
||||
if (emailId) {
|
||||
try {
|
||||
await destroyEmails([emailId], accountId);
|
||||
} catch (err) {
|
||||
console.warn('[email] failed to remove the unsent copy:', err);
|
||||
}
|
||||
}
|
||||
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([emailSubmissionId!], accountId);
|
||||
throw new RecipientsRefusedError(delivery.recipients);
|
||||
}
|
||||
|
||||
// An immediate send's submission record has done its job once the request
|
||||
// that created it returned: the message is queued and filed, and with no
|
||||
// hold there is nothing to cancel. Stalwart 1.0 stops accepting submissions
|
||||
@@ -1825,6 +1861,9 @@ export async function sendEmail(
|
||||
emailId,
|
||||
emailSubmissionId,
|
||||
filingWarning,
|
||||
// Some recipients refused: the others get the message, the caller says
|
||||
// who doesn't.
|
||||
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1885,7 +1924,7 @@ export interface ScheduledEmail {
|
||||
// primary-only lookup never found it, so it could not be cancelled (webmail
|
||||
// #874). Empty when no account supports FUTURERELEASE.
|
||||
export async function listScheduledEmails(): Promise<ScheduledEmail[]> {
|
||||
const accountIds = jmapClient.getSubmissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id));
|
||||
const accountIds = submissionAccountIds().filter((id) => jmapClient.hasDelayedSend(id));
|
||||
const out: ScheduledEmail[] = [];
|
||||
for (const accountId of accountIds) {
|
||||
try {
|
||||
@@ -2029,7 +2068,8 @@ export async function cancelScheduledSend(emailSubmissionId: string, accountIdOv
|
||||
* 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.
|
||||
* withdrawn again. 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: {
|
||||
@@ -2043,7 +2083,7 @@ export async function rescheduleScheduledSend(
|
||||
},
|
||||
holdForSeconds: number,
|
||||
recipients?: EmailAddress[],
|
||||
): Promise<{ emailSubmissionId?: string; sendAt?: string }> {
|
||||
): 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.
|
||||
const holdFor = Math.max(1, Math.ceil(holdForSeconds));
|
||||
@@ -2080,15 +2120,18 @@ export async function rescheduleScheduledSend(
|
||||
// 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) } } },
|
||||
[
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
create: {
|
||||
replacement: {
|
||||
...create,
|
||||
envelope: { ...create.envelope, mailFrom: { ...create.envelope.mailFrom, parameters: { HOLDFOR: String(seconds) } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, '0']],
|
||||
}, '0'],
|
||||
deliveryStatusCall(accountId, 'replacement', '1'),
|
||||
],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.SUBMISSION],
|
||||
);
|
||||
let res = await createReplacement(holdFor);
|
||||
@@ -2112,6 +2155,16 @@ export async function rescheduleScheduledSend(
|
||||
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');
|
||||
|
||||
// The recipients are checked again for the replacement, and a throttle
|
||||
// used up since the original was taken refuses them all: nothing was
|
||||
// queued, and cancelling the original would lose the message. It goes at
|
||||
// its old time instead; the replacement's record is dead weight.
|
||||
const delivery = refusedRecipients(res.methodResponses.find(([, , callId]) => callId === '1'));
|
||||
if (delivery.all) {
|
||||
if (isStalwartAccount(accountId)) await destroyEmailSubmissions([created.id], accountId);
|
||||
throw new RecipientsRefusedError(delivery.recipients);
|
||||
}
|
||||
|
||||
// Either cancel below also removes the record it canceled, so a reschedule
|
||||
// leaves only the replacement behind.
|
||||
try {
|
||||
@@ -2130,7 +2183,11 @@ export async function rescheduleScheduledSend(
|
||||
throw new Error(`Failed to cancel the previous schedule: ${reason}`);
|
||||
}
|
||||
}
|
||||
return { emailSubmissionId: created.id, sendAt: created.sendAt };
|
||||
return {
|
||||
emailSubmissionId: created.id,
|
||||
sendAt: created.sendAt,
|
||||
...(delivery.recipients.length > 0 ? { refusedRecipients: delivery.recipients } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,6 +165,32 @@ export class NoLongerScheduledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** A recipient the server refused to take a message for (EmailSubmission deliveryStatus `delivered: "no"`). */
|
||||
export interface RefusedRecipient {
|
||||
email: string;
|
||||
/** The server's SMTP reply, e.g. "550 5.1.2 Relay not allowed." */
|
||||
smtpReply?: string;
|
||||
}
|
||||
|
||||
/** "nobody@example.net (550 5.1.2 Relay not allowed.)", one per refused recipient. */
|
||||
export function formatRefusedRecipients(recipients: RefusedRecipient[]): string {
|
||||
return recipients.map((r) => (r.smtpReply ? `${r.email} (${r.smtpReply})` : r.email)).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The server took a submission but refused every one of its recipients, so
|
||||
* nothing was queued: nothing goes out, and no bounce will say so. Stalwart
|
||||
* answers this way to a relay it doesn't allow ("550 5.1.2 Relay not
|
||||
* allowed.") and to a throttle that is used up ("452 4.4.5 Rate limit
|
||||
* exceeded, try again later."), on 0.16 and 1.0 alike.
|
||||
*/
|
||||
export class RecipientsRefusedError extends Error {
|
||||
constructor(readonly recipients: RefusedRecipient[]) {
|
||||
super(`The server refused ${formatRefusedRecipients(recipients)}`);
|
||||
this.name = 'RecipientsRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** A scheduled send later than the server's hold limit. */
|
||||
export class ScheduleTooLateError extends Error {
|
||||
maxSeconds?: number;
|
||||
|
||||
+74
-7
@@ -8,9 +8,12 @@
|
||||
// any more are ever destroyed. Port of the webmail's client (48382170).
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { SubmissionQuotaError, batched, requireMethodResult } from './jmap-result';
|
||||
import {
|
||||
SubmissionQuotaError, batched, jmapErrorType, requireMethodResult, type RefusedRecipient,
|
||||
} from './jmap-result';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { JMAPMethodCall, JMAPResponseBody } from './types';
|
||||
import { debug } from '../lib/debug';
|
||||
|
||||
/** `using` for EmailSubmission calls that neither read nor write an Email. */
|
||||
export const SUBMISSION_ONLY_USING = [CAPABILITIES.CORE, CAPABILITIES.SUBMISSION];
|
||||
@@ -29,15 +32,36 @@ export const SUBMISSION_SCAN_MAX_PAGES = 40;
|
||||
const prunedAt = new Map<string, number>();
|
||||
/** Accounts where making room found nothing to free, until when (epoch ms). */
|
||||
const roomRecheckAt = new Map<string, number>();
|
||||
/**
|
||||
* Shared accounts that advertise EmailSubmission but refused this login's
|
||||
* calls with `forbidden` (see pruneSubmissionAccount), keyed by server, login
|
||||
* and account. Left out of submissionAccountIds while the app runs.
|
||||
*/
|
||||
const refusedAccounts = new Set<string>();
|
||||
|
||||
function pruneKey(accountId: string): string {
|
||||
return `${jmapClient.serverUrl ?? ''}|${accountId}`;
|
||||
}
|
||||
|
||||
/** Access is the login's: another login on the same server may be a member of the account. */
|
||||
function refusalKey(accountId: string): string {
|
||||
return `${pruneKey(accountId)}|${jmapClient.username ?? ''}`;
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
export function resetSubmissionPrune(): void {
|
||||
prunedAt.clear();
|
||||
roomRecheckAt.clear();
|
||||
refusedAccounts.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts whose submission records this login reaches: the primary
|
||||
* submission account and every shared or group account advertising
|
||||
* submission, except those that refused them.
|
||||
*/
|
||||
export function submissionAccountIds(): string[] {
|
||||
return jmapClient.getSubmissionAccountIds().filter((id) => !refusedAccounts.has(refusalKey(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +122,39 @@ export function submissionCreateError(res: JMAPResponseBody, creationId: string)
|
||||
?.find(([name]) => name === 'EmailSubmission/set')?.[1]?.notCreated?.[creationId];
|
||||
}
|
||||
|
||||
/**
|
||||
* EmailSubmission/get of the submission a /set creates in the same request,
|
||||
* named by its creation id; a result reference into the /set answer does not
|
||||
* resolve on 1.0. The server runs the SMTP RCPT stage while it creates the
|
||||
* submission - for a held send as well, only the delivery waits - and
|
||||
* records a refused recipient right away as `delivered: "no"` with its
|
||||
* reply. The create's own answer carries no deliveryStatus (0.16 and 1.0).
|
||||
*/
|
||||
export function deliveryStatusCall(accountId: string, creationId: string, callId: string): JMAPMethodCall {
|
||||
return ['EmailSubmission/get', { accountId, ids: [`#${creationId}`], properties: ['id', 'deliveryStatus'] }, callId];
|
||||
}
|
||||
|
||||
/**
|
||||
* The recipients the server refused for a new submission, from the answer to
|
||||
* deliveryStatusCall, and whether it refused every one. Nothing is known when
|
||||
* that get failed - it does whenever the create did - or the server keeps no
|
||||
* delivery status.
|
||||
*/
|
||||
export function refusedRecipients(
|
||||
answer: [string, Record<string, any>, string] | undefined,
|
||||
): { recipients: RefusedRecipient[]; all: boolean } {
|
||||
const [name, body] = answer ?? [];
|
||||
const status = name === 'EmailSubmission/get'
|
||||
? (body?.list?.[0] as { deliveryStatus?: Record<string, { delivered?: string; smtpReply?: string } | null> | null } | undefined)
|
||||
?.deliveryStatus
|
||||
: undefined;
|
||||
const entries = Object.entries(status ?? {});
|
||||
const recipients = entries
|
||||
.filter(([, delivery]) => delivery?.delivered === 'no')
|
||||
.map(([email, delivery]) => ({ email, ...(delivery?.smtpReply ? { smtpReply: delivery.smtpReply } : {}) }));
|
||||
return { recipients, all: recipients.length > 0 && recipients.length === entries.length };
|
||||
}
|
||||
|
||||
export function submissionDestroyCall(ids: string[], accountId: string, callId: string): JMAPMethodCall {
|
||||
return ['EmailSubmission/set', { accountId, destroy: ids }, callId];
|
||||
}
|
||||
@@ -143,9 +200,9 @@ export async function destroyEmailSubmissions(ids: string[], accountId: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy, in every submission account, the records that can no longer be
|
||||
* cancelled (see prunableSubmissionIds). That includes other devices' and
|
||||
* other group members' records: the limit is per account.
|
||||
* Destroy, in every submission account (submissionAccountIds), the records
|
||||
* that can no longer be cancelled (see prunableSubmissionIds). That includes
|
||||
* other devices' and other group members' records: the limit is per account.
|
||||
* EmailSubmission/query only sees records whose sendAt is at most three days
|
||||
* back, so older ones are left to makeRoomForSubmission.
|
||||
*
|
||||
@@ -160,8 +217,9 @@ export async function pruneEmailSubmissions(options: { force?: boolean } = {}):
|
||||
// Without submission there are no records, and a request naming the
|
||||
// capability would be refused whole.
|
||||
if (!jmapClient.hasCapability(CAPABILITIES.SUBMISSION)) return 0;
|
||||
for (const accountId of jmapClient.getSubmissionAccountIds()) {
|
||||
destroyed += await pruneSubmissionAccount(accountId, options.force ?? false);
|
||||
const [primary] = jmapClient.getSubmissionAccountIds();
|
||||
for (const accountId of submissionAccountIds()) {
|
||||
destroyed += await pruneSubmissionAccount(accountId, options.force ?? false, accountId === primary);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[submissions] prune failed:', err);
|
||||
@@ -169,7 +227,7 @@ export async function pruneEmailSubmissions(options: { force?: boolean } = {}):
|
||||
return destroyed;
|
||||
}
|
||||
|
||||
async function pruneSubmissionAccount(accountId: string, force: boolean): Promise<number> {
|
||||
async function pruneSubmissionAccount(accountId: string, force: boolean, primary: boolean): Promise<number> {
|
||||
const now = Date.now();
|
||||
const key = pruneKey(accountId);
|
||||
const lastRun = prunedAt.get(key);
|
||||
@@ -180,6 +238,15 @@ async function pruneSubmissionAccount(accountId: string, force: boolean): Promis
|
||||
if (prunable.length === 0) return 0;
|
||||
return (await destroyEmailSubmissions(prunable, accountId)).length;
|
||||
} catch (err) {
|
||||
// Stalwart 0.16 lists an account that shared only an address book or a
|
||||
// calendar with the submission capability, then refuses its records
|
||||
// ("You are not an owner of account ..."). Nothing of this login's is
|
||||
// there, so the account is left alone while the app runs.
|
||||
if (jmapErrorType(err) === 'forbidden' && !primary) {
|
||||
refusedAccounts.add(refusalKey(accountId));
|
||||
debug.log('email', `[submissions] ${accountId} refuses this login's submissions; not asking again`);
|
||||
return 0;
|
||||
}
|
||||
console.warn(`[submissions] pruning ${accountId} failed:`, err);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user