Files
native/src/api/jmap-result.ts
T
Linus Rath fe0facfea5 fix: keep sending working in the app past Stalwart 1.0's submission limit
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`.
2026-09-25 19:46:15 +02:00

136 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}