mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
Stalwart 1.0 counts every EmailSubmission record of an account against `maxSubmissions` (500 by default), whatever its status or age, and its own purge of old records never runs. Bulwark never destroyed a submission, so after about 500 JMAP sends an account could not send at all: every send, read receipt and reschedule failed with a raw "There are too many email submissions" error, and each failed attempt left one more copy of the message in Drafts. Bulwark now removes the records that can no longer be cancelled. Destroying a record never stops its delivery, but a pending one could no longer be cancelled, so pending records are never touched: - an immediate send (no hold, no undo window) destroys its own record right after the send, riding along with the old-draft cleanup; only where `urn:stalwart:jmap` is advertised, and never decided by the created `undoStatus`, which 0.16.10-0.16.16 report as final for held sends - a throttled background prune destroys canceled records and final records whose `sendAt` has passed, going by `EmailSubmission/get`, in every submission account; it runs at startup, after sends, after undo and on EmailSubmission state changes - a confirmed cancel (undo, cancel, reschedule) destroys the canceled record in a request of its own - an `overQuota` on a submission create prunes at once, on Stalwart walks the change log for records older than the query's three-day window, retries once, and otherwise throws `SubmissionQuotaError`, shown as a translated explanation in the composer, quick reply, read receipts and the scheduled-send actions Failed sends now clean up after themselves and say so: - a failed send removes the Drafts copy it created and keeps the user's draft; the raw send paths remove their import the same way - a read receipt that did not go out is taken back out of Sent, a method-level error counts as a failure, `$MDNSent` is only set after a sent receipt, and the automatic receipt no longer retries on every refresh of the open message - quick-reply failures, "Send now", "Reschedule" and a failed undo are shown instead of only logged - the scheduled list reads every page of `EmailSubmission/query`; it stopped after the first because `total` needs `calculateTotal`
167 lines
6.9 KiB
TypeScript
167 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
|
|
// #1009: a From override is asked for as the envelope MAIL FROM, so the
|
|
// Return-Path doesn't reveal the identity's address where the server allows it.
|
|
|
|
type MethodCall = [string, Record<string, unknown>, string];
|
|
|
|
function createClient(): JMAPClient {
|
|
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
|
Object.assign(client, {
|
|
apiUrl: 'https://jmap.example.com/api',
|
|
accountId: 'account-1',
|
|
username: 'user@example.com',
|
|
});
|
|
return client;
|
|
}
|
|
|
|
function enableDelayedSend(client: JMAPClient) {
|
|
Object.assign(client, {
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:core': {},
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': {},
|
|
},
|
|
session: {
|
|
primaryAccounts: {
|
|
'urn:ietf:params:jmap:mail': 'account-1',
|
|
'urn:ietf:params:jmap:submission': 'account-1',
|
|
},
|
|
accounts: {
|
|
'account-1': {
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const IDENTITIES = [
|
|
{ id: 'identity-1', email: 'user@example.com', mayDelete: false },
|
|
{ id: 'identity-2', email: 'Sales@example.com', mayDelete: true, replyTo: [{ email: 'team@example.com' }] },
|
|
];
|
|
|
|
/**
|
|
* Answers the requests sendEmail makes; `refuseMailFrom` makes every
|
|
* EmailSubmission/set whose envelope asks for another MAIL FROM than the
|
|
* identity's fail the way Stalwart does. `overQuotaOnce` refuses the first
|
|
* submission the way Stalwart 1.0 does at its `maxSubmissions` limit - before
|
|
* it looks at the envelope.
|
|
*/
|
|
function mockServer(options: { refuseMailFrom?: boolean; overQuotaOnce?: boolean } = {}) {
|
|
const submissions: Array<Record<string, unknown>> = [];
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
const { methodCalls } = JSON.parse((init as { body: string }).body) as { methodCalls: MethodCall[] };
|
|
const methodResponses = methodCalls.map(([name, args, callId]) => {
|
|
if (name === 'Mailbox/get') {
|
|
return [name, { list: [{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, { id: 'mb-sent', name: 'Sent', role: 'sent' }] }, callId];
|
|
}
|
|
if (name === 'Identity/get') return [name, { list: IDENTITIES }, callId];
|
|
if (name === 'Email/set') {
|
|
const created = Object.keys((args.create ?? {}) as object);
|
|
return [name, { created: Object.fromEntries(created.map((id) => [id, { id: 'email-9' }])) }, callId];
|
|
}
|
|
if (name === 'EmailSubmission/set') {
|
|
const create = (args.create as Record<string, Record<string, unknown>>)['1'];
|
|
submissions.push(create);
|
|
if (options.overQuotaOnce && submissions.length === 1) {
|
|
return [name, { notCreated: { '1': { type: 'overQuota', description: 'There are too many email submissions, please delete some before adding a new one.' } } }, callId];
|
|
}
|
|
const identity = IDENTITIES.find((i) => i.id === create.identityId);
|
|
const mailFrom = (create.envelope as { mailFrom?: { email: string } } | undefined)?.mailFrom?.email;
|
|
if (options.refuseMailFrom && mailFrom && mailFrom.toLowerCase() !== identity?.email.toLowerCase()) {
|
|
return [name, { notCreated: { '1': { type: 'forbiddenFrom', description: 'Envelope mailFrom does not match identity email address.' } } }, callId];
|
|
}
|
|
return [name, { created: { '1': { id: `sub-${submissions.length}` } } }, callId];
|
|
}
|
|
return ['error', { type: 'unknownMethod' }, callId];
|
|
});
|
|
const payload = { methodResponses };
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: () => Promise.resolve(JSON.stringify(payload)),
|
|
json: () => Promise.resolve(payload),
|
|
} as Response;
|
|
});
|
|
return submissions;
|
|
}
|
|
|
|
function send(client: JMAPClient, fromEmail: string, envelopeMailFrom?: string, delayedUntil?: string) {
|
|
return client.sendEmail(
|
|
['Recipient <rcpt@example.net>'],
|
|
'Subject',
|
|
'body',
|
|
undefined, undefined, 'identity-1', fromEmail,
|
|
undefined, undefined, undefined, undefined,
|
|
undefined, undefined,
|
|
delayedUntil,
|
|
envelopeMailFrom,
|
|
);
|
|
}
|
|
|
|
describe('JMAPClient.sendEmail envelope MAIL FROM (#1009)', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('asks for the From override as MAIL FROM', async () => {
|
|
const submissions = mockServer();
|
|
|
|
const result = await send(createClient(), 'alias@example.com', 'alias@example.com');
|
|
|
|
expect(submissions).toEqual([{
|
|
emailId: expect.stringMatching(/^#send-/),
|
|
identityId: 'identity-1',
|
|
envelope: { mailFrom: { email: 'alias@example.com' }, rcptTo: [{ email: 'rcpt@example.net' }] },
|
|
}]);
|
|
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-1' });
|
|
});
|
|
|
|
it("submits again with the identity's address when the server refuses the override", async () => {
|
|
const submissions = mockServer({ refuseMailFrom: true });
|
|
|
|
const result = await send(createClient(), 'alias@example.com', 'alias@example.com');
|
|
|
|
expect(submissions).toHaveLength(2);
|
|
expect(submissions[1]).toEqual({ emailId: 'email-9', identityId: 'identity-1' });
|
|
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-2' });
|
|
});
|
|
|
|
it("makes room at a full quota, then falls back to the identity's address on Stalwart 1.0", async () => {
|
|
const submissions = mockServer({ refuseMailFrom: true, overQuotaOnce: true });
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
const result = await send(createClient(), 'alias@example.com', 'alias@example.com');
|
|
|
|
const mailFroms = submissions.map((s) => (s.envelope as { mailFrom?: { email: string } } | undefined)?.mailFrom?.email ?? 'identity');
|
|
expect(mailFroms).toEqual(['alias@example.com', 'alias@example.com', 'identity']);
|
|
expect(submissions[1].emailId).toBe('email-9');
|
|
expect(result).toMatchObject({ emailId: 'email-9', emailSubmissionId: 'sub-3' });
|
|
});
|
|
|
|
it('sends through the identity that owns the override address', async () => {
|
|
const submissions = mockServer({ refuseMailFrom: true });
|
|
|
|
await send(createClient(), 'sales@example.com', 'sales@example.com');
|
|
|
|
expect(submissions).toEqual([{ emailId: expect.stringMatching(/^#send-/), identityId: 'identity-2' }]);
|
|
});
|
|
|
|
it("keeps the identity's address as MAIL FROM of a sub-addressed scheduled send", async () => {
|
|
const client = createClient();
|
|
enableDelayedSend(client);
|
|
const submissions = mockServer({ refuseMailFrom: true });
|
|
|
|
await send(client, 'user+shop@example.com', undefined, new Date(Date.now() + 60_000).toISOString());
|
|
|
|
expect(submissions).toHaveLength(1);
|
|
expect(submissions[0].envelope).toMatchObject({ mailFrom: { email: 'user@example.com' } });
|
|
});
|
|
});
|