Files
native/src/api/push.ts
T
Linus Rath e8a9c58d18 fix: keep push notifications working when a mailbox is shared with you
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.
2026-09-25 20:56:57 +02:00

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;
}
}