diff --git a/README.md b/README.md index 1b04755..4f6f479 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Nostr email notification server. Receives events pushed from relays via NIP-9a ## Architecture ``` -Flotilla ──HTTP──▶ Mailship (POST /subscription/email) +Flotilla ──HTTP──▶ Mailship (PUT /subscription/email) │ NIP-98 auth │ returns {key, callback} │ @@ -43,8 +43,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email) ## API -### POST /subscription/email -Register for email digests. +### PUT /subscription/email +Idempotently register or update an email subscription. Re-sends the confirmation +email only when the subscription is new or the email address changed; a frequency +change keeps the existing confirmation. ``` Body: { email, frequency, pubkey } @@ -52,6 +54,14 @@ Auth: NIP-98 (planned) Response: { key, callback } ``` +### GET /subscription/email?pubkey=... +Look up an existing subscription, so clients can avoid re-registering (and +re-confirming) when settings haven't changed. Returns 404 if none exists. + +``` +Response: { key, callback, email, frequency, confirmed } +``` + ### DELETE /subscription/:key Unsubscribe. diff --git a/src/actions.ts b/src/actions.ts index 459cd01..ceec6fe 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -23,8 +23,12 @@ export const registerSubscription = instrument( const sub = await db.insertSubscription(pubkey, email, frequency) const callback = `${process.env.BASE_URL}/notify/${sub.id}` - // Send confirmation email - await mailer.sendConfirm(sub) + // Only send a confirmation when the subscription is new, unconfirmed, or + // its email address changed. An already-confirmed, unchanged subscription + // (or one where only the frequency changed) skips it. + if (!sub.confirmed_at) { + await mailer.sendConfirm(sub) + } return { key: sub.key, callback } }, diff --git a/src/database.ts b/src/database.ts index b50fe56..268675a 100644 --- a/src/database.ts +++ b/src/database.ts @@ -101,7 +101,26 @@ export const insertSubscription = instrument( const existing = await getSubscriptionByPubkey(pubkey) if (existing) { - // Update existing + // If nothing changed, keep confirmation and don't re-validate + if (existing.email === email && existing.frequency === frequency) { + return assertResult(parseSubscription(existing)) + } + + // Update existing. Only a change of email address invalidates the + // existing confirmation (the new address must be verified); changing + // the frequency keeps it confirmed. + if (existing.email === email) { + return assertResult( + parseSubscription( + await get( + `UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL + WHERE pubkey = ? RETURNING *`, + [frequency, pubkey], + ), + ), + ) + } + return assertResult( parseSubscription( await get( @@ -113,7 +132,6 @@ export const insertSubscription = instrument( ) } - // Create new return assertResult( parseSubscription( await get( diff --git a/src/server.ts b/src/server.ts index 1cc71de..eb3fe92 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,7 +20,7 @@ const corsOrigin = process.env.CORS_ORIGIN ?? '*' server.use((req: Request, res: Response, next: NextFunction) => { res.setHeader('Access-Control-Allow-Origin', corsOrigin) - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') res.setHeader('Access-Control-Max-Age', '86400') @@ -57,7 +57,7 @@ server.use( type Handler = (req: Request, res: Response) => Promise -const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Handler) => { +const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) => { server[method]( path, instrument(path, async (req: Request, res: Response, next: NextFunction) => { @@ -89,8 +89,34 @@ addRoute('get', '/', async (req: Request, res: Response) => { }) }) -// Subscribe to email digests -addRoute('post', '/subscription/email', async (req: Request, res: Response) => { +// Look up an existing email subscription for a pubkey, so clients can avoid +// re-registering (and re-confirming) when settings haven't changed. +addRoute('get', '/subscription/email', async (req: Request, res: Response) => { + const { pubkey } = req.query + + if (!pubkey || typeof pubkey !== 'string') { + return res.status(400).json({ error: 'pubkey is required' }) + } + + const sub = await getSubscriptionByPubkey(pubkey) + + if (!sub) { + return res.status(404).json({ error: 'Subscription not found' }) + } + + const callback = `${process.env.BASE_URL}/notify/${sub.id}` + + res.json({ + key: sub.key, + callback, + email: sub.email, + frequency: sub.frequency, + confirmed: Boolean(sub.confirmed_at), + }) +}) + +// Subscribe to email digests (idempotent PUT upsert) +addRoute('put', '/subscription/email', async (req: Request, res: Response) => { const { email, frequency, pubkey } = req.body if (!email || !email.includes('@')) { @@ -202,7 +228,11 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { if (typeof req.query.token !== 'string') { return res.send( await render('pages/confirm-error.html', { - message: 'No confirmation token was provided.', + message: 'No confirmation token was provided. Please check the link in your email and try again.', + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, }) ) } @@ -220,7 +250,13 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => { const isActionError = error instanceof ActionError const message = isActionError ? String(error) : 'Oops, something went wrong on our end!' - res.send(await render('pages/confirm-error.html', { message })) + res.send(await render('pages/confirm-error.html', { + message, + brandName: BRAND_NAME, + brandAccent: BRAND_ACCENT, + brandLogo: BRAND_LOGO, + settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`, + })) if (!isActionError) { throw error diff --git a/test/integration.sh b/test/integration.sh index 88dcc3a..7d43141 100755 --- a/test/integration.sh +++ b/test/integration.sh @@ -117,7 +117,7 @@ check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship" # Test 2: Register subscription echo "" echo "2. Register subscription" -REG=$(curl -s "$BASE_URL/subscription/email" -X POST -H "Content-Type: application/json" \ +REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \ -d '{"email":"test@example.com","frequency":"daily","pubkey":"abc123"}') KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null)