From 15006086d2159bc07f16b830babd296c07c939fb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:26:09 +0200 Subject: [PATCH] feat: implement JMAP push notification handling and subscription management --- app/api/push/jmap/[id]/route.ts | 91 ++++++++++++++++++++++ app/api/push/register/[id]/route.ts | 29 +++++++ app/api/push/register/route.ts | 52 +++++++++++++ app/api/push/verify/[id]/route.ts | 34 +++++++++ lib/push/expo.ts | 61 +++++++++++++++ lib/push/store.ts | 113 ++++++++++++++++++++++++++++ lib/push/types.ts | 23 ++++++ lib/push/validation.ts | 12 +++ 8 files changed, 415 insertions(+) create mode 100644 app/api/push/jmap/[id]/route.ts create mode 100644 app/api/push/register/[id]/route.ts create mode 100644 app/api/push/register/route.ts create mode 100644 app/api/push/verify/[id]/route.ts create mode 100644 lib/push/expo.ts create mode 100644 lib/push/store.ts create mode 100644 lib/push/types.ts create mode 100644 lib/push/validation.ts diff --git a/app/api/push/jmap/[id]/route.ts b/app/api/push/jmap/[id]/route.ts new file mode 100644 index 00000000..a83e2aac --- /dev/null +++ b/app/api/push/jmap/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/push/register/[id]/route.ts b/app/api/push/register/[id]/route.ts new file mode 100644 index 00000000..f9ad8028 --- /dev/null +++ b/app/api/push/register/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/push/register/route.ts b/app/api/push/register/route.ts new file mode 100644 index 00000000..c5e459b0 --- /dev/null +++ b/app/api/push/register/route.ts @@ -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 }); + } +} diff --git a/app/api/push/verify/[id]/route.ts b/app/api/push/verify/[id]/route.ts new file mode 100644 index 00000000..762bd8c6 --- /dev/null +++ b/app/api/push/verify/[id]/route.ts @@ -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 }); + } +} diff --git a/lib/push/expo.ts b/lib/push/expo.ts new file mode 100644 index 00000000..5dafc137 --- /dev/null +++ b/lib/push/expo.ts @@ -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 { + // 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(); + 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; + } +} diff --git a/lib/push/store.ts b/lib/push/store.ts new file mode 100644 index 00000000..c143eed2 --- /dev/null +++ b/lib/push/store.ts @@ -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; +} + +class SubscriptionStore { + private cache: Record = {}; + private loaded = false; + private writeQueue: Promise = Promise.resolve(); + + async load(): Promise { + 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 { + if (!this.loaded) await this.load(); + } + + async get(id: string): Promise { + 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 { + await this.ensureLoaded(); + this.cache[id] = record; + await this.persist(); + } + + async delete(id: string): Promise { + 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 { + // 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 { + 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 }; diff --git a/lib/push/types.ts b/lib/push/types.ts new file mode 100644 index 00000000..1cbea419 --- /dev/null +++ b/lib/push/types.ts @@ -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>; +} + +export type JmapPushBody = PushVerification | StateChange; diff --git a/lib/push/validation.ts b/lib/push/validation.ts new file mode 100644 index 00000000..11785de9 --- /dev/null +++ b/lib/push/validation.ts @@ -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) + ); +}