mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
1256 lines
62 KiB
TypeScript
1256 lines
62 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { JMAPClient, NoLongerScheduledError, RecipientsRejectedError, SubmissionQuotaError, prunableSubmissionIds } from '../jmap/client';
|
|
import { jmapErrorType } from '../jmap/jmap-error';
|
|
|
|
/**
|
|
* Stalwart 1.0 counts every EmailSubmission record of an account against
|
|
* `maxSubmissions` (500 by default) and never purges them, so the client has
|
|
* to destroy the records that can no longer be cancelled. Destroying a
|
|
* pending one would not stop its mail, only make it impossible to cancel.
|
|
*
|
|
* The fake below answers the way Stalwart does:
|
|
* - 1.0 (2663169eb): the create returns {id, sendAt, undoStatus}, pending while
|
|
* queued; overQuota with Stalwart's description once the limit is reached;
|
|
* - 0.16.10-0.16.16: the create returns undoStatus final, even for a held send;
|
|
* - 0.16.6-0.16.9: the create returns {id} only;
|
|
* - every version: EmailSubmission/get computes pending while the message is
|
|
* queued, final once it left, canceled once canceled; the query only sees
|
|
* sendAt from three days back on; destroy removes the record, nothing else.
|
|
*/
|
|
|
|
type Version = '1.0' | '0.16.12' | '0.16.6';
|
|
type MethodCall = [string, Record<string, unknown>, string];
|
|
type SetError = { type: string; description?: string };
|
|
|
|
interface FakeSubmission {
|
|
id: string;
|
|
accountId: string;
|
|
emailId: string;
|
|
sendAt?: string;
|
|
canceled: boolean;
|
|
/** Still in the queue: held, or retrying. */
|
|
queued: boolean;
|
|
/** What the RCPT stage answered, per recipient (EmailSubmission deliveryStatus). */
|
|
deliveryStatus?: Record<string, { delivered: string; smtpReply: string; displayed: string }>;
|
|
/** The envelope the submission was created with, when it had one. */
|
|
envelope?: { mailFrom: { email: string }; rcptTo: Array<{ email: string }> };
|
|
}
|
|
|
|
// Stalwart's replies to recipients it refuses at RCPT (live, 0.16.23 and 1.0).
|
|
const RELAY_DENIED = '550 5.1.2 Relay not allowed.';
|
|
const RATE_LIMITED = '452 4.4.5 Rate limit exceeded, try again later.';
|
|
|
|
const OVER_QUOTA_DESCRIPTION = 'There are too many email submissions, please delete some before adding a new one.';
|
|
const HOUR = 60 * 60 * 1000;
|
|
const DAY = 24 * HOUR;
|
|
|
|
function utcDate(ms: number): string {
|
|
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
}
|
|
|
|
interface FakeOptions {
|
|
version?: Version;
|
|
/** Stalwart 1.0 `maxSubmissions`; null = unlimited. */
|
|
quota?: number | null;
|
|
/** Answer the query with every record, ignoring filter and window. */
|
|
queryIgnoresFilter?: boolean;
|
|
/** Override the answer to an EmailSubmission/set destroy. */
|
|
destroyAnswer?: (ids: string[], callId: string) => [string, Record<string, unknown>, string] | undefined;
|
|
/** Override the answer to an EmailSubmission/set create (after the quota check). */
|
|
createAnswer?: (creationId: string, create: Record<string, unknown>) => SetError | undefined;
|
|
/** Override the answer to an EmailSubmission/set update. */
|
|
updateAnswer?: (id: string, callId: string) => [string, Record<string, unknown>, string] | undefined;
|
|
/** Refuse Email/set creates with this error (storage quota). */
|
|
emailCreateError?: SetError;
|
|
/** Answer a call before the fake does. */
|
|
intercept?: (call: MethodCall) => Array<[string, Record<string, unknown>, string]> | undefined;
|
|
/** The SMTP reply the RCPT stage refuses a recipient with; undefined takes it. */
|
|
refuseRecipient?: (email: string) => string | undefined;
|
|
}
|
|
|
|
function fakeStalwart(options: FakeOptions = {}) {
|
|
const version = options.version ?? '1.0';
|
|
const submissions = new Map<string, FakeSubmission>();
|
|
const emails = new Map<string, { accountId: string; mailboxIds: Record<string, boolean>; recipients?: string[] }>();
|
|
const requests: MethodCall[][] = [];
|
|
let next = 1;
|
|
|
|
const newId = (prefix: string) => `${prefix}-${next++}`;
|
|
|
|
function computedStatus(s: FakeSubmission): 'pending' | 'final' | 'canceled' {
|
|
if (s.canceled) return 'canceled';
|
|
return s.queued ? 'pending' : 'final';
|
|
}
|
|
|
|
function add(record: Partial<FakeSubmission> & { accountId: string }): FakeSubmission {
|
|
const submission: FakeSubmission = {
|
|
id: record.id ?? newId('sub'),
|
|
emailId: record.emailId ?? newId('email'),
|
|
canceled: false,
|
|
queued: false,
|
|
...record,
|
|
};
|
|
submissions.set(submission.id, submission);
|
|
return submission;
|
|
}
|
|
|
|
function matches(s: FakeSubmission, filter: Record<string, unknown> | undefined): boolean {
|
|
if (!filter) return true;
|
|
if (filter.operator === 'OR') return (filter.conditions as Array<Record<string, unknown>>).some((c) => matches(s, c));
|
|
if (filter.operator === 'AND') return (filter.conditions as Array<Record<string, unknown>>).every((c) => matches(s, c));
|
|
if (typeof filter.before === 'string') return !!s.sendAt && Date.parse(s.sendAt) < Date.parse(filter.before);
|
|
if (typeof filter.undoStatus === 'string') return computedStatus(s) === filter.undoStatus;
|
|
return true;
|
|
}
|
|
|
|
function handle(call: MethodCall, earlier: Array<[string, Record<string, unknown>, string]>): Array<[string, Record<string, unknown>, string]> {
|
|
const intercepted = options.intercept?.(call);
|
|
if (intercepted) return intercepted;
|
|
const [name, rawArgs, callId] = call;
|
|
const args = { ...rawArgs };
|
|
const ref = args['#ids'] as { resultOf: string; path: string } | undefined;
|
|
if (ref) {
|
|
const source = earlier.find(([, , id]) => id === ref.resultOf);
|
|
if (!source || source[0] === 'error') return [['error', { type: 'invalidResultReference' }, callId]];
|
|
args.ids = source[1][ref.path.replace(/^\//, '')];
|
|
}
|
|
const accountId = args.accountId as string;
|
|
|
|
switch (name) {
|
|
case 'Mailbox/get':
|
|
return [[name, { accountId, list: [
|
|
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
|
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
|
] }, callId]];
|
|
case 'Identity/get':
|
|
return [[name, { accountId, list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] }, callId]];
|
|
case 'Email/get':
|
|
return [[name, { accountId, list: ((args.ids as string[] | undefined) ?? []).map((id) => ({
|
|
id, threadId: 'thread-1', mailboxIds: { 'mb-drafts': true }, keywords: {}, to: [{ email: 'bob@example.net' }], subject: 'Subject',
|
|
})), notFound: [] }, callId]];
|
|
case 'Email/import': {
|
|
const created: Record<string, { id: string }> = {};
|
|
for (const [key, value] of Object.entries(args.emails as Record<string, { mailboxIds: Record<string, boolean> }>)) {
|
|
const id = newId('imported');
|
|
emails.set(id, { accountId, mailboxIds: value.mailboxIds });
|
|
created[key] = { id };
|
|
}
|
|
return [[name, { accountId, created }, callId]];
|
|
}
|
|
case 'Email/set': {
|
|
const result: Record<string, unknown> = { accountId };
|
|
if (args.create) {
|
|
const created: Record<string, { id: string }> = {};
|
|
const notCreated: Record<string, SetError> = {};
|
|
for (const [key, value] of Object.entries(args.create as Record<string, { mailboxIds: Record<string, boolean>; to?: Array<{ email: string }>; cc?: Array<{ email: string }>; bcc?: Array<{ email: string }> }>)) {
|
|
if (options.emailCreateError) {
|
|
notCreated[key] = options.emailCreateError;
|
|
continue;
|
|
}
|
|
const id = newId('email');
|
|
const recipients = [...(value.to ?? []), ...(value.cc ?? []), ...(value.bcc ?? [])].map((a) => a.email);
|
|
emails.set(id, { accountId, mailboxIds: value.mailboxIds, recipients });
|
|
created[key] = { id };
|
|
}
|
|
if (Object.keys(created).length) result.created = created;
|
|
if (Object.keys(notCreated).length) result.notCreated = notCreated;
|
|
}
|
|
if (args.update) {
|
|
result.updated = Object.fromEntries(Object.keys(args.update as object).map((id) => [id, null]));
|
|
}
|
|
if (args.destroy) {
|
|
const destroyed: string[] = [];
|
|
const notDestroyed: Record<string, SetError> = {};
|
|
for (const id of args.destroy as string[]) {
|
|
if (emails.delete(id) || id.startsWith('draft-')) destroyed.push(id);
|
|
else notDestroyed[id] = { type: 'notFound' };
|
|
}
|
|
result.destroyed = destroyed;
|
|
if (Object.keys(notDestroyed).length) result.notDestroyed = notDestroyed;
|
|
}
|
|
return [[name, result, callId]];
|
|
}
|
|
case 'EmailSubmission/set': {
|
|
const destroy = args.destroy as string[] | undefined;
|
|
if (destroy) {
|
|
const answer = options.destroyAnswer?.(destroy, callId);
|
|
if (answer) return [answer];
|
|
const destroyed: string[] = [];
|
|
const notDestroyed: Record<string, SetError> = {};
|
|
for (const id of destroy) {
|
|
if (submissions.delete(id)) destroyed.push(id);
|
|
else notDestroyed[id] = { type: 'notFound' };
|
|
}
|
|
return [[name, { accountId, destroyed, ...(Object.keys(notDestroyed).length ? { notDestroyed } : {}) }, callId]];
|
|
}
|
|
if (args.update) {
|
|
const [id] = Object.keys(args.update as object);
|
|
const answer = options.updateAnswer?.(id, callId);
|
|
if (answer) return [answer];
|
|
const submission = submissions.get(id);
|
|
if (!submission) return [[name, { accountId, notUpdated: { [id]: { type: 'notFound' } } }, callId]];
|
|
if (!submission.queued) {
|
|
return [[name, { accountId, notUpdated: { [id]: { type: 'cannotUnsend', description: 'The requested message is no longer in the queue.' } } }, callId]];
|
|
}
|
|
submission.canceled = true;
|
|
submission.queued = false;
|
|
return [[name, { accountId, updated: { [id]: null } }, callId]];
|
|
}
|
|
const created: Record<string, Record<string, unknown>> = {};
|
|
const notCreated: Record<string, SetError> = {};
|
|
const successEmails: Record<string, string> = {};
|
|
for (const [key, create] of Object.entries(args.create as Record<string, Record<string, unknown>>)) {
|
|
const used = [...submissions.values()].filter((s) => s.accountId === accountId).length;
|
|
if (version === '1.0' && options.quota != null && used >= options.quota) {
|
|
notCreated[key] = { type: 'overQuota', description: OVER_QUOTA_DESCRIPTION };
|
|
continue;
|
|
}
|
|
const refused = options.createAnswer?.(key, create);
|
|
if (refused) {
|
|
notCreated[key] = refused;
|
|
continue;
|
|
}
|
|
let emailId = create.emailId as string;
|
|
if (emailId.startsWith('#')) {
|
|
const source = earlier.find(([method]) => method === 'Email/set' || method === 'Email/import');
|
|
emailId = ((source?.[1].created as Record<string, { id: string }> | undefined)?.[emailId.slice(1)]?.id) ?? '';
|
|
if (!emailId) {
|
|
notCreated[key] = { type: 'invalidProperties', description: 'emailId not found' };
|
|
continue;
|
|
}
|
|
}
|
|
const holdFor = Number((create.envelope as { mailFrom?: { parameters?: { HOLDFOR?: string } } } | undefined)?.mailFrom?.parameters?.HOLDFOR ?? 0);
|
|
// The RCPT stage runs while the submission is created, held or
|
|
// not. A refused recipient is not queued; with none taken, nothing is.
|
|
const recipients = (create.envelope as { rcptTo?: Array<{ email: string }> } | undefined)?.rcptTo?.map((r) => r.email)
|
|
?? emails.get(emailId)?.recipients ?? [];
|
|
const deliveryStatus = Object.fromEntries(recipients.map((email) => {
|
|
const reply = options.refuseRecipient?.(email);
|
|
return [email, reply
|
|
? { delivered: 'no', smtpReply: reply, displayed: 'unknown' }
|
|
: { delivered: 'queued', smtpReply: '250 2.1.5 Queued', displayed: 'unknown' }];
|
|
}));
|
|
const taken = recipients.length === 0 || Object.values(deliveryStatus).some((d) => d.delivered !== 'no');
|
|
const submission = add({ accountId, emailId, sendAt: utcDate(Date.now() + holdFor * 1000), queued: taken, deliveryStatus });
|
|
successEmails[key] = emailId;
|
|
created[key] = version === '0.16.6'
|
|
? { id: submission.id }
|
|
: { id: submission.id, sendAt: submission.sendAt, undoStatus: version === '1.0' && taken ? 'pending' : 'final' };
|
|
}
|
|
const responses: Array<[string, Record<string, unknown>, string]> = [[name, {
|
|
accountId,
|
|
...(Object.keys(created).length ? { created } : {}),
|
|
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
|
}, callId]];
|
|
// The implicit Email/set of onSuccessUpdateEmail / onSuccessDestroyEmail.
|
|
if (Object.keys(created).length && (args.onSuccessUpdateEmail || args.onSuccessDestroyEmail)) {
|
|
const implicit: Record<string, unknown> = { accountId };
|
|
if (args.onSuccessUpdateEmail) {
|
|
implicit.updated = Object.fromEntries(Object.keys(args.onSuccessUpdateEmail as object)
|
|
.map((ref) => [successEmails[ref.slice(1)] ?? ref, null]));
|
|
}
|
|
if (args.onSuccessDestroyEmail) {
|
|
implicit.destroyed = (args.onSuccessDestroyEmail as string[]).map((ref) => {
|
|
const id = successEmails[ref.slice(1)] ?? ref;
|
|
emails.delete(id);
|
|
return id;
|
|
});
|
|
}
|
|
responses.push(['Email/set', implicit, callId]);
|
|
}
|
|
return responses;
|
|
}
|
|
case 'EmailSubmission/query': {
|
|
const now = Date.now();
|
|
const all = [...submissions.values()].filter((s) => s.accountId === accountId);
|
|
const visible = options.queryIgnoresFilter
|
|
? all
|
|
: all
|
|
.filter((s) => !!s.sendAt && Date.parse(s.sendAt) >= now - 3 * DAY)
|
|
.filter((s) => matches(s, args.filter as Record<string, unknown> | undefined))
|
|
.sort((a, b) => Date.parse(a.sendAt!) - Date.parse(b.sendAt!));
|
|
const position = (args.position as number | undefined) ?? 0;
|
|
const limit = (args.limit as number | undefined) ?? 5000;
|
|
const ids = visible.slice(position, position + limit).map((s) => s.id);
|
|
return [[name, {
|
|
accountId, queryState: 'q1', canCalculateChanges: true, position, ids,
|
|
...(args.calculateTotal ? { total: visible.length } : {}),
|
|
}, callId]];
|
|
}
|
|
case 'EmailSubmission/get': {
|
|
// A creation id ("#1") names a submission created earlier in the same
|
|
// request; unknown, the whole call is refused (as Stalwart does).
|
|
const created = earlier.find(([method]) => method === 'EmailSubmission/set')?.[1].created as Record<string, { id: string }> | undefined;
|
|
const requested = (args.ids as string[] | null) ?? [];
|
|
const unresolved = requested.find((id) => id.startsWith('#') && !created?.[id.slice(1)]);
|
|
if (unresolved) {
|
|
return [['error', { type: 'invalidResultReference', description: `Id reference "${unresolved.slice(1)}" not found.` }, callId]];
|
|
}
|
|
const ids = requested.map((id) => (id.startsWith('#') ? created![id.slice(1)].id : id));
|
|
const list: Array<Record<string, unknown>> = [];
|
|
const notFound: string[] = [];
|
|
for (const id of ids) {
|
|
const s = submissions.get(id);
|
|
if (!s || s.accountId !== accountId) {
|
|
notFound.push(id);
|
|
continue;
|
|
}
|
|
list.push({
|
|
id: s.id, emailId: s.emailId, identityId: 'identity-1', ...(s.sendAt ? { sendAt: s.sendAt } : {}), undoStatus: computedStatus(s),
|
|
...(s.deliveryStatus ? { deliveryStatus: s.deliveryStatus } : {}),
|
|
...(s.envelope ? { envelope: s.envelope } : {}),
|
|
});
|
|
}
|
|
return [[name, { accountId, state: 's1', list, notFound }, callId]];
|
|
}
|
|
case 'EmailSubmission/changes': {
|
|
const created = [...submissions.values()].filter((s) => s.accountId === accountId).map((s) => s.id);
|
|
return [[name, { accountId, oldState: args.sinceState, newState: 's-final', hasMoreChanges: false, created, updated: [], destroyed: [] }, callId]];
|
|
}
|
|
default:
|
|
return [['error', { type: 'unknownMethod' }, callId]];
|
|
}
|
|
}
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
const body = JSON.parse((init as { body: string }).body) as { methodCalls: MethodCall[] };
|
|
requests.push(body.methodCalls);
|
|
const methodResponses: Array<[string, Record<string, unknown>, string]> = [];
|
|
for (const call of body.methodCalls) methodResponses.push(...handle(call, methodResponses));
|
|
const payload = { methodResponses };
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: () => Promise.resolve(JSON.stringify(payload)),
|
|
json: () => Promise.resolve(payload),
|
|
} as Response;
|
|
});
|
|
|
|
/** Every call of `method` across all requests, in wire order. */
|
|
const calls = (method: string) => requests.flat().filter(([name]) => name === method).map(([, args]) => args);
|
|
const destroys = () => calls('EmailSubmission/set').filter((args) => args.destroy);
|
|
const destroyedIds = () => destroys().flatMap((args) => args.destroy as string[]);
|
|
|
|
return { submissions, emails, requests, calls, destroys, destroyedIds, add };
|
|
}
|
|
|
|
interface ClientOptions {
|
|
stalwart?: boolean;
|
|
accounts?: string[];
|
|
maxObjectsInSet?: number;
|
|
maxObjectsInGet?: number;
|
|
delayedSend?: boolean;
|
|
}
|
|
|
|
function createClient({ stalwart = true, accounts = ['account-1'], maxObjectsInSet = 500, maxObjectsInGet = 500, delayedSend = true }: ClientOptions = {}): JMAPClient {
|
|
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
|
const submissionCapability = delayedSend
|
|
? { maxDelayedSend: 7 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: [] } }
|
|
: {};
|
|
Object.assign(client, {
|
|
apiUrl: 'https://jmap.example.com/api',
|
|
accountId: 'account-1',
|
|
username: 'user@example.com',
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:core': { maxObjectsInSet, maxObjectsInGet },
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': {},
|
|
...(stalwart ? { 'urn:stalwart:jmap': {} } : {}),
|
|
},
|
|
session: {
|
|
primaryAccounts: {
|
|
'urn:ietf:params:jmap:mail': 'account-1',
|
|
'urn:ietf:params:jmap:submission': 'account-1',
|
|
},
|
|
accounts: Object.fromEntries(accounts.map((id) => [id, {
|
|
name: id === 'account-1' ? 'user@example.com' : `${id}@example.com`,
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:submission': submissionCapability,
|
|
...(stalwart ? { 'urn:stalwart:jmap': {} } : {}),
|
|
},
|
|
}])),
|
|
},
|
|
});
|
|
return client;
|
|
}
|
|
|
|
function send(client: JMAPClient, opts: { draftId?: string; delayedUntil?: string } = {}) {
|
|
return client.sendEmail(
|
|
['bob@example.net'], 'Subject', 'body',
|
|
undefined, undefined, 'identity-1', 'user@example.com',
|
|
opts.draftId,
|
|
undefined, undefined, undefined, undefined, undefined,
|
|
opts.delayedUntil,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('destroying the record of an immediate send', () => {
|
|
it('destroys it in its own request after the send on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
|
|
const result = await send(createClient());
|
|
|
|
expect(result.emailSubmissionId).toBeDefined();
|
|
expect(result.filingError).toBeUndefined();
|
|
const last = server.requests.at(-1)!;
|
|
expect(last).toEqual([['EmailSubmission/set', { accountId: 'account-1', destroy: [result.emailSubmissionId] }, '0']]);
|
|
expect(server.submissions.size).toBe(0);
|
|
});
|
|
|
|
it('rides along with the old-draft cleanup on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
|
|
const result = await send(createClient(), { draftId: 'draft-old-1' });
|
|
|
|
const cleanup = server.requests.at(-1)!;
|
|
expect(cleanup).toEqual([
|
|
['Email/set', { accountId: 'account-1', destroy: ['draft-old-1'] }, '0'],
|
|
['EmailSubmission/set', { accountId: 'account-1', destroy: [result.emailSubmissionId] }, '1'],
|
|
]);
|
|
expect(result.filingError).toBeUndefined();
|
|
});
|
|
|
|
it('does not turn a refused destroy into a filing warning on Stalwart 1.0', async () => {
|
|
fakeStalwart({
|
|
version: '1.0',
|
|
destroyAnswer: (ids, callId) => ['EmailSubmission/set', { notDestroyed: { [ids[0]]: { type: 'forbidden' } } }, callId],
|
|
});
|
|
|
|
const alone = await send(createClient());
|
|
const withDraft = await send(createClient(), { draftId: 'draft-old-1' });
|
|
|
|
expect(alone.filingError).toBeUndefined();
|
|
expect(withDraft.filingError).toBeUndefined();
|
|
});
|
|
|
|
it('does not turn a failed destroy call into a filing warning on Stalwart 1.0', async () => {
|
|
fakeStalwart({
|
|
version: '1.0',
|
|
destroyAnswer: (_ids, callId) => ['error', { type: 'serverFail', description: 'boom' }, callId],
|
|
});
|
|
|
|
const result = await send(createClient(), { draftId: 'draft-old-1' });
|
|
|
|
expect(result.filingError).toBeUndefined();
|
|
expect(result.emailSubmissionId).toBeDefined();
|
|
});
|
|
|
|
it('still reports a refused old-draft destroy riding with it on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
|
|
// 'kept-draft' is unknown to the fake, so its destroy comes back notFound.
|
|
const result = await send(createClient(), { draftId: 'kept-draft' });
|
|
|
|
expect(result.filingError).toBe('notFound');
|
|
expect(server.destroyedIds()).toEqual([result.emailSubmissionId]);
|
|
});
|
|
|
|
it('destroys the {id}-only record 0.16.6 creates', async () => {
|
|
const server = fakeStalwart({ version: '0.16.6' });
|
|
|
|
const result = await send(createClient());
|
|
|
|
expect(server.destroyedIds()).toEqual([result.emailSubmissionId]);
|
|
});
|
|
|
|
it('leaves a held send alone although 0.16.12 reports it final', async () => {
|
|
const server = fakeStalwart({ version: '0.16.12' });
|
|
|
|
const result = await send(createClient(), { delayedUntil: new Date(Date.now() + 10_000).toISOString() });
|
|
|
|
expect(result.scheduled).toBe(true);
|
|
expect(server.destroys()).toEqual([]);
|
|
expect(server.submissions.has(result.emailSubmissionId!)).toBe(true);
|
|
});
|
|
|
|
it('leaves undo-window and scheduled sends alone on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
const client = createClient();
|
|
|
|
await send(client, { delayedUntil: new Date(Date.now() + 30_000).toISOString() });
|
|
await send(client, { delayedUntil: new Date(Date.now() + 2 * HOUR).toISOString(), draftId: 'draft-old-1' });
|
|
|
|
expect(server.destroys()).toEqual([]);
|
|
expect(server.submissions.size).toBe(2);
|
|
});
|
|
|
|
it('leaves the record alone without urn:stalwart:jmap', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
|
|
await send(createClient({ stalwart: false }));
|
|
await send(createClient({ stalwart: false }), { draftId: 'draft-old-1' });
|
|
|
|
expect(server.destroys()).toEqual([]);
|
|
});
|
|
|
|
it('destroys the records of immediate raw sends on Stalwart 1.0, not of held ones', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
const client = createClient();
|
|
vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'blob-1', size: 10, type: 'message/rfc822' });
|
|
const raw = new Blob(['raw'], { type: 'message/rfc822' });
|
|
|
|
const sent = await client.sendRawEmail(raw, 'identity-1', 'mb-sent', 'mb-drafts');
|
|
const submitted = await client.submitRawEmail(raw, 'identity-1');
|
|
const held = await client.sendRawEmail(raw, 'identity-1', 'mb-sent', 'mb-drafts', new Date(Date.now() + 60_000).toISOString());
|
|
|
|
expect(server.destroyedIds()).toEqual([sent.emailSubmissionId, submitted.emailSubmissionId]);
|
|
expect(server.submissions.has(held.emailSubmissionId!)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('prunableSubmissionIds', () => {
|
|
it('picks canceled records and final ones whose sendAt has passed', () => {
|
|
const now = Date.now();
|
|
const ids = prunableSubmissionIds([
|
|
{ id: 'pending-future', undoStatus: 'pending', sendAt: utcDate(now + HOUR) },
|
|
{ id: 'pending-past', undoStatus: 'pending', sendAt: utcDate(now - HOUR) },
|
|
{ id: 'final-past', undoStatus: 'final', sendAt: utcDate(now - HOUR) },
|
|
{ id: 'final-future', undoStatus: 'final', sendAt: utcDate(now + HOUR) },
|
|
{ id: 'canceled-future', undoStatus: 'canceled', sendAt: utcDate(now + HOUR) },
|
|
{ id: 'canceled-no-sendAt', undoStatus: 'canceled' },
|
|
{ id: 'final-no-sendAt', undoStatus: 'final' },
|
|
], now);
|
|
|
|
expect(ids).toEqual(['final-past', 'canceled-future', 'canceled-no-sendAt']);
|
|
});
|
|
});
|
|
|
|
describe('pruneEmailSubmissions', () => {
|
|
/** One record of each kind in `accountId`. */
|
|
function seed(server: ReturnType<typeof fakeStalwart>, accountId: string) {
|
|
const now = Date.now();
|
|
return {
|
|
pendingFuture: server.add({ accountId, id: `${accountId}-pending-future`, sendAt: utcDate(now + HOUR), queued: true }),
|
|
pendingPast: server.add({ accountId, id: `${accountId}-pending-past`, sendAt: utcDate(now - HOUR), queued: true }),
|
|
finalPast: server.add({ accountId, id: `${accountId}-final-past`, sendAt: utcDate(now - HOUR) }),
|
|
canceledFuture: server.add({ accountId, id: `${accountId}-canceled-future`, sendAt: utcDate(now + HOUR), canceled: true }),
|
|
noSendAt: server.add({ accountId, id: `${accountId}-no-sendAt` }),
|
|
};
|
|
}
|
|
|
|
it('destroys canceled and delivered records in every submission account on Stalwart 1.0, never pending ones', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
seed(server, 'account-1');
|
|
seed(server, 'shared-1');
|
|
const client = createClient({ accounts: ['account-1', 'shared-1'], maxObjectsInSet: 1 });
|
|
|
|
const destroyed = await client.pruneEmailSubmissions();
|
|
|
|
expect(destroyed).toBe(4);
|
|
// One destroy per record at maxObjectsInSet 1, each in its own account.
|
|
expect(server.destroys().map((d) => `${d.accountId}: ${(d.destroy as string[]).join()}`).sort()).toEqual([
|
|
'account-1: account-1-canceled-future', 'account-1: account-1-final-past',
|
|
'shared-1: shared-1-canceled-future', 'shared-1: shared-1-final-past',
|
|
]);
|
|
expect([...server.submissions.keys()].sort()).toEqual([
|
|
'account-1-no-sendAt', 'account-1-pending-future', 'account-1-pending-past',
|
|
'shared-1-no-sendAt', 'shared-1-pending-future', 'shared-1-pending-past',
|
|
]);
|
|
// Each account is asked in its own request, candidates only.
|
|
const queries = server.calls('EmailSubmission/query');
|
|
expect(queries.map((q) => q.accountId)).toEqual(['account-1', 'shared-1']);
|
|
expect(queries[0].filter).toEqual({ operator: 'OR', conditions: [{ before: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/) }, { undoStatus: 'canceled' }] });
|
|
expect(queries[0].calculateTotal).toBe(true);
|
|
expect(server.calls('EmailSubmission/get')[0].properties).toEqual(['id', 'sendAt', 'undoStatus']);
|
|
});
|
|
|
|
it('decides from EmailSubmission/get, not from what the query returns (0.16.12)', async () => {
|
|
const server = fakeStalwart({ version: '0.16.12', queryIgnoresFilter: true });
|
|
seed(server, 'account-1');
|
|
|
|
await createClient().pruneEmailSubmissions();
|
|
|
|
expect(server.destroyedIds().sort()).toEqual(['account-1-canceled-future', 'account-1-final-past']);
|
|
});
|
|
|
|
it('pages through the query and batches destroys by maxObjectsInSet on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
const now = Date.now();
|
|
for (let i = 0; i < 5; i++) server.add({ accountId: 'account-1', id: `old-${i}`, sendAt: utcDate(now - (i + 1) * HOUR) });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(now + HOUR), queued: true });
|
|
|
|
await createClient({ maxObjectsInGet: 2, maxObjectsInSet: 2 }).pruneEmailSubmissions();
|
|
|
|
expect(server.calls('EmailSubmission/query').map((q) => q.position)).toEqual([0, 2, 4]);
|
|
expect(server.destroys().map((d) => (d.destroy as string[]).length)).toEqual([2, 2, 1]);
|
|
expect([...server.submissions.keys()]).toEqual(['held']);
|
|
});
|
|
|
|
it('runs at most once per account every ten minutes unless forced on Stalwart 1.0', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
const client = createClient();
|
|
|
|
await client.pruneEmailSubmissions();
|
|
await client.pruneEmailSubmissions();
|
|
expect(server.calls('EmailSubmission/query')).toHaveLength(1);
|
|
|
|
await client.pruneEmailSubmissions({ force: true });
|
|
expect(server.calls('EmailSubmission/query')).toHaveLength(2);
|
|
|
|
vi.setSystemTime(Date.now() + 11 * 60 * 1000);
|
|
await client.pruneEmailSubmissions();
|
|
expect(server.calls('EmailSubmission/query')).toHaveLength(3);
|
|
});
|
|
|
|
it('never rejects when the server is unreachable', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
|
|
|
|
await expect(createClient().pruneEmailSubmissions()).resolves.toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('overQuota on EmailSubmission/set create', () => {
|
|
/** Fill the account with records the query no longer sees (sendAt 5 days back). */
|
|
function fillWithOldRecords(server: ReturnType<typeof fakeStalwart>, count: number, queued = false) {
|
|
const sendAt = utcDate(Date.now() - 5 * DAY);
|
|
for (let i = 0; i < count; i++) server.add({ accountId: 'account-1', id: `old-${i}`, sendAt, queued });
|
|
}
|
|
|
|
it('prunes, walks the change log and submits again on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 3 });
|
|
fillWithOldRecords(server, 3);
|
|
|
|
const result = await send(createClient(), { draftId: 'draft-old-1' });
|
|
|
|
expect(result.emailSubmissionId).toBeDefined();
|
|
const methods = server.requests.map((r) => r.map(([name, args]) => (
|
|
name === 'EmailSubmission/set' ? `${name} ${Object.keys(args).find((k) => ['create', 'destroy', 'update'].includes(k))}` : name
|
|
)).join(' + '));
|
|
// Each create carries the get of its delivery status.
|
|
expect(methods).toEqual([
|
|
'Mailbox/get',
|
|
'Identity/get',
|
|
'Email/set + EmailSubmission/set create + EmailSubmission/get',
|
|
'EmailSubmission/query + EmailSubmission/get',
|
|
'EmailSubmission/changes',
|
|
'EmailSubmission/get',
|
|
'EmailSubmission/set destroy',
|
|
'EmailSubmission/set create + EmailSubmission/get',
|
|
'Email/set + EmailSubmission/set destroy',
|
|
]);
|
|
expect(server.calls('EmailSubmission/changes')[0]).toEqual({ accountId: 'account-1', sinceState: 'n' });
|
|
// The retry submits the Drafts copy the first attempt created.
|
|
const creates = server.calls('EmailSubmission/set').filter((args) => args.create);
|
|
expect((creates[1].create as Record<string, { emailId: string }>)['1'].emailId).toBe(result.emailId);
|
|
expect(result.filingError).toBeUndefined();
|
|
});
|
|
|
|
it('does not walk the change log when the prune made room on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 2 });
|
|
server.add({ accountId: 'account-1', id: 'recent-final', sendAt: utcDate(Date.now() - HOUR) });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
|
|
const result = await send(createClient());
|
|
|
|
expect(result.emailSubmissionId).toBeDefined();
|
|
expect(server.calls('EmailSubmission/changes')).toEqual([]);
|
|
expect(server.submissions.has('held')).toBe(true);
|
|
});
|
|
|
|
it('only prunes when a server without urn:stalwart:jmap answers overQuota', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 1 });
|
|
fillWithOldRecords(server, 1);
|
|
|
|
const error = await send(createClient({ stalwart: false })).catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(SubmissionQuotaError);
|
|
// Its overQuota need not be about submission records: no Stalwart wording.
|
|
expect((error as SubmissionQuotaError).countsRecords).toBe(false);
|
|
expect(server.calls('EmailSubmission/query')).toHaveLength(1);
|
|
expect(server.calls('EmailSubmission/changes')).toEqual([]);
|
|
});
|
|
|
|
it('throws SubmissionQuotaError and removes the Drafts copy when the retry fails too on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 2 });
|
|
// Held for later: nothing may be destroyed.
|
|
server.add({ accountId: 'account-1', id: 'held-1', sendAt: utcDate(Date.now() + DAY), queued: true });
|
|
server.add({ accountId: 'account-1', id: 'held-2', sendAt: utcDate(Date.now() + 2 * DAY), queued: true });
|
|
|
|
const error = await send(createClient(), { draftId: 'draft-old-1' }).catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(SubmissionQuotaError);
|
|
expect((error as Error).message).toBe(OVER_QUOTA_DESCRIPTION);
|
|
expect((error as SubmissionQuotaError).countsRecords).toBe(true);
|
|
expect(jmapErrorType(error)).toBe('overQuota');
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(2);
|
|
// The copy made for this attempt is gone; the user's own draft stays.
|
|
const destroyedEmails = server.calls('Email/set').flatMap((args) => (args.destroy as string[] | undefined) ?? []);
|
|
expect(destroyedEmails).toHaveLength(1);
|
|
expect(destroyedEmails[0]).toMatch(/^email-/);
|
|
expect(destroyedEmails).not.toContain('draft-old-1');
|
|
expect(server.emails.size).toBe(0);
|
|
expect([...server.submissions.keys()].sort()).toEqual(['held-1', 'held-2']);
|
|
});
|
|
|
|
it('does not take the storage overQuota of Email/set for a submission quota on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', emailCreateError: { type: 'overQuota', description: 'Account quota exceeded.' } });
|
|
|
|
const error = await send(createClient()).catch((e: unknown) => e);
|
|
|
|
expect(error).not.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(jmapErrorType(error)).toBe('overQuota');
|
|
expect((error as Error).message).toContain('Account quota exceeded.');
|
|
expect(server.calls('EmailSubmission/query')).toEqual([]);
|
|
});
|
|
|
|
describe('a reschedule', () => {
|
|
const reschedule = (client: JMAPClient, delayedUntil: string) =>
|
|
client.rescheduleEmailSubmission('original', 'email-x', 'identity-1', delayedUntil, 'account-1');
|
|
|
|
// The original is canceled, and its record destroyed, before the
|
|
// replacement is created (#1308), so the replacement takes its place.
|
|
it('needs no room at the limit on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 2 });
|
|
server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + DAY), queued: true });
|
|
|
|
const result = await reschedule(createClient(), new Date(Date.now() + 2 * HOUR).toISOString());
|
|
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(1);
|
|
expect(server.calls('EmailSubmission/query')).toEqual([]);
|
|
expect(server.destroyedIds()).toEqual(['original']);
|
|
expect([...server.submissions.keys()].sort()).toEqual(['held', result.emailSubmissionId].sort());
|
|
});
|
|
|
|
it('throws SubmissionQuotaError when no room can be made, on Stalwart 1.0', async () => {
|
|
// Over the limit already, as after the administrator lowered it.
|
|
const server = fakeStalwart({ version: '1.0', quota: 2 });
|
|
const original = server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
server.add({ accountId: 'account-1', id: 'held-1', sendAt: utcDate(Date.now() + DAY), queued: true });
|
|
server.add({ accountId: 'account-1', id: 'held-2', sendAt: utcDate(Date.now() + 2 * DAY), queued: true });
|
|
|
|
const error = await reschedule(createClient(), new Date(Date.now() + 2 * HOUR).toISOString()).catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(SubmissionQuotaError);
|
|
expect(original.canceled).toBe(true);
|
|
// The replacement and the restore of the original, each tried again after the prune.
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(4);
|
|
expect([...server.submissions.keys()].sort()).toEqual(['held-1', 'held-2']);
|
|
});
|
|
|
|
it('stops without a replacement when the original went out already on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() - 1000) });
|
|
|
|
// "Send now" of a message due any moment
|
|
const error = await reschedule(createClient(), new Date(Date.now() + 1000).toISOString()).catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(NoLongerScheduledError);
|
|
expect((error as NoLongerScheduledError).sent).toBe(true);
|
|
// A create would have sent the message twice.
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toEqual([]);
|
|
});
|
|
|
|
it('stops when the original was cancelled elsewhere on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), canceled: true });
|
|
|
|
const error = await reschedule(createClient(), new Date(Date.now() + 2 * HOUR).toISOString()).catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(NoLongerScheduledError);
|
|
expect((error as NoLongerScheduledError).sent).toBe(false);
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toEqual([]);
|
|
});
|
|
|
|
it('takes the time spent off the hold of the replacement on Stalwart 1.0', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
const start = Date.now();
|
|
const server = fakeStalwart({
|
|
version: '1.0',
|
|
quota: 2,
|
|
// Walking the change log takes ten minutes.
|
|
intercept: ([name]) => {
|
|
if (name === 'EmailSubmission/changes') vi.setSystemTime(Date.now() + 10 * 60 * 1000);
|
|
return undefined;
|
|
},
|
|
});
|
|
server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(start + 3 * HOUR), queued: true });
|
|
fillWithOldRecords(server, 2);
|
|
|
|
const result = await reschedule(createClient(), new Date(start + 2 * HOUR).toISOString());
|
|
|
|
const holds = server.calls('EmailSubmission/set')
|
|
.filter((args) => args.create)
|
|
.map((args) => (args.create as Record<string, { envelope: { mailFrom: { parameters: { HOLDFOR: string } } } }>)
|
|
.replacement.envelope.mailFrom.parameters.HOLDFOR);
|
|
expect(holds.map(Number)).toEqual([2 * 60 * 60, 2 * 60 * 60 - 10 * 60]);
|
|
expect(Math.abs(Date.parse(result.sendAt!) - (start + 2 * HOUR))).toBeLessThan(1000);
|
|
});
|
|
});
|
|
|
|
describe('when no room can be made', () => {
|
|
function fullOfHeldSends(options: FakeOptions = {}) {
|
|
const server = fakeStalwart({ version: '1.0', quota: 2, ...options });
|
|
server.add({ accountId: 'account-1', id: 'held-1', sendAt: utcDate(Date.now() + DAY), queued: true });
|
|
server.add({ accountId: 'account-1', id: 'held-2', sendAt: utcDate(Date.now() + 2 * DAY), queued: true });
|
|
return server;
|
|
}
|
|
|
|
it('searches again for every send the user starts on Stalwart 1.0', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
const server = fullOfHeldSends();
|
|
const client = createClient();
|
|
const searches = () => server.calls('EmailSubmission/query').length + server.calls('EmailSubmission/changes').length;
|
|
|
|
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(searches()).toBe(2);
|
|
|
|
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(searches()).toBe(4);
|
|
});
|
|
|
|
// A search that found nothing used to hold off the next one for five
|
|
// minutes, whoever sent: a held send that went out within them could not
|
|
// make room, and every send failed with "ask your administrator".
|
|
it('makes room once a held send went out, minutes after a search found nothing, on Stalwart 1.0', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
const server = fakeStalwart({ version: '1.0', quota: 2 });
|
|
const undoHold = server.add({ accountId: 'account-1', id: 'undo-hold', sendAt: utcDate(Date.now() + 60 * 1000), queued: true });
|
|
server.add({ accountId: 'account-1', id: 'scheduled', sendAt: utcDate(Date.now() + DAY), queued: true });
|
|
const client = createClient();
|
|
|
|
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
|
|
// Two minutes later the undo hold has run out and its message left.
|
|
vi.setSystemTime(Date.now() + 2 * 60 * 1000);
|
|
undoHold.queued = false;
|
|
const result = await send(client);
|
|
|
|
expect(result.emailSubmissionId).toBeDefined();
|
|
expect(server.destroyedIds()).toContain('undo-hold');
|
|
expect(server.submissions.has('scheduled')).toBe(true);
|
|
});
|
|
|
|
it('searches again right away when the search itself failed on Stalwart 1.0', async () => {
|
|
let failures = 1;
|
|
const server = fullOfHeldSends({
|
|
intercept: ([name, , callId]) => (name === 'EmailSubmission/query' && failures-- > 0
|
|
? [['error', { type: 'serverFail', description: 'Temporary failure' }, callId]]
|
|
: undefined),
|
|
});
|
|
const client = createClient();
|
|
|
|
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
await expect(send(client)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
|
|
expect(server.calls('EmailSubmission/query')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it('submits the imported raw message again after making room on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 1 });
|
|
server.add({ accountId: 'account-1', id: 'delivered', sendAt: utcDate(Date.now() - HOUR) });
|
|
const client = createClient();
|
|
vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'blob-1', size: 10, type: 'message/rfc822' });
|
|
|
|
const result = await client.sendRawEmail(new Blob(['raw']), 'identity-1', 'mb-sent', 'mb-drafts');
|
|
|
|
const creates = server.calls('EmailSubmission/set').filter((args) => args.create);
|
|
expect(creates).toHaveLength(2);
|
|
expect((creates[1].create as Record<string, { emailId: string }>)['raw-submit'].emailId).toBe(result.emailId);
|
|
});
|
|
|
|
it('removes the imported raw message when the submission stays refused on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 1 });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
const client = createClient();
|
|
vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'blob-1', size: 10, type: 'message/rfc822' });
|
|
|
|
await expect(client.submitRawEmail(new Blob(['raw']), 'identity-1')).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
|
|
expect(server.emails.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('cancelEmailSubmission', () => {
|
|
it('destroys the record in a request of its own once the cancel succeeded on 0.16', async () => {
|
|
const server = fakeStalwart({ version: '0.16.6' });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
|
|
await createClient().cancelEmailSubmission('held', 'account-1');
|
|
|
|
expect(server.requests).toEqual([
|
|
[['EmailSubmission/set', { accountId: 'account-1', update: { held: { undoStatus: 'canceled' } } }, '0']],
|
|
[['EmailSubmission/set', { accountId: 'account-1', destroy: ['held'] }, '0']],
|
|
]);
|
|
});
|
|
|
|
it('sends no destroy when the cancel is refused on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
server.add({ accountId: 'account-1', id: 'gone', sendAt: utcDate(Date.now() - HOUR) });
|
|
|
|
await expect(createClient().cancelEmailSubmission('gone', 'account-1')).rejects.toThrow('no longer in the queue');
|
|
|
|
expect(server.destroys()).toEqual([]);
|
|
expect(server.submissions.has('gone')).toBe(true);
|
|
});
|
|
|
|
it('sends no destroy when the cancel call fails on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({
|
|
version: '1.0',
|
|
updateAnswer: (_id, callId) => ['error', { type: 'serverFail', description: 'Temporary failure' }, callId],
|
|
});
|
|
const held = server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
|
|
const error = await createClient().cancelEmailSubmission('held', 'account-1').catch((e: unknown) => e);
|
|
|
|
expect(jmapErrorType(error)).toBe('serverFail');
|
|
expect(server.destroys()).toEqual([]);
|
|
// Still held, still cancellable.
|
|
expect(held.queued).toBe(true);
|
|
expect(server.submissions.has('held')).toBe(true);
|
|
});
|
|
|
|
it('destroys the canceled original of a reschedule, and makes no replacement when the original could not be canceled, on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
const client = createClient();
|
|
|
|
const result = await client.rescheduleEmailSubmission('original', 'email-x', 'identity-1', new Date(Date.now() + 2 * HOUR).toISOString(), 'account-1');
|
|
expect(server.destroyedIds()).toEqual(['original']);
|
|
|
|
// Already sent: the original can't be canceled, and nothing is created.
|
|
server.submissions.get(result.emailSubmissionId!)!.queued = false;
|
|
await expect(client.rescheduleEmailSubmission(result.emailSubmissionId!, 'email-x', 'identity-1', new Date(Date.now() + 3 * HOUR).toISOString(), 'account-1'))
|
|
.rejects.toBeInstanceOf(NoLongerScheduledError);
|
|
expect(server.destroyedIds()).toEqual(['original']);
|
|
expect([...server.submissions.keys()]).toEqual([result.emailSubmissionId]);
|
|
});
|
|
});
|
|
|
|
describe('sendReadReceipt', () => {
|
|
function mdnClient(options: ClientOptions = {}) {
|
|
const client = createClient(options);
|
|
vi.spyOn(client, 'uploadBlob').mockResolvedValue({ blobId: 'blob-mdn', size: 10, type: 'message/rfc822' });
|
|
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
|
|
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
|
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
|
] as never);
|
|
return client;
|
|
}
|
|
|
|
const receipt = {
|
|
to: 'sender@example.net',
|
|
fromEmail: 'user@example.com',
|
|
identityId: 'identity-1',
|
|
originalMessageId: 'orig@example.net',
|
|
originalSubject: 'Hello',
|
|
};
|
|
|
|
it('destroys its record once sent on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
|
|
await mdnClient().sendReadReceipt(receipt);
|
|
|
|
expect(server.destroys()).toHaveLength(1);
|
|
expect(server.submissions.size).toBe(0);
|
|
// The receipt itself stays in Sent.
|
|
expect(server.emails.size).toBe(1);
|
|
});
|
|
|
|
it('takes the receipt back out of Sent when the submission is refused on 0.16', async () => {
|
|
const server = fakeStalwart({
|
|
version: '0.16.12',
|
|
createAnswer: () => ({ type: 'forbiddenFrom', description: 'Envelope mailFrom does not match identity email address.' }),
|
|
});
|
|
|
|
await expect(mdnClient().sendReadReceipt(receipt)).rejects.toThrow('MDN submission failed: Envelope mailFrom does not match');
|
|
|
|
expect(server.emails.size).toBe(0);
|
|
});
|
|
|
|
it('treats a method-level error as a failed receipt on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({
|
|
version: '1.0',
|
|
intercept: ([name, args, callId]) => (name === 'EmailSubmission/set' && args.create
|
|
? [['error', { type: 'serverFail', description: 'Temporary failure' }, callId]]
|
|
: undefined),
|
|
});
|
|
|
|
const error = await mdnClient().sendReadReceipt(receipt).catch((e: unknown) => e);
|
|
|
|
expect(jmapErrorType(error)).toBe('serverFail');
|
|
expect(server.emails.size).toBe(0);
|
|
});
|
|
|
|
it('throws SubmissionQuotaError at a full quota and leaves nothing in Sent on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', quota: 1 });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
|
|
await expect(mdnClient().sendReadReceipt(receipt)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(2);
|
|
expect(server.emails.size).toBe(0);
|
|
});
|
|
|
|
it('does not search again for automatic receipts for a few minutes after finding nothing that can go on Stalwart 1.0', async () => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
const server = fakeStalwart({ version: '1.0', quota: 1 });
|
|
server.add({ accountId: 'account-1', id: 'held', sendAt: utcDate(Date.now() + DAY), queued: true });
|
|
const client = mdnClient();
|
|
const searches = () => server.calls('EmailSubmission/query').length + server.calls('EmailSubmission/changes').length;
|
|
const automatic = { ...receipt, automatic: true };
|
|
|
|
await expect(client.sendReadReceipt(automatic)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(searches()).toBe(2);
|
|
|
|
// Every further automatic receipt only tries the create once more ...
|
|
await expect(client.sendReadReceipt(automatic)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(searches()).toBe(2);
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(4);
|
|
|
|
// ... while one the user sends looks again,
|
|
await expect(client.sendReadReceipt(receipt)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(searches()).toBe(4);
|
|
|
|
// and so do automatic ones after a few minutes.
|
|
vi.setSystemTime(Date.now() + 5 * 60 * 1000);
|
|
await expect(client.sendReadReceipt(automatic)).rejects.toBeInstanceOf(SubmissionQuotaError);
|
|
expect(searches()).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('getScheduledEmails', () => {
|
|
it('reads every page of the query, so mail scheduled furthest ahead is listed on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
const now = Date.now();
|
|
for (let i = 0; i < 60; i++) server.add({ accountId: 'account-1', id: `delivered-${i}`, sendAt: utcDate(now - (i + 1) * 60_000) });
|
|
server.add({ accountId: 'account-1', id: 'later', emailId: 'email-later', sendAt: utcDate(now + DAY), queued: true });
|
|
const result = await createClient().getScheduledEmails(50, 0);
|
|
|
|
expect(result.emails.map((email) => email.emailSubmissionId)).toEqual(['later']);
|
|
expect(server.calls('EmailSubmission/query').map((q) => q.position)).toEqual([0, 50]);
|
|
});
|
|
|
|
// Live on 0.16.27: the member's own scheduled mail and the group's were
|
|
// both submission "f" of message "maaaaad". Both rows came out as the
|
|
// group's message in the group account, and cancelling the own one
|
|
// cancelled the group's.
|
|
it('keeps two accounts\' submissions apart when their ids are the same', async () => {
|
|
const client = createClient({ accounts: ['account-1', 'team'] });
|
|
const sendAt = utcDate(Date.now() + HOUR);
|
|
vi.spyOn(client as unknown as { request: (calls: MethodCall[]) => Promise<unknown> }, 'request')
|
|
.mockImplementation(async (calls: MethodCall[]) => {
|
|
const [name, args, callId] = calls[0];
|
|
const accountId = args.accountId as string;
|
|
if (name === 'EmailSubmission/query') return { methodResponses: [[name, { accountId, ids: ['f'], total: 1 }, callId]] };
|
|
if (name === 'EmailSubmission/get') {
|
|
return { methodResponses: [[name, { accountId, list: [{ id: 'f', emailId: 'maaaaad', identityId: 'b', sendAt, undoStatus: 'pending' }] }, callId]] };
|
|
}
|
|
if (name === 'Email/get') {
|
|
return { methodResponses: [[name, { accountId, list: [{ id: 'maaaaad', subject: `from ${accountId}`, mailboxIds: {}, keywords: {} }] }, callId]] };
|
|
}
|
|
throw new Error(`unexpected ${name}`);
|
|
});
|
|
|
|
const { emails } = await client.getScheduledEmails(50, 0);
|
|
|
|
expect(emails.map((email) => [email.scheduledAccountId, email.subject]).sort()).toEqual([
|
|
['account-1', 'from account-1'],
|
|
['team', 'from team'],
|
|
]);
|
|
// The group's row is read, keyed and threaded in its own account.
|
|
expect(emails.map((email) => email.sourceAccountId).sort()).toEqual(['team', undefined]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
describe('recipients the server refuses', () => {
|
|
const relayOff = (email: string) => (email.endsWith('@example.net') ? RELAY_DENIED : undefined);
|
|
|
|
function sendTo(client: JMAPClient, to: string[], opts: { draftId?: string; delayedUntil?: string } = {}) {
|
|
return client.sendEmail(
|
|
to, 'Subject', 'body',
|
|
undefined, undefined, 'identity-1', 'user@example.com',
|
|
opts.draftId,
|
|
undefined, undefined, undefined, undefined, undefined,
|
|
opts.delayedUntil,
|
|
);
|
|
}
|
|
|
|
it.each(['1.0', '0.16.12', '0.16.6'] as const)('fails a send that every recipient was refused for on Stalwart %s, keeping the draft', async (version) => {
|
|
const server = fakeStalwart({ version, refuseRecipient: relayOff });
|
|
|
|
const error = await sendTo(createClient(), ['nobody@example.net'], { draftId: 'draft-old-1' }).catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(RecipientsRejectedError);
|
|
expect((error as RecipientsRejectedError).recipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
|
// The copy filed in Sent is gone, the user's draft is not ...
|
|
expect(server.emails.size).toBe(0);
|
|
const destroyedEmails = server.calls('Email/set').flatMap((args) => (args.destroy as string[] | undefined) ?? []);
|
|
expect(destroyedEmails).not.toContain('draft-old-1');
|
|
// ... and so is the record, which can neither go out nor be cancelled.
|
|
expect(server.submissions.size).toBe(0);
|
|
});
|
|
|
|
it('fails a held send (undo send) the same way on Stalwart 1.0: it is refused when created', async () => {
|
|
const server = fakeStalwart({ version: '1.0', refuseRecipient: relayOff });
|
|
|
|
const error = await sendTo(createClient(), ['nobody@example.net'], { delayedUntil: new Date(Date.now() + 10_000).toISOString() })
|
|
.catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(RecipientsRejectedError);
|
|
expect(server.emails.size).toBe(0);
|
|
expect(server.submissions.size).toBe(0);
|
|
});
|
|
|
|
it('takes a used-up throttle for a refusal too on Stalwart 1.0', async () => {
|
|
fakeStalwart({ version: '1.0', refuseRecipient: () => RATE_LIMITED });
|
|
|
|
const error = await sendTo(createClient(), ['bob@example.org']).catch((e: unknown) => e);
|
|
|
|
expect((error as RecipientsRejectedError).recipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
|
|
});
|
|
|
|
it.each(['1.0', '0.16.12'] as const)('names the refused recipients and sends to the others on Stalwart %s', async (version) => {
|
|
const server = fakeStalwart({ version, refuseRecipient: relayOff });
|
|
|
|
const result = await sendTo(createClient(), ['bob@example.org', 'nobody@example.net'], { draftId: 'draft-old-1' });
|
|
|
|
expect(result.rejectedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
|
expect(result.filingError).toBeUndefined();
|
|
// Sent as usual: the draft goes, the message stays in Sent.
|
|
expect(server.calls('Email/set').flatMap((args) => (args.destroy as string[] | undefined) ?? [])).toEqual(['draft-old-1']);
|
|
expect(server.emails.size).toBe(1);
|
|
});
|
|
|
|
it('reads the delivery status in the send request, before the record is destroyed, on Stalwart 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0', refuseRecipient: relayOff });
|
|
|
|
const result = await sendTo(createClient(), ['bob@example.org', 'nobody@example.net']);
|
|
|
|
expect(server.requests[2].map(([name]) => name)).toEqual(['Email/set', 'EmailSubmission/set', 'EmailSubmission/get']);
|
|
expect(server.requests[2][2][1]).toEqual({ accountId: 'account-1', ids: ['#1'], properties: ['deliveryStatus'] });
|
|
expect(server.requests.at(-1)).toEqual([['EmailSubmission/set', { accountId: 'account-1', destroy: [result.emailSubmissionId] }, '0']]);
|
|
});
|
|
|
|
it('sends as before when the server tells nothing about the recipients', async () => {
|
|
fakeStalwart({
|
|
version: '1.0',
|
|
refuseRecipient: relayOff,
|
|
intercept: ([name, , callId]) => (name === 'EmailSubmission/get' ? [['error', { type: 'unknownMethod' }, callId]] : undefined),
|
|
});
|
|
|
|
const result = await sendTo(createClient(), ['nobody@example.net']);
|
|
|
|
expect(result.emailSubmissionId).toBeDefined();
|
|
expect(result.rejectedRecipients).toBeUndefined();
|
|
// The get's own failure is neither a failed send nor a filing problem.
|
|
expect(result.filingError).toBeUndefined();
|
|
});
|
|
|
|
it("reports a refused create with the create's own reason, not the get's", async () => {
|
|
fakeStalwart({ version: '1.0', createAnswer: () => ({ type: 'forbiddenToSend', description: 'Sending is disabled for this account.' }) });
|
|
|
|
const error = await sendTo(createClient(), ['bob@example.org']).catch((e: unknown) => e);
|
|
|
|
expect(jmapErrorType(error)).toBe('forbiddenToSend');
|
|
expect((error as Error).message).toContain('Sending is disabled for this account.');
|
|
});
|
|
|
|
it('fails a reschedule every recipient is refused for, and leaves no dead record, on Stalwart 1.0', async () => {
|
|
let throttled = false;
|
|
const server = fakeStalwart({ version: '1.0', refuseRecipient: () => (throttled ? RATE_LIMITED : undefined) });
|
|
server.add({ accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true });
|
|
throttled = true;
|
|
|
|
const error = await createClient()
|
|
.rescheduleEmailSubmission('original', 'email-x', 'identity-1', new Date(Date.now() + 1000).toISOString(), 'account-1')
|
|
.catch((e: unknown) => e);
|
|
|
|
expect(error).toBeInstanceOf(RecipientsRejectedError);
|
|
// The restore of the original is refused the same way; neither record stays.
|
|
expect(server.calls('EmailSubmission/set').filter((args) => args.create)).toHaveLength(2);
|
|
expect(server.submissions.size).toBe(0);
|
|
});
|
|
|
|
// "Send now" and "Reschedule" cancel the original and then create a
|
|
// replacement (#1308). The server checks the recipients again for the
|
|
// replacement: the 25/h throttle may be used up for one of them by now.
|
|
describe('when a reschedule\'s replacement is refused for some recipients', () => {
|
|
// A message held for an hour, with what the server answered when it took it.
|
|
const addOriginal = (server: ReturnType<typeof fakeStalwart>, status: Record<string, 'queued' | 'no'>) => server.add({
|
|
accountId: 'account-1', id: 'original', emailId: 'email-x', sendAt: utcDate(Date.now() + HOUR), queued: true,
|
|
envelope: { mailFrom: { email: 'user@example.com' }, rcptTo: Object.keys(status).map((email) => ({ email })) },
|
|
deliveryStatus: Object.fromEntries(Object.entries(status).map(([email, delivered]) => [email, {
|
|
delivered, smtpReply: delivered === 'no' ? RELAY_DENIED : '250 2.1.5 Queued', displayed: 'unknown',
|
|
}])),
|
|
});
|
|
const sendNow = () => createClient()
|
|
.rescheduleEmailSubmission('original', 'email-x', 'identity-1', new Date(Date.now() + 1000).toISOString(), 'account-1');
|
|
const throttledFor = (address: string) => (email: string) => (email === address ? RATE_LIMITED : undefined);
|
|
|
|
it.each(['1.0', '0.16.12'] as const)('goes ahead for the recipients taken and names the refused one on Stalwart %s', async (version) => {
|
|
const server = fakeStalwart({ version, refuseRecipient: throttledFor('bob@example.org') });
|
|
const original = addOriginal(server, { 'alice@example.org': 'queued', 'bob@example.org': 'queued' });
|
|
|
|
const result = await sendNow();
|
|
|
|
expect(result.rejectedRecipients).toEqual([{ email: 'bob@example.org', smtpReply: RATE_LIMITED }]);
|
|
expect(original.canceled).toBe(true);
|
|
expect([...server.submissions.keys()]).toEqual([result.emailSubmissionId]);
|
|
});
|
|
|
|
it.each(['1.0', '0.16.12'] as const)('goes ahead when only a recipient the original was refused for is refused again, and names it, on Stalwart %s', async (version) => {
|
|
const server = fakeStalwart({ version, refuseRecipient: (email) => (email.endsWith('@example.net') ? RELAY_DENIED : undefined) });
|
|
const original = addOriginal(server, { 'alice@example.org': 'queued', 'nobody@example.net': 'no' });
|
|
|
|
const result = await sendNow();
|
|
|
|
expect(result.rejectedRecipients).toEqual([{ email: 'nobody@example.net', smtpReply: RELAY_DENIED }]);
|
|
expect(original.canceled).toBe(true);
|
|
expect([...server.submissions.keys()]).toEqual([result.emailSubmissionId]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('shared accounts that refuse the submission prune', () => {
|
|
const refuseShared = (accountId: string) => ([, args, callId]: MethodCall) => (args.accountId === accountId
|
|
? [['error', { type: 'forbidden', description: `You are not an owner of account ${accountId}` }, callId]] as Array<[string, Record<string, unknown>, string]>
|
|
: undefined);
|
|
|
|
// 0.16.23 lists an account that shared only an address book or a calendar
|
|
// with the submission capability, then refuses its records.
|
|
it('leaves such an account alone for the session, without a warning, on 0.16', async () => {
|
|
const server = fakeStalwart({ version: '0.16.12', intercept: refuseShared('userb') });
|
|
const client = createClient({ accounts: ['account-1', 'userb'] });
|
|
|
|
await client.pruneEmailSubmissions({ force: true });
|
|
await client.pruneEmailSubmissions({ force: true });
|
|
await client.getScheduledEmails();
|
|
|
|
expect(server.calls('EmailSubmission/query').map((q) => q.accountId)).toEqual(['account-1', 'userb', 'account-1', 'account-1']);
|
|
expect(console.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps pruning a group account the user belongs to on 1.0', async () => {
|
|
const server = fakeStalwart({ version: '1.0' });
|
|
const client = createClient({ accounts: ['account-1', 'team'] });
|
|
|
|
await client.pruneEmailSubmissions({ force: true });
|
|
await client.pruneEmailSubmissions({ force: true });
|
|
|
|
expect(server.calls('EmailSubmission/query').map((q) => q.accountId)).toEqual(['account-1', 'team', 'account-1', 'team']);
|
|
});
|
|
});
|