mailship/src/server.ts

284 lines
8.5 KiB
TypeScript
Raw Normal View History

import { instrument } from 'succinct-async'
import express, { Request, Response, NextFunction } from 'express'
import rateLimit from 'express-rate-limit'
import { appSigner, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL } from './env.js'
import { render } from './templates.js'
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
import { load } from '@welshman/net'
import { getIdFilters } from '@welshman/util'
import { verifyEvent } from 'nostr-tools/pure'
// Endpoints
export const server: express.Application = express()
2026-08-20 16:42:31 +00:00
// The browser hits /subscription with an Authorization header and Content-Type:
// application/json, which triggers a CORS preflight. Answer it and allow the
// configured origin so the client can register.
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,PUT,POST,DELETE,OPTIONS')
2026-08-20 16:42:31 +00:00
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
next()
})
server.use(express.json())
// Rate limit for registration endpoints
server.use(
'/subscription',
rateLimit({
limit: 30,
windowMs: 5 * 60 * 1000,
validate: { xForwardedForHeader: false },
})
)
// Rate limit for notify endpoint
server.use(
'/notify',
rateLimit({
limit: 300,
windowMs: 60 * 1000,
validate: { xForwardedForHeader: false },
})
)
type Handler = (req: Request, res: Response) => Promise<any>
const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) => {
server[method](
path,
instrument(path, async (req: Request, res: Response, next: NextFunction) => {
try {
await handler(req, res)
} catch (e) {
next(e)
}
})
)
}
addRoute('get', '/', async (req: Request, res: Response) => {
res.json({
name: 'Mailship',
description: 'Email notification server for Nostr',
pubkey: await appSigner.getPubkey(),
software: 'https://gitea.coracle.social/mplorentz/mailship',
})
})
// 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('@')) {
return res.status(400).json({ error: 'A valid email address is required' })
}
if (!['daily', 'weekly'].includes(frequency)) {
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
}
if (!pubkey) {
return res.status(400).json({ error: 'pubkey is required' })
}
// TODO: Verify NIP-98 auth header
// const auth = req.headers.authorization
// if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' })
// Verify using @welshman/util makeHttpAuth
try {
const result = await registerSubscription({ pubkey, email, frequency })
res.json(result)
} catch (error: any) {
2026-08-25 19:15:33 +00:00
// The subscription was still created, but sending the confirmation email
// may have failed. Always log it so SMTP issues are visible.
console.error('Failed to send confirmation email for', pubkey, error?.message || error)
// Look up the actual subscription key from the DB
const sub = await getSubscriptionByPubkey(pubkey)
if (sub) {
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
res.json({ key: sub.key, callback })
} else {
console.error('Failed to register subscription:', error)
res.status(500).json({ error: 'Failed to register subscription' })
}
}
})
// Delete subscription
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
const { key } = req.params
const sub = await getSubscriptionByKey(key)
if (!sub) {
return res.status(404).json({ error: 'Subscription not found' })
}
// TODO: Verify NIP-98 auth header matches sub.pubkey
await unsubscribeAction({ token: key })
res.json({ ok: true })
})
// NIP-9a relay push callback
addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
const { id, relay, event } = req.body
if (!id || !relay) {
return res.status(400).json({ error: 'id and relay are required' })
}
const sub = await getSubscriptionById(req.params.id)
if (!sub) {
// 404 signals the relay to delete the subscription
return res.status(404).json({ error: 'Subscription not found' })
}
if (!sub.confirmed_at || sub.unsubscribed_at) {
return res.status(404).json({ error: 'Subscription not active' })
}
try {
let storedEvent = event
if (storedEvent) {
// If the subscription requested include_event, verify and use it directly
if (storedEvent.id !== id || !validEvent(storedEvent)) {
return res.status(400).json({ error: 'Invalid event' })
}
} else {
// Otherwise fetch the full event from the relay
const [fetched] = await load({
relays: [relay],
filters: getIdFilters([id]),
})
storedEvent = fetched
if (!storedEvent) {
// Event not found at relay — don't 404, just skip
return res.json({ ok: true, skipped: true })
}
}
const stored = await insertEvent(id, sub.id, storedEvent, relay)
return res.json({ ok: true, stored })
} catch (error) {
console.error(`Failed to process notification for subscription ${sub.id}:`, error)
return res.status(500).json({ error: 'Internal server error' })
}
})
// Confirmation
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. 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`,
})
)
}
try {
await confirmSubscriptionAction({ token: req.query.token })
res.send(await render('pages/confirm-success.html', {
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
}))
} catch (error) {
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,
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
}))
if (!isActionError) {
throw error
}
}
})
// Unsubscribe
addRoute('get', '/unsubscribe', async (req: Request, res: Response) => {
try {
await unsubscribeAction({ token: req.query.token as string })
} catch (error) {
// pass
}
res.send(await render('pages/unsubscribe.html'))
})
server.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err) {
if (err instanceof ActionError) {
res.status(400).json({ error: err.message })
} else {
console.log('Unhandled error', err)
res.status(500).json({ error: 'Internal server error' })
}
} else {
next()
}
})
// Validate an event's signature and that its id hash matches (defense against
// a malicious relay forwarding tampered content via include_event).
const validEvent = (event: any) => {
if (!event || typeof event !== 'object') return false
return verifyEvent(event)
}