mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 00:06: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. The app never removed a record, so after about 500 sends from an account every send, quick reply, read receipt and "Send now" failed with Stalwart's raw "There are too many email submissions, please delete some" error. This ports the webmail fix (48382170). The app 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 a record EmailSubmission/get reports as pending is never touched: - an immediate send (no undo delay, no schedule) and a read receipt destroy their own record right after the send, riding along with the old-draft cleanup when there is one; 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 (`src/api/submissions.ts`) destroys canceled records and final records whose sendAt has passed, going by EmailSubmission/get, in every submission account; it runs at app start, on return to the foreground and after sends and undo - a confirmed cancel (undo, cancel, reschedule, "Send now") destroys the canceled record in a request of its own - an `overQuota` on a submission create prunes at once, on Stalwart walks `EmailSubmission/changes` from "n" 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, the undo bar and the scheduled list; the copy the failed attempt made is removed as before 0.16 never enforces the limit, and destroy never cancels on any version, so the same cleanup runs there unchanged. The undo bar now also says when an undo or "Send now" failed instead of only logging it. The scheduled list reads every page of EmailSubmission/query with calculateTotal; it asked for 200 records, oldest first, so the mail scheduled furthest ahead dropped off once the three-day window held more than that. `SubmissionQuotaError` is added to `jmap-result.ts` as a subclass of `JMAPMethodError`.
136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
// Shared JMAP response helpers. Kept dependency-free (no client import) so
|
||
// every `src/api/*.ts` module - and their tests, which mock `./jmap-client`
|
||
// wholesale - can use them.
|
||
//
|
||
// Most RN api functions used to index `methodResponses[0][1]` blindly, so a
|
||
// method-level `['error', {...}]` reply produced a TypeError on reads and was
|
||
// silently treated as success on writes (the outbox then dropped the op).
|
||
// Route every response through these instead.
|
||
|
||
import type { JMAPResponseBody } from './types';
|
||
|
||
type MethodResponse = [string, Record<string, any>, string];
|
||
|
||
export class JMAPMethodError extends Error {
|
||
type: string;
|
||
constructor(type: string, description?: string) {
|
||
super(description || type || 'JMAP method error');
|
||
this.name = 'JMAPMethodError';
|
||
this.type = type;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The JMAP error type a thrown value carries: a method error, or a SetError
|
||
* thrown as a `JMAPMethodError`. Callers branch on it instead of the
|
||
* description, which differs between servers and Stalwart releases.
|
||
*/
|
||
export function jmapErrorType(err: unknown): string | undefined {
|
||
return err instanceof JMAPMethodError ? err.type : undefined;
|
||
}
|
||
|
||
/**
|
||
* Pull the response body for `callId` (or the first response when omitted)
|
||
* and throw a `JMAPMethodError` when the server answered with the error
|
||
* envelope. Also verifies the method name when `expectMethod` is given.
|
||
*/
|
||
export function requireMethodResult<T = Record<string, any>>(
|
||
res: JMAPResponseBody,
|
||
callId?: string,
|
||
expectMethod?: string,
|
||
): T {
|
||
const responses = (res?.methodResponses ?? []) as MethodResponse[];
|
||
const match = callId === undefined
|
||
? responses[0]
|
||
: responses.find((r) => r[2] === callId);
|
||
if (!match) {
|
||
throw new JMAPMethodError(
|
||
'missingResponse',
|
||
`Missing JMAP response${callId !== undefined ? ` for call ${callId}` : ''}`,
|
||
);
|
||
}
|
||
if (match[0] === 'error') {
|
||
const err = match[1] as { type?: string; description?: string };
|
||
throw new JMAPMethodError(err.type ?? 'serverFail', err.description);
|
||
}
|
||
if (expectMethod && match[0] !== expectMethod) {
|
||
throw new JMAPMethodError('unexpectedMethod', `Expected ${expectMethod}, got ${match[0]}`);
|
||
}
|
||
return match[1] as T;
|
||
}
|
||
|
||
/**
|
||
* Throw when a /set response reports the given ids (or any id when `ids` is
|
||
* omitted) under `notCreated` / `notUpdated` / `notDestroyed`.
|
||
*/
|
||
export function assertSetResult(
|
||
body: Record<string, any>,
|
||
ids?: string[],
|
||
what = 'object',
|
||
): void {
|
||
const buckets = ['notCreated', 'notUpdated', 'notDestroyed'] as const;
|
||
for (const bucket of buckets) {
|
||
const failures = body?.[bucket] as
|
||
| Record<string, { type?: string; description?: string; properties?: string[] }>
|
||
| undefined;
|
||
if (!failures) continue;
|
||
const entries = Object.entries(failures).filter(([id]) => !ids || ids.includes(id));
|
||
if (entries.length === 0) continue;
|
||
const [id, err] = entries[0];
|
||
const verb = bucket === 'notCreated' ? 'create' : bucket === 'notUpdated' ? 'update' : 'destroy';
|
||
const detail = [err?.type ?? 'unknown'];
|
||
if (err?.properties?.length) detail.push(`properties=[${err.properties.join(', ')}]`);
|
||
if (err?.description) detail.push(err.description);
|
||
const more = entries.length > 1 ? ` (+${entries.length - 1} more)` : '';
|
||
throw new JMAPMethodError(
|
||
err?.type ?? 'setError',
|
||
`Failed to ${verb} ${what} ${id}${more}: ${detail.join(' – ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The server refused a new EmailSubmission because the account holds too many
|
||
* submission records, and removing the ones that can no longer be cancelled
|
||
* did not make room. Stalwart 1.0 counts every record, whatever its status or
|
||
* age, against `maxSubmissions` (500 by default) and answers the create with
|
||
* `overQuota` - not to be confused with Email/set's `overQuota`, which means
|
||
* the mailbox storage is full.
|
||
*/
|
||
export class SubmissionQuotaError extends JMAPMethodError {
|
||
constructor(description?: string) {
|
||
super('overQuota', description || 'There are too many email submissions');
|
||
this.name = 'SubmissionQuotaError';
|
||
}
|
||
}
|
||
|
||
/** A scheduled send later than the server's hold limit. */
|
||
export class ScheduleTooLateError extends Error {
|
||
maxSeconds?: number;
|
||
constructor(maxSeconds?: number) {
|
||
super('Scheduled send time is later than the server allows');
|
||
this.name = 'ScheduleTooLateError';
|
||
this.maxSeconds = maxSeconds;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The hold limit named in a rejected submission, if that was the reason:
|
||
* Stalwart's MTA refuses a HOLDFOR beyond its `futureRelease` limit with
|
||
* "501 5.5.4 Requested hold time exceeds maximum of N seconds".
|
||
*/
|
||
export function parseHoldLimit(description: string | undefined): number | null {
|
||
const m = description?.match(/hold time exceeds maximum of (\d+) seconds/i);
|
||
return m ? Number(m[1]) : null;
|
||
}
|
||
|
||
/** Split `items` into consecutive batches of at most `size` entries. */
|
||
export function batched<T>(items: T[], size: number): T[][] {
|
||
const step = Math.max(1, Math.floor(size));
|
||
const result: T[][] = [];
|
||
for (let i = 0; i < items.length; i += step) {
|
||
result.push(items.slice(i, i + step));
|
||
}
|
||
return result;
|
||
}
|