Make subscription upsert idempotent via PUT
Switch the subscribe endpoint from POST to an idempotent PUT so clients can always upsert without a stateful lookup first. - Add GET /subscription/email?pubkey= lookup so clients can avoid re-POSTing. - insertSubscription no longer clears confirmed_at unless the email address changes; a frequency change keeps the existing confirmation. - registerSubscription only sends a confirmation email when the subscription is new, unconfirmed, or the email address changed. - Update integration test and README for the PUT endpoint.
This commit is contained in:
parent
ff8e1d7a0d
commit
5abec46cb7
5 changed files with 82 additions and 14 deletions
16
README.md
16
README.md
|
|
@ -5,7 +5,7 @@ A Nostr email notification server. Receives events pushed from relays via NIP-9a
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
|
Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
|
||||||
│ NIP-98 auth
|
│ NIP-98 auth
|
||||||
│ returns {key, callback}
|
│ returns {key, callback}
|
||||||
│
|
│
|
||||||
|
|
@ -43,8 +43,10 @@ Flotilla ──HTTP──▶ Mailship (POST /subscription/email)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### POST /subscription/email
|
### PUT /subscription/email
|
||||||
Register for email digests.
|
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 }
|
Body: { email, frequency, pubkey }
|
||||||
|
|
@ -52,6 +54,14 @@ Auth: NIP-98 (planned)
|
||||||
Response: { key, callback }
|
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
|
### DELETE /subscription/:key
|
||||||
Unsubscribe.
|
Unsubscribe.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,12 @@ export const registerSubscription = instrument(
|
||||||
const sub = await db.insertSubscription(pubkey, email, frequency)
|
const sub = await db.insertSubscription(pubkey, email, frequency)
|
||||||
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
||||||
|
|
||||||
// Send confirmation email
|
// Only send a confirmation when the subscription is new, unconfirmed, or
|
||||||
await mailer.sendConfirm(sub)
|
// 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 }
|
return { key: sub.key, callback }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,26 @@ export const insertSubscription = instrument(
|
||||||
const existing = await getSubscriptionByPubkey(pubkey)
|
const existing = await getSubscriptionByPubkey(pubkey)
|
||||||
|
|
||||||
if (existing) {
|
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(
|
return assertResult(
|
||||||
parseSubscription(
|
parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
|
|
@ -113,7 +132,6 @@ export const insertSubscription = instrument(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new
|
|
||||||
return assertResult(
|
return assertResult(
|
||||||
parseSubscription(
|
parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const corsOrigin = process.env.CORS_ORIGIN ?? '*'
|
||||||
|
|
||||||
server.use((req: Request, res: Response, next: NextFunction) => {
|
server.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', corsOrigin)
|
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-Allow-Headers', 'Content-Type,Authorization')
|
||||||
res.setHeader('Access-Control-Max-Age', '86400')
|
res.setHeader('Access-Control-Max-Age', '86400')
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ server.use(
|
||||||
|
|
||||||
type Handler = (req: Request, res: Response) => Promise<any>
|
type Handler = (req: Request, res: Response) => Promise<any>
|
||||||
|
|
||||||
const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Handler) => {
|
const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) => {
|
||||||
server[method](
|
server[method](
|
||||||
path,
|
path,
|
||||||
instrument(path, async (req: Request, res: Response, next: NextFunction) => {
|
instrument(path, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|
@ -89,8 +89,34 @@ addRoute('get', '/', async (req: Request, res: Response) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Subscribe to email digests
|
// Look up an existing email subscription for a pubkey, so clients can avoid
|
||||||
addRoute('post', '/subscription/email', async (req: Request, res: Response) => {
|
// 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
|
const { email, frequency, pubkey } = req.body
|
||||||
|
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
|
|
@ -202,7 +228,11 @@ addRoute('get', '/confirm', async (req: Request, res: Response) => {
|
||||||
if (typeof req.query.token !== 'string') {
|
if (typeof req.query.token !== 'string') {
|
||||||
return res.send(
|
return res.send(
|
||||||
await render('pages/confirm-error.html', {
|
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 isActionError = error instanceof ActionError
|
||||||
const message = isActionError ? String(error) : 'Oops, something went wrong on our end!'
|
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) {
|
if (!isActionError) {
|
||||||
throw error
|
throw error
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ check_field "Root endpoint returns Mailship" "$HEALTH" "name" "Mailship"
|
||||||
# Test 2: Register subscription
|
# Test 2: Register subscription
|
||||||
echo ""
|
echo ""
|
||||||
echo "2. Register subscription"
|
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"}')
|
-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)
|
KEY=$(echo "$REG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue