Merge branch 'main' into cron-minutely

This commit is contained in:
mplorentz 2026-09-03 12:00:22 -04:00
commit db490f5c99
6 changed files with 123 additions and 26 deletions

View file

@ -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.

View file

@ -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 }
}, },

View file

@ -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(

View file

@ -1,34 +1,63 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Confirmation Failed</title> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Confirmation Failed</title>
<style> <style>
* { box-sizing: border-box; }
body { body {
font-family: system-ui, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: #f5f5f5; background: #f0f2f5;
color: #1e293b;
} }
.container { .container {
width: 100%;
max-width: 480px;
margin: 16px;
text-align: center; text-align: center;
padding: 2rem; padding: 40px 32px;
background: white; background: #ffffff;
border-radius: 8px; border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); border: 1px solid #e2e8f0;
}
.logo { height: 40px; width: auto; margin: 0 auto 16px; display: block; }
.brand {
font-size: 24px;
font-weight: 700;
margin: 0 0 28px;
color: {{brandAccent}};
}
.title {
font-size: 20px;
font-weight: 700;
margin: 0 0 12px;
color: #dc2626;
} }
.message { .message {
margin-top: 1rem; font-size: 15px;
color: #e74c3c; line-height: 1.6;
color: #64748b;
margin: 0;
} }
.message a { color: {{brandAccent}}; text-decoration: none; font-weight: 600; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h2>Confirmation Failed</h2> {{#brandLogo}}<img class="logo" src="{{brandLogo}}" alt="{{brandName}}" />{{/brandLogo}}
<div class="message">{{message}}</div> <div class="brand">{{brandName}}</div>
<h1 class="title">Email not confirmed</h1>
<p class="message">
{{message}}
<br /><br />
Visit <a href="{{settingsUrl}}">{{brandName}}</a> to try again or manage your subscription.
</p>
</div> </div>
</body> </body>
</html> </html>

View file

@ -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

View file

@ -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)