import { instrument } from 'succinct-async' import express, { Request, Response, NextFunction } from 'express' import rateLimit from 'express-rate-limit' import { appSigner } 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' // Endpoints export const server: express.Application = express() // 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,POST,DELETE,OPTIONS') 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()) server.use(express.static('web/dist')) // 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 const addRoute = (method: 'get' | 'post' | '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) => { try { const {existsSync} = await import('fs') const webIndex = new URL('../web/dist/index.html', import.meta.url) if (existsSync(webIndex)) { return res.send(await render('../web/dist/index.html')) } } catch { // Fall through to JSON } res.json({ name: 'Mailship', description: 'Email notification server for Nostr', pubkey: await appSigner.getPubkey(), software: 'https://gitea.coracle.social/mplorentz/mailship', }) }) // Subscribe to email digests addRoute('post', '/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) { // 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 } = 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' }) } // Fetch the full event from the relay try { const [event] = await load({ relays: [relay], filters: getIdFilters([id]), }) if (!event) { // Event not found at relay — don't 404, just skip return res.json({ ok: true, skipped: true }) } const stored = await insertEvent(id, sub.id, event, 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.', }) ) } try { await confirmSubscriptionAction({ token: req.query.token }) res.send(await render('pages/confirm-success.html')) } 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 })) 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() } })