feat: implement JMAP push notification handling and subscription management

This commit is contained in:
Linus Rath
2026-04-20 08:26:09 +02:00
parent bc3b923945
commit 15006086d2
8 changed files with 415 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { subscriptionStore } from '@/lib/push/store';
import { sendExpoPush } from '@/lib/push/expo';
import { isValidSubscriptionId } from '@/lib/push/validation';
import type { JmapPushBody } from '@/lib/push/types';
/**
* POST /api/push/jmap/:id
*
* Destination configured on JMAP PushSubscription. Spec (RFC 8620 §7.2)
* allows two body shapes:
*
* { "@type": "PushVerification", pushSubscriptionId, verificationCode }
* { "@type": "StateChange", changed: { [accountId]: { [type]: state } } }
*
* PushVerification is terminated here — cached against the subscription so
* the client can poll it. StateChange is fanned out as an Expo push so the
* mobile app wakes and re-fetches with its own credentials.
*/
function getAllowedOrigin(): string | null {
const raw = process.env.PUSH_ALLOWED_JMAP_ORIGIN?.trim();
return raw ? raw : null;
}
function getSharedSecret(): string | null {
const raw = process.env.PUSH_SHARED_SECRET?.trim();
return raw ? raw : null;
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
if (!isValidSubscriptionId(id)) {
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
}
const allowedOrigin = getAllowedOrigin();
if (allowedOrigin) {
const origin = request.headers.get('origin') ?? '';
if (origin && origin !== allowedOrigin) {
return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 });
}
}
const sharedSecret = getSharedSecret();
if (sharedSecret) {
// Stalwart supports a custom `Authorization` header on the push URL via
// PushSubscription.keys/headers — a shared secret is the strongest
// authentication a stateless relay can enforce.
const presented = request.headers.get('x-push-secret') ?? '';
if (presented !== sharedSecret) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
const record = await subscriptionStore.get(id);
if (!record) {
return NextResponse.json({ error: 'Unknown subscription' }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as JmapPushBody | null;
if (!body || typeof body['@type'] !== 'string') {
return NextResponse.json({ error: 'Invalid JMAP push body' }, { status: 400 });
}
if (body['@type'] === 'PushVerification') {
record.verificationCode = body.verificationCode;
await subscriptionStore.put(id, record);
return NextResponse.json({ ok: true });
}
if (body['@type'] === 'StateChange') {
const ok = await sendExpoPush(record, body);
record.lastPushAt = Date.now();
await subscriptionStore.put(id, record);
return NextResponse.json({ ok });
}
return NextResponse.json({ error: 'Unsupported JMAP push type' }, { status: 400 });
} catch (error) {
logger.error('push: jmap handler failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { subscriptionStore } from '@/lib/push/store';
import { isValidSubscriptionId } from '@/lib/push/validation';
/**
* DELETE /api/push/register/:id
*
* Client tears down its relay mapping on logout / uninstall. Idempotent —
* deleting a non-existent id still returns ok.
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
if (!isValidSubscriptionId(id)) {
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
}
await subscriptionStore.delete(id);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('push: unregister failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { subscriptionStore } from '@/lib/push/store';
import {
isValidExpoPushToken,
isValidSubscriptionId,
} from '@/lib/push/validation';
import type { SubscriptionRecord } from '@/lib/push/types';
/**
* POST /api/push/register
* Body: { subscriptionId, expoPushToken, accountLabel? }
*
* Called by the mobile app once it has created a JMAP PushSubscription and
* received the subscriptionId it plans to use. We hold only the device's
* Expo push token + that id — no user credentials pass through the relay.
*/
export async function POST(request: NextRequest) {
try {
const body = (await request.json().catch(() => null)) as {
subscriptionId?: unknown;
expoPushToken?: unknown;
accountLabel?: unknown;
} | null;
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
const { subscriptionId, expoPushToken, accountLabel } = body;
if (!isValidSubscriptionId(subscriptionId)) {
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
}
if (!isValidExpoPushToken(expoPushToken)) {
return NextResponse.json({ error: 'Invalid expoPushToken' }, { status: 400 });
}
const record: SubscriptionRecord = {
expoPushToken,
verificationCode: null,
createdAt: Date.now(),
lastPushAt: null,
accountLabel:
typeof accountLabel === 'string' ? accountLabel.slice(0, 120) : undefined,
};
await subscriptionStore.put(subscriptionId, record);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('push: register failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { subscriptionStore } from '@/lib/push/store';
import { isValidSubscriptionId } from '@/lib/push/validation';
/**
* GET /api/push/verify/:id
*
* Mobile app polls this after creating a JMAP PushSubscription. When the
* JMAP server POSTs a PushVerification to /api/push/jmap/:id we stash the
* code on the record; the client then PATCHes the subscription with it and
* activation completes (RFC 8620 §7.2.2).
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
if (!isValidSubscriptionId(id)) {
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
}
const record = await subscriptionStore.get(id);
if (!record) {
return NextResponse.json({ error: 'Unknown subscription' }, { status: 404 });
}
return NextResponse.json({ verificationCode: record.verificationCode ?? null });
} catch (error) {
logger.error('push: verify poll failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { logger } from '@/lib/logger';
import type { StateChange, SubscriptionRecord } from './types';
// Expo's push API. Swap via env when self-hosting.
function getExpoPushUrl(): string {
return process.env.EXPO_PUSH_URL ?? 'https://exp.host/--/api/v2/push/send';
}
export async function sendExpoPush(
record: SubscriptionRecord,
change: StateChange,
): Promise<boolean> {
// Summarize the JMAP StateChange into a hint the user can see on lock
// screen. The app wakes on receipt, re-fetches via JMAP with its own creds,
// and can replace the notification via `presentNotificationAsync` if
// subject/sender should be shown.
const types = new Set<string>();
for (const perAccount of Object.values(change.changed)) {
for (const type of Object.keys(perAccount)) types.add(type);
}
const hasEmail = types.has('Email') || types.has('EmailDelivery');
const message = {
to: record.expoPushToken,
title: hasEmail ? 'New mail' : 'Mailbox updated',
body: record.accountLabel ?? '',
sound: 'default',
priority: 'high' as const,
channelId: 'mail',
data: {
kind: 'jmap-state-change',
changed: change.changed,
},
_contentAvailable: true,
};
try {
const res = await fetch(getExpoPushUrl(), {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
'accept-encoding': 'gzip, deflate',
},
body: JSON.stringify(message),
});
if (!res.ok) {
logger.warn('push: expo delivery failed', {
status: res.status,
body: await res.text().catch(() => ''),
});
return false;
}
return true;
} catch (error) {
logger.warn('push: expo delivery error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return false;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { SubscriptionRecord } from './types';
// JMAP subscriptions default to "no expiry" but servers may expire at will.
// We evict mappings 30d past last activity; active devices re-register on
// every app launch so real users never age out.
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
// How long a verification code sits waiting for the client to poll it.
const VERIFICATION_TTL_MS = 10 * 60 * 1000;
function getPushDir(): string {
return process.env.PUSH_DATA_DIR || path.join(process.cwd(), 'data', 'push');
}
function subscriptionsPath(): string {
return path.join(getPushDir(), 'subscriptions.json');
}
interface SubscriptionsFile {
records: Record<string, SubscriptionRecord>;
}
class SubscriptionStore {
private cache: Record<string, SubscriptionRecord> = {};
private loaded = false;
private writeQueue: Promise<void> = Promise.resolve();
async load(): Promise<void> {
try {
const raw = await readFile(subscriptionsPath(), 'utf-8');
const parsed = JSON.parse(raw) as SubscriptionsFile;
this.cache = parsed.records ?? {};
this.evictExpired();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
this.cache = {};
} else {
logger.warn('push: failed to read subscriptions', {
error: error instanceof Error ? error.message : 'Unknown error',
});
this.cache = {};
}
}
this.loaded = true;
}
async ensureLoaded(): Promise<void> {
if (!this.loaded) await this.load();
}
async get(id: string): Promise<SubscriptionRecord | null> {
await this.ensureLoaded();
const record = this.cache[id];
if (!record) return null;
if (this.isExpired(record)) {
delete this.cache[id];
await this.persist();
return null;
}
return record;
}
async put(id: string, record: SubscriptionRecord): Promise<void> {
await this.ensureLoaded();
this.cache[id] = record;
await this.persist();
}
async delete(id: string): Promise<void> {
await this.ensureLoaded();
if (id in this.cache) {
delete this.cache[id];
await this.persist();
}
}
private isExpired(record: SubscriptionRecord): boolean {
const age = Date.now() - (record.lastPushAt ?? record.createdAt);
return age > TTL_MS;
}
private evictExpired(): void {
for (const [id, record] of Object.entries(this.cache)) {
if (this.isExpired(record)) delete this.cache[id];
}
}
private persist(): Promise<void> {
// Serialize writes so concurrent requests don't clobber each other.
const next = this.writeQueue.then(() => this.flush());
this.writeQueue = next.catch(() => undefined);
return next;
}
private async flush(): Promise<void> {
const dir = getPushDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const target = subscriptionsPath();
const tmp = target + '.tmp';
const payload: SubscriptionsFile = { records: this.cache };
await writeFile(tmp, JSON.stringify(payload, null, 2), 'utf-8');
await rename(tmp, target);
}
}
export const subscriptionStore = new SubscriptionStore();
export { VERIFICATION_TTL_MS };
+23
View File
@@ -0,0 +1,23 @@
// Types shared between the relay routes and the store. Kept in a leaf module
// so both server code and admin UIs can import without pulling `node:fs`.
export interface SubscriptionRecord {
expoPushToken: string;
verificationCode: string | null;
createdAt: number;
lastPushAt: number | null;
accountLabel?: string;
}
export interface PushVerification {
'@type': 'PushVerification';
pushSubscriptionId: string;
verificationCode: string;
}
export interface StateChange {
'@type': 'StateChange';
changed: Record<string, Record<string, string>>;
}
export type JmapPushBody = PushVerification | StateChange;
+12
View File
@@ -0,0 +1,12 @@
export function isValidSubscriptionId(id: unknown): id is string {
return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id);
}
export function isValidExpoPushToken(token: unknown): token is string {
if (typeof token !== 'string') return false;
// Expo managed tokens, or raw FCM/APNs tokens for bare workflows.
return (
/^ExponentPushToken\[[A-Za-z0-9_-]+\]$/.test(token) ||
/^[A-Za-z0-9:_-]{40,250}$/.test(token)
);
}