mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Stalwart refuses a push subscription's whole emailPush map as a `forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and later, and 1.0), and 1.0 stops listing emailpush on such accounts. The app narrowed the map on any `forbidden`, including a refused method call, and put every account with folders in the first attempt, so 1.0 cost one refused call per install. Worse, a failed listing counted as "the server has no subscription" and dropped the key of a working registration, and any failed refresh - a network error, a timeout, rate limiting - started a replacement. - `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same name, carrying `properties`) and `isJMAPSetError`. `assertSetResult` throws it, so every refused object can be told apart from a refused call; existing `instanceof` checks and the outbox's name-based error classification are unchanged. - The emailPush map holds the primary account and the accounts whose `accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused map still narrows to the accounts that look owned, then to the primary account, but only on a `forbidden` SetError. - A subscription ends only with a verified replacement, a user disable or the server's verdict: a failed listing aborts the run, and only a SetError on the refresh starts a replacement. Other failures leave the subscription and its key alone and the next run tries again. - `forbidden` and `overQuota` read as plain text in settings and the onboarding prompt, and settings says that mailboxes others share don't send background notifications. New strings in all 27 languages.
196 lines
7.4 KiB
TypeScript
196 lines
7.4 KiB
TypeScript
import { jmapClient } from './jmap-client';
|
|
import { assertSetResult, JMAPMethodError, requireMethodResult } from './jmap-result';
|
|
import { CAPABILITIES } from './types';
|
|
import type { EmailPushConfig, PushSubscription, StateChange } from './types';
|
|
|
|
export type StateChangeHandler = (change: StateChange) => void;
|
|
|
|
// ─── PushSubscription (RFC 8620 §7.2) ───────────────────
|
|
// Mirrors the webmail's lib/jmap/client.ts push calls - keep them in sync.
|
|
|
|
// draft-ietf-jmap-emailpush (Stalwart >= 0.16.16) lets a subscription carry a
|
|
// per-account `emailPush` delivery filter. Older servers reject the capability
|
|
// in `using` and the property outright, so every call that touches it gates
|
|
// on the session advertising the capability.
|
|
function hasEmailPushCapability(): boolean {
|
|
return jmapClient.hasCapability(CAPABILITIES.EMAIL_PUSH);
|
|
}
|
|
|
|
function pushUsing(withEmailPush: boolean): string[] {
|
|
return withEmailPush ? [CAPABILITIES.CORE, CAPABILITIES.EMAIL_PUSH] : [CAPABILITIES.CORE];
|
|
}
|
|
|
|
/**
|
|
* Every PushSubscription of the account. Throws when the server can't be
|
|
* asked or refuses the call - never an empty list instead: to the push setup
|
|
* an empty list means "the server no longer has our subscription".
|
|
*/
|
|
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
|
const withEmailPush = hasEmailPushCapability();
|
|
// `emailPush` is not in the server's default property set, so ask for it
|
|
// explicitly - without it the stored filter never compares equal to the
|
|
// wanted one and every launch re-patches it.
|
|
const args: Record<string, unknown> = { ids: null };
|
|
if (withEmailPush) {
|
|
args.properties = ['id', 'deviceClientId', 'verificationCode', 'expires', 'types', 'emailPush'];
|
|
}
|
|
const res = await jmapClient.request(
|
|
[['PushSubscription/get', args, '0']],
|
|
pushUsing(withEmailPush),
|
|
);
|
|
const body = requireMethodResult(res, '0', 'PushSubscription/get');
|
|
return (body.list as PushSubscription[]) ?? [];
|
|
}
|
|
|
|
/**
|
|
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
|
* Returns the server-assigned id (which the client also registers with the
|
|
* relay so the relay can route incoming pushes to an Expo token) and the
|
|
* expiry the server settled on. A refused subscription throws a JMAPSetError
|
|
* carrying the SetError type, so callers can tell a `forbidden` emailPush map
|
|
* from a refused method call and other failures.
|
|
*/
|
|
export async function createPushSubscription(params: {
|
|
deviceClientId: string;
|
|
url: string;
|
|
types: string[];
|
|
// ISO date. Servers may clamp to their own ceiling - we send the maximum we
|
|
// want and accept whatever Stalwart returns.
|
|
expires?: string;
|
|
// draft-ietf-jmap-emailpush delivery filter, only when the server advertises
|
|
// urn:ietf:params:jmap:emailpush (see serverSupportsEmailPush).
|
|
emailPush?: Record<string, EmailPushConfig>;
|
|
}): Promise<{ id: string; expires: string | null }> {
|
|
const created: Record<string, unknown> = {
|
|
deviceClientId: params.deviceClientId,
|
|
url: params.url,
|
|
types: params.types,
|
|
};
|
|
if (params.expires) created.expires = params.expires;
|
|
const withEmailPush = !!params.emailPush && hasEmailPushCapability();
|
|
if (withEmailPush) created.emailPush = params.emailPush;
|
|
|
|
const res = await jmapClient.request(
|
|
[
|
|
[
|
|
'PushSubscription/set',
|
|
{ create: { new: created } },
|
|
'0',
|
|
],
|
|
],
|
|
pushUsing(withEmailPush),
|
|
);
|
|
const body = requireMethodResult(res, '0', 'PushSubscription/set');
|
|
assertSetResult(body, ['new'], 'push subscription');
|
|
const result = body.created?.new as { id?: string; expires?: string | null } | undefined;
|
|
if (!result?.id) {
|
|
throw new Error(`PushSubscription/set create failed: ${JSON.stringify(body)}`);
|
|
}
|
|
// The server only echoes `expires` when it changed it (Stalwart clamps it to
|
|
// seven days); otherwise the requested value stands.
|
|
return { id: result.id, expires: result.expires ?? params.expires ?? null };
|
|
}
|
|
|
|
/**
|
|
* Push the subscription's expiry forward and re-sync its types or delivery
|
|
* filter (RFC 8620 §7.2.1). Throws a JMAPSetError carrying the SetError type
|
|
* when the server refuses the update - `notFound` once the subscription is
|
|
* gone, `forbidden` for an emailPush map naming an account the user may not
|
|
* subscribe to. An answer that neither updates nor refuses it is a plain
|
|
* JMAPMethodError: it says nothing about the subscription.
|
|
*/
|
|
export async function updatePushSubscription(
|
|
id: string,
|
|
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> },
|
|
): Promise<void> {
|
|
const withEmailPush = patch.emailPush !== undefined && hasEmailPushCapability();
|
|
const update: Record<string, unknown> = { ...patch };
|
|
if (!withEmailPush) delete update.emailPush;
|
|
const res = await jmapClient.request(
|
|
[
|
|
[
|
|
'PushSubscription/set',
|
|
{ update: { [id]: update } },
|
|
'0',
|
|
],
|
|
],
|
|
pushUsing(withEmailPush),
|
|
);
|
|
const body = requireMethodResult(res, '0', 'PushSubscription/set');
|
|
assertSetResult(body, [id], 'push subscription');
|
|
if (body.updated?.[id] === undefined) {
|
|
throw new JMAPMethodError('notUpdated', `PushSubscription/set did not update ${id}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send the verification code back to the server - the call that flips the
|
|
* subscription from pending to active (RFC 8620 §7.2.2).
|
|
*/
|
|
export async function verifyPushSubscription(
|
|
id: string,
|
|
verificationCode: string,
|
|
): Promise<void> {
|
|
const res = await jmapClient.request(
|
|
[
|
|
[
|
|
'PushSubscription/set',
|
|
{ update: { [id]: { verificationCode } } },
|
|
'0',
|
|
],
|
|
],
|
|
[CAPABILITIES.CORE],
|
|
);
|
|
const body = requireMethodResult(res, '0', 'PushSubscription/set');
|
|
assertSetResult(body, [id], 'push subscription');
|
|
}
|
|
|
|
export async function destroyPushSubscription(id: string): Promise<void> {
|
|
const res = await jmapClient.request(
|
|
[['PushSubscription/set', { destroy: [id] }, '0']],
|
|
[CAPABILITIES.CORE],
|
|
);
|
|
const body = requireMethodResult(res, '0', 'PushSubscription/set');
|
|
// Already gone is what the caller asked for.
|
|
if (body.notDestroyed?.[id]?.type === 'notFound') return;
|
|
assertSetResult(body, [id], 'push subscription');
|
|
}
|
|
|
|
// ─── Live updates ────────────────────────────────────────
|
|
// The EventSource lifecycle (reconnect with back-off, fresh bearer on every
|
|
// connect, ping watchdog, polling fallback) lives in ./push-stream. These
|
|
// re-exports keep the historical entry points.
|
|
|
|
export {
|
|
startLiveUpdates,
|
|
startPolling,
|
|
type LiveUpdatesHandle,
|
|
type LiveUpdatesOptions,
|
|
} from './push-stream';
|
|
|
|
export interface StartPushOptions {
|
|
onStateChange: StateChangeHandler;
|
|
onError?: (error: Error) => void;
|
|
onFallback?: (reason: string) => void;
|
|
isActive?: () => boolean;
|
|
}
|
|
|
|
/**
|
|
* Start real-time updates, preferring SSE with polling fallback. Resolves to
|
|
* a cleanup function; prefer `startLiveUpdates` when the handle's
|
|
* `reconnect()` is needed (foreground resume).
|
|
*/
|
|
export async function startPushUpdates(
|
|
_client: unknown,
|
|
opts: StartPushOptions,
|
|
): Promise<() => void> {
|
|
const { startLiveUpdates: start } = await import('./push-stream');
|
|
try {
|
|
const handle = await start(opts);
|
|
return () => handle.close();
|
|
} catch (err) {
|
|
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
return () => undefined;
|
|
}
|
|
}
|