feat: implement JMAP push notification handling and subscription management
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user