mailship/src/database.ts

394 lines
12 KiB
TypeScript
Raw Normal View History

/* eslint @typescript-eslint/no-unused-vars: 0 */
import sqlite3 from 'sqlite3'
import crypto from 'crypto'
import { instrument } from 'succinct-async'
import { now } from '@welshman/lib'
import type { Subscription } from './alert.js'
const DATA_DIR = process.env.DATA_DIR || '.'
const db = new sqlite3.Database(DATA_DIR + '/mailship.db')
type Param = number | string | boolean | null | undefined
type Row = Record<string, any>
const run = (query: string, params: Param[] = []) =>
new Promise((resolve, reject) => {
db.run(query, params, function (err) {
return err ? reject(err) : resolve(this.changes > 0)
})
})
// prettier-ignore
const all = <T=Row>(query: string, params: Param[] = []) =>
new Promise<T[]>((resolve, reject) => {
db.all(query, params, (err, rows: T[]) => (err ? reject(err) : resolve(rows)))
})
// prettier-ignore
const get = <T=Row>(query: string, params: Param[] = []) =>
new Promise<T | undefined>((resolve, reject) => {
db.get(query, params, (err, row) => {
if (err) {
reject(err)
} else if (row) {
resolve(row as T)
} else {
resolve(undefined)
}
})
})
async function assertResult<T>(p: T | Promise<T>) {
return (await p)!
}
// Migrations
export const migrate = () =>
new Promise<void>(async (resolve, reject) => {
try {
db.serialize(async () => {
await run(
`
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
pubkey TEXT NOT NULL,
email TEXT NOT NULL,
frequency TEXT NOT NULL DEFAULT 'daily',
hour INTEGER NOT NULL DEFAULT 17,
minute INTEGER NOT NULL DEFAULT 0,
day_of_week INTEGER,
timezone TEXT NOT NULL DEFAULT 'UTC',
created_at INTEGER NOT NULL,
confirmed_at INTEGER,
unsubscribed_at INTEGER,
last_digest_at INTEGER
)
`
)
await run(
`
CREATE TABLE IF NOT EXISTS events (
id TEXT NOT NULL,
subscription_id TEXT NOT NULL,
event JSON NOT NULL,
relay TEXT NOT NULL,
received_at INTEGER NOT NULL,
PRIMARY KEY (id, subscription_id)
)
`
)
await run(
`CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)`
)
// Tombstone older duplicate active rows, so the unique index below can be created
// even if a previous version of the server let races insert more than one.
await run(
`
UPDATE subscriptions
SET unsubscribed_at = created_at
WHERE unsubscribed_at IS NULL
AND id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY pubkey ORDER BY created_at DESC, id DESC
) AS rn
FROM subscriptions
WHERE unsubscribed_at IS NULL
)
WHERE rn = 1
)
`
)
// At most one active subscription per pubkey. Enforced in the DB so a
// check-then-insert race can never create duplicate digest rows.
await run(
`
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_active_pubkey
ON subscriptions (pubkey)
WHERE unsubscribed_at IS NULL
`
)
// Idempotent migration: add schedule columns to existing databases.
// ALTER TABLE ADD COLUMN fails if the column already exists, so we
// run each one and ignore "duplicate column" errors.
const addCol = (colDef: string) =>
run(`ALTER TABLE subscriptions ADD COLUMN ${colDef}`).catch((err: any) => {
if (!err?.message?.includes('duplicate column')) throw err
})
await addCol('hour INTEGER NOT NULL DEFAULT 17')
await addCol('minute INTEGER NOT NULL DEFAULT 0')
await addCol('day_of_week INTEGER')
await addCol("timezone TEXT NOT NULL DEFAULT 'UTC'")
resolve()
})
} catch (err) {
reject(err)
}
})
// Subscriptions
const parseSubscription = (row: any): Subscription | undefined => {
if (row) {
return row as Subscription
}
}
export const updateSubscription = instrument(
'database.updateSubscription',
async (existing: Subscription, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
const scheduleChanged =
(hour !== undefined && hour !== existing.hour) ||
(minute !== undefined && minute !== existing.minute) ||
(dayOfWeek !== undefined && dayOfWeek !== existing.day_of_week) ||
(timezone !== undefined && timezone !== existing.timezone)
if (existing.email === email && existing.frequency === frequency && !scheduleChanged) {
return existing
}
const hr = hour ?? existing.hour
const mn = minute ?? existing.minute
const dow = dayOfWeek ?? existing.day_of_week
const tz = timezone ?? existing.timezone
// Update by id, not pubkey, so tombstoned rows for the same account are never
// re-activated alongside this one (the unique index would reject that anyway).
if (existing.email === email) {
return parseSubscription(
await get(
`UPDATE subscriptions SET frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, unsubscribed_at = NULL
WHERE id = ? RETURNING *`,
[frequency, hr, mn, dow, tz, existing.id]
)
)
}
return parseSubscription(
await get(
`UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL
WHERE id = ? RETURNING *`,
[email, frequency, hr, mn, dow, tz, existing.id]
)
)
}
)
export const insertSubscription = instrument(
'database.insertSubscription',
async (pubkey: string, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
const existing = await getSubscriptionByPubkey(pubkey)
if (existing) {
return assertResult(await updateSubscription(existing, email, frequency, hour, minute, dayOfWeek, timezone))
}
try {
return assertResult(
parseSubscription(
await get(
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, hour, minute, day_of_week, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
[
crypto.randomUUID(),
crypto.randomBytes(32).toString('hex'),
pubkey,
email,
frequency,
hour ?? 17,
minute ?? 0,
dayOfWeek ?? null,
timezone ?? 'UTC',
now(),
]
)
)
)
} catch (err: any) {
// A concurrent request inserted the active subscription between our select
// and insert. The partial unique index forces one active row per pubkey,
// so fall back to updating the row that won the race.
if (err.message?.includes('UNIQUE constraint')) {
const concurrent = await getSubscriptionByPubkey(pubkey)
if (concurrent) {
return assertResult(await updateSubscription(concurrent, email, frequency, hour, minute, dayOfWeek, timezone))
}
}
throw err
}
}
)
fix: distinguish already-confirmed tokens from invalid ones; prevent duplicate active subscriptions Problem ------- 1. Re-clicking an already-confirmed confirmation link (e.g. /confirm?token=…) returned from confirmSubscription because the SQL WHERE clause required . The caller then threw an ActionError('invalid or expired') which rendered the 'Email not confirmed' error page — misleading for someone who had already confirmed. 2. A second PUT /subscription/email with the same email+frequency could silently bypass the upsert path when getSubscriptionByPubkey found the active row but updateSubscription returned it unchanged (email and frequency matched). While the unique index prevented a true duplicate INSERT, the code path was fragile and the regression test was missing. Changes ------- database.ts: - confirmSubscription now returns { sub, alreadyConfirmed } | undefined. First it tries the existing UPDATE (unconfirmed tokens only). If that returns no rows, it looks up the key directly: if the row exists and is already confirmed, returns { sub, alreadyConfirmed: true }. If the row doesn't exist or is unsubscribed, returns undefined (invalid/expired). - Exported new ConfirmResult type for callers. actions.ts: - confirmSubscriptionAction destructures the new return type. - Only registers the cron job on fresh confirmation (not re-confirms). - Returns the ConfirmResult so the route can distinguish the two cases. server.ts: - /confirm route checks result.alreadyConfirmed and renders confirm-already.html instead of confirm-success.html. pages/confirm-already.html: - New page with title 'Email already confirmed' and an info message explaining the address was already confirmed. Tests: - test/confirm-already-confirmed.test.ts — NEW (3 tests): first confirm succeeds with alreadyConfirmed=false; second confirm returns alreadyConfirmed=true; nonexistent token returns undefined. - test/duplicate-subscription.test.ts — NEW (4 tests): full cycle of register → confirm → re-register → assert one active row with unchanged key, verifying the upsert is idempotent. - Adapted 3 existing test files to destructure the new ConfirmResult.
2026-09-17 20:57:30 +00:00
export type ConfirmResult = {
sub: Subscription
alreadyConfirmed: boolean
}
export const confirmSubscription = instrument(
'database.confirmSubscription',
fix: distinguish already-confirmed tokens from invalid ones; prevent duplicate active subscriptions Problem ------- 1. Re-clicking an already-confirmed confirmation link (e.g. /confirm?token=…) returned from confirmSubscription because the SQL WHERE clause required . The caller then threw an ActionError('invalid or expired') which rendered the 'Email not confirmed' error page — misleading for someone who had already confirmed. 2. A second PUT /subscription/email with the same email+frequency could silently bypass the upsert path when getSubscriptionByPubkey found the active row but updateSubscription returned it unchanged (email and frequency matched). While the unique index prevented a true duplicate INSERT, the code path was fragile and the regression test was missing. Changes ------- database.ts: - confirmSubscription now returns { sub, alreadyConfirmed } | undefined. First it tries the existing UPDATE (unconfirmed tokens only). If that returns no rows, it looks up the key directly: if the row exists and is already confirmed, returns { sub, alreadyConfirmed: true }. If the row doesn't exist or is unsubscribed, returns undefined (invalid/expired). - Exported new ConfirmResult type for callers. actions.ts: - confirmSubscriptionAction destructures the new return type. - Only registers the cron job on fresh confirmation (not re-confirms). - Returns the ConfirmResult so the route can distinguish the two cases. server.ts: - /confirm route checks result.alreadyConfirmed and renders confirm-already.html instead of confirm-success.html. pages/confirm-already.html: - New page with title 'Email already confirmed' and an info message explaining the address was already confirmed. Tests: - test/confirm-already-confirmed.test.ts — NEW (3 tests): first confirm succeeds with alreadyConfirmed=false; second confirm returns alreadyConfirmed=true; nonexistent token returns undefined. - test/duplicate-subscription.test.ts — NEW (4 tests): full cycle of register → confirm → re-register → assert one active row with unchanged key, verifying the upsert is idempotent. - Adapted 3 existing test files to destructure the new ConfirmResult.
2026-09-17 20:57:30 +00:00
async (key: string): Promise<ConfirmResult | undefined> => {
// Try to update an unconfirmed, active row
const updated = parseSubscription(
await get(
`UPDATE subscriptions SET confirmed_at = unixepoch()
WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`,
[key]
)
)
fix: distinguish already-confirmed tokens from invalid ones; prevent duplicate active subscriptions Problem ------- 1. Re-clicking an already-confirmed confirmation link (e.g. /confirm?token=…) returned from confirmSubscription because the SQL WHERE clause required . The caller then threw an ActionError('invalid or expired') which rendered the 'Email not confirmed' error page — misleading for someone who had already confirmed. 2. A second PUT /subscription/email with the same email+frequency could silently bypass the upsert path when getSubscriptionByPubkey found the active row but updateSubscription returned it unchanged (email and frequency matched). While the unique index prevented a true duplicate INSERT, the code path was fragile and the regression test was missing. Changes ------- database.ts: - confirmSubscription now returns { sub, alreadyConfirmed } | undefined. First it tries the existing UPDATE (unconfirmed tokens only). If that returns no rows, it looks up the key directly: if the row exists and is already confirmed, returns { sub, alreadyConfirmed: true }. If the row doesn't exist or is unsubscribed, returns undefined (invalid/expired). - Exported new ConfirmResult type for callers. actions.ts: - confirmSubscriptionAction destructures the new return type. - Only registers the cron job on fresh confirmation (not re-confirms). - Returns the ConfirmResult so the route can distinguish the two cases. server.ts: - /confirm route checks result.alreadyConfirmed and renders confirm-already.html instead of confirm-success.html. pages/confirm-already.html: - New page with title 'Email already confirmed' and an info message explaining the address was already confirmed. Tests: - test/confirm-already-confirmed.test.ts — NEW (3 tests): first confirm succeeds with alreadyConfirmed=false; second confirm returns alreadyConfirmed=true; nonexistent token returns undefined. - test/duplicate-subscription.test.ts — NEW (4 tests): full cycle of register → confirm → re-register → assert one active row with unchanged key, verifying the upsert is idempotent. - Adapted 3 existing test files to destructure the new ConfirmResult.
2026-09-17 20:57:30 +00:00
if (updated) {
return { sub: updated, alreadyConfirmed: false }
}
// No unconfirmed row was updated. Check if the key exists and is
// already confirmed — the user is re-clicking a used link. If the row
// is unsubscribed, treat it as invalid (expired).
const existing = await getSubscriptionByKey(key)
if (!existing || existing.unsubscribed_at) {
return undefined
}
return { sub: existing, alreadyConfirmed: true }
}
)
export const unsubscribeSubscription = instrument(
'database.unsubscribeSubscription',
async (key: string) => {
return parseSubscription(
await get(
`UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`,
[key]
)
)
}
)
export const getSubscriptionById = instrument(
'database.getSubscriptionById',
async (id: string) => {
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id]))
}
)
export const getSubscriptionByKey = instrument(
'database.getSubscriptionByKey',
async (key: string) => {
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key]))
}
)
export const getSubscriptionByPubkey = instrument(
'database.getSubscriptionByPubkey',
async (pubkey: string) => {
return parseSubscription(
await get(`SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`, [
pubkey,
])
)
}
)
export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => {
const rows = await all(
`SELECT * FROM subscriptions
WHERE confirmed_at IS NOT NULL
AND unsubscribed_at IS NULL`
)
return rows.map(parseSubscription) as Subscription[]
})
export const updateLastDigestAt = instrument(
'database.updateLastDigestAt',
async (id: string, timestamp: number) => {
await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id])
}
)
// Events
export type StoredEvent = {
id: string
subscription_id: string
event: any
relay: string
received_at: number
}
export const insertEvent = instrument(
'database.insertEvent',
async (eventId: string, subscriptionId: string, event: any, relay: string) => {
try {
await run(
`INSERT INTO events (id, subscription_id, event, relay, received_at)
VALUES (?, ?, ?, ?, ?)`,
[eventId, subscriptionId, JSON.stringify(event), relay, now()]
)
return true
} catch (err: any) {
// PRIMARY KEY collision = dedup, not an error
if (err.message?.includes('UNIQUE constraint')) {
return false
}
throw err
}
}
)
export const getEventsForSubscription = instrument(
'database.getEventsForSubscription',
async (subscriptionId: string, since: number) => {
const rows = await all<StoredEvent>(
`SELECT * FROM events
WHERE subscription_id = ? AND received_at > ?
ORDER BY received_at DESC`,
[subscriptionId, since]
)
return rows.map((row) => ({
...row,
event: JSON.parse(row.event as any),
}))
}
)
export const deleteEventsForSubscription = instrument(
'database.deleteEventsForSubscription',
async (subscriptionId: string, since: number) => {
await run(`DELETE FROM events WHERE subscription_id = ? AND received_at > ?`, [
subscriptionId,
since,
])
}
)
export const deleteEventsByIds = instrument(
'database.deleteEventsByIds',
async (subscriptionId: string, eventIds: string[]) => {
if (eventIds.length === 0) return
const placeholders = eventIds.map(() => '?').join(',')
await run(
`DELETE FROM events WHERE subscription_id = ? AND id IN (${placeholders})`,
[subscriptionId, ...eventIds]
)
}
)
export const purgeEventsOlderThan = instrument(
'database.purgeEventsOlderThan',
async (timestamp: number) => {
await run(`DELETE FROM events WHERE received_at < ?`, [timestamp])
}
)