2026-08-18 16:21:22 +00:00
|
|
|
/* 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'
|
|
|
|
|
|
2026-08-18 16:35:20 +00:00
|
|
|
const DATA_DIR = process.env.DATA_DIR || '.'
|
|
|
|
|
const db = new sqlite3.Database(DATA_DIR + '/mailship.db')
|
2026-08-18 16:21:22 +00:00
|
|
|
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
type Param = number | string | boolean | null | undefined
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
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',
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
hour INTEGER NOT NULL DEFAULT 17,
|
|
|
|
|
minute INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
day_of_week INTEGER,
|
|
|
|
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
2026-08-18 16:21:22 +00:00
|
|
|
created_at INTEGER NOT NULL,
|
|
|
|
|
confirmed_at INTEGER,
|
|
|
|
|
unsubscribed_at INTEGER,
|
|
|
|
|
last_digest_at INTEGER
|
|
|
|
|
)
|
2026-09-03 17:42:50 +00:00
|
|
|
`
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
)
|
2026-09-03 17:42:50 +00:00
|
|
|
`
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
await run(
|
2026-09-03 17:42:50 +00:00
|
|
|
`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
|
|
|
|
|
`
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
|
|
|
|
|
// 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'")
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
resolve()
|
|
|
|
|
})
|
|
|
|
|
} catch (err) {
|
|
|
|
|
reject(err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Subscriptions
|
|
|
|
|
|
|
|
|
|
const parseSubscription = (row: any): Subscription | undefined => {
|
|
|
|
|
if (row) {
|
|
|
|
|
return row as Subscription
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 17:42:50 +00:00
|
|
|
export const updateSubscription = instrument(
|
|
|
|
|
'database.updateSubscription',
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
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) {
|
2026-09-03 17:42:50 +00:00
|
|
|
return existing
|
|
|
|
|
}
|
|
|
|
|
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
const hr = hour ?? existing.hour
|
|
|
|
|
const mn = minute ?? existing.minute
|
|
|
|
|
const dow = dayOfWeek ?? existing.day_of_week
|
|
|
|
|
const tz = timezone ?? existing.timezone
|
|
|
|
|
|
2026-09-03 17:42:50 +00:00
|
|
|
// 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(
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
`UPDATE subscriptions SET frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, unsubscribed_at = NULL
|
2026-09-03 17:42:50 +00:00
|
|
|
WHERE id = ? RETURNING *`,
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
[frequency, hr, mn, dow, tz, existing.id]
|
2026-09-03 17:42:50 +00:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parseSubscription(
|
|
|
|
|
await get(
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
`UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL
|
2026-09-03 17:42:50 +00:00
|
|
|
WHERE id = ? RETURNING *`,
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
[email, frequency, hr, mn, dow, tz, existing.id]
|
2026-09-03 17:42:50 +00:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-21 14:05:53 +00:00
|
|
|
const getMostRecentTombstonedSubscription = async (pubkey: string) =>
|
|
|
|
|
parseSubscription(
|
|
|
|
|
await get(
|
|
|
|
|
`SELECT * FROM subscriptions
|
|
|
|
|
WHERE pubkey = ? AND unsubscribed_at IS NOT NULL
|
|
|
|
|
ORDER BY created_at DESC, id DESC LIMIT 1`,
|
|
|
|
|
[pubkey]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const reactivateSubscription = async (tombstoned: Subscription, frequency: string) =>
|
|
|
|
|
parseSubscription(
|
|
|
|
|
await get(
|
|
|
|
|
`UPDATE subscriptions SET key = ?, frequency = ?, unsubscribed_at = NULL
|
|
|
|
|
WHERE id = ? RETURNING *`,
|
|
|
|
|
[crypto.randomBytes(32).toString('hex'), frequency, tombstoned.id]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
export const insertSubscription = instrument(
|
|
|
|
|
'database.insertSubscription',
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
async (pubkey: string, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
|
2026-08-18 16:21:22 +00:00
|
|
|
const existing = await getSubscriptionByPubkey(pubkey)
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
return assertResult(await updateSubscription(existing, email, frequency, hour, minute, dayOfWeek, timezone))
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-09-03 15:47:21 +00:00
|
|
|
|
2026-09-21 14:05:53 +00:00
|
|
|
// No active row. If this account previously subscribed to the same email
|
|
|
|
|
// and was unsubscribed, reactivate that row instead of inserting a fresh
|
|
|
|
|
// one. Otherwise every turn-on → turn-off → turn-on cycle would create a
|
|
|
|
|
// new row, abandoning the confirmed state (and the already-known key).
|
|
|
|
|
const tombstoned = await getMostRecentTombstonedSubscription(pubkey)
|
|
|
|
|
|
|
|
|
|
if (tombstoned?.email === email) {
|
|
|
|
|
try {
|
|
|
|
|
return assertResult(await reactivateSubscription(tombstoned, frequency))
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
if (err.message?.includes('UNIQUE constraint')) {
|
|
|
|
|
const concurrent = await getSubscriptionByPubkey(pubkey)
|
|
|
|
|
|
|
|
|
|
if (concurrent) {
|
|
|
|
|
return assertResult(await updateSubscription(concurrent, email, frequency))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 17:42:50 +00:00
|
|
|
try {
|
2026-08-18 16:21:22 +00:00
|
|
|
return assertResult(
|
|
|
|
|
parseSubscription(
|
|
|
|
|
await get(
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, hour, minute, day_of_week, timezone, created_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
2026-09-03 17:42:50 +00:00
|
|
|
[
|
|
|
|
|
crypto.randomUUID(),
|
|
|
|
|
crypto.randomBytes(32).toString('hex'),
|
|
|
|
|
pubkey,
|
|
|
|
|
email,
|
|
|
|
|
frequency,
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
hour ?? 17,
|
|
|
|
|
minute ?? 0,
|
|
|
|
|
dayOfWeek ?? null,
|
|
|
|
|
timezone ?? 'UTC',
|
2026-09-03 17:42:50 +00:00
|
|
|
now(),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
2026-09-03 17:42:50 +00:00
|
|
|
} 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)
|
2026-08-18 16:21:22 +00:00
|
|
|
|
2026-09-03 17:42:50 +00:00
|
|
|
if (concurrent) {
|
feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
Add DB migration (hour, minute, day_of_week, timezone columns) with
idempotent ALTER TABLE upgrade path for existing databases.
Extend getCronExpression with optional dayOfWeek parameter; weekly
defaults to Monday (1) when not specified.
Worker createJob now passes stored schedule fields to cron expression
and uses the subscription's IANA timezone instead of hardcoded 'UTC'.
PUT /subscription/email accepts optional hour (0-23), minute (0-59),
dayOfWeek (1-7, only for weekly), timezone (IANA). GET response
includes the new fields. Omitted fields fall back to defaults (17:00
UTC).
Closes mailship-200
2026-09-23 14:50:04 +00:00
|
|
|
return assertResult(await updateSubscription(concurrent, email, frequency, hour, minute, dayOfWeek, timezone))
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
2026-09-17 20:57:30 +00:00
|
|
|
export type ConfirmResult = {
|
|
|
|
|
sub: Subscription
|
|
|
|
|
alreadyConfirmed: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
export const confirmSubscription = instrument(
|
|
|
|
|
'database.confirmSubscription',
|
2026-09-17 20:57:30 +00:00
|
|
|
async (key: string): Promise<ConfirmResult | undefined> => {
|
|
|
|
|
// Try to update an unconfirmed, active row
|
|
|
|
|
const updated = parseSubscription(
|
2026-08-18 16:21:22 +00:00
|
|
|
await get(
|
|
|
|
|
`UPDATE subscriptions SET confirmed_at = unixepoch()
|
2026-09-03 17:42:50 +00:00
|
|
|
WHERE key = ? AND confirmed_at IS NULL AND unsubscribed_at IS NULL RETURNING *`,
|
|
|
|
|
[key]
|
|
|
|
|
)
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
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 }
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const unsubscribeSubscription = instrument(
|
|
|
|
|
'database.unsubscribeSubscription',
|
|
|
|
|
async (key: string) => {
|
|
|
|
|
return parseSubscription(
|
|
|
|
|
await get(
|
|
|
|
|
`UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`,
|
2026-09-03 17:42:50 +00:00
|
|
|
[key]
|
|
|
|
|
)
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const getSubscriptionById = instrument(
|
|
|
|
|
'database.getSubscriptionById',
|
|
|
|
|
async (id: string) => {
|
|
|
|
|
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id]))
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const getSubscriptionByKey = instrument(
|
|
|
|
|
'database.getSubscriptionByKey',
|
|
|
|
|
async (key: string) => {
|
|
|
|
|
return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key]))
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const getSubscriptionByPubkey = instrument(
|
|
|
|
|
'database.getSubscriptionByPubkey',
|
|
|
|
|
async (pubkey: string) => {
|
|
|
|
|
return parseSubscription(
|
2026-09-03 17:42:50 +00:00
|
|
|
await get(`SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`, [
|
|
|
|
|
pubkey,
|
|
|
|
|
])
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => {
|
|
|
|
|
const rows = await all(
|
|
|
|
|
`SELECT * FROM subscriptions
|
|
|
|
|
WHERE confirmed_at IS NOT NULL
|
2026-09-03 17:42:50 +00:00
|
|
|
AND unsubscribed_at IS NULL`
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return rows.map(parseSubscription) as Subscription[]
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-21 14:05:53 +00:00
|
|
|
// Every row ever created for a pubkey — both active and tombstoned. Exposed
|
|
|
|
|
// primarily for tests asserting re-subscribe never grows the table.
|
|
|
|
|
export const getAllSubscriptionsByPubkey = instrument(
|
|
|
|
|
'database.getAllSubscriptionsByPubkey',
|
|
|
|
|
async (pubkey: string) => {
|
|
|
|
|
const rows = await all<Row>(
|
|
|
|
|
`SELECT * FROM subscriptions WHERE pubkey = ? ORDER BY created_at`,
|
|
|
|
|
[pubkey]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return rows.map(parseSubscription) as Subscription[]
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
export const updateLastDigestAt = instrument(
|
|
|
|
|
'database.updateLastDigestAt',
|
|
|
|
|
async (id: string, timestamp: number) => {
|
|
|
|
|
await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id])
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 (?, ?, ?, ?, ?)`,
|
2026-09-03 17:42:50 +00:00
|
|
|
[eventId, subscriptionId, JSON.stringify(event), relay, now()]
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
// PRIMARY KEY collision = dedup, not an error
|
|
|
|
|
if (err.message?.includes('UNIQUE constraint')) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
throw err
|
|
|
|
|
}
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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`,
|
2026-09-03 17:42:50 +00:00
|
|
|
[subscriptionId, since]
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return rows.map((row) => ({
|
|
|
|
|
...row,
|
|
|
|
|
event: JSON.parse(row.event as any),
|
|
|
|
|
}))
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
export const deleteEventsForSubscription = instrument(
|
|
|
|
|
'database.deleteEventsForSubscription',
|
|
|
|
|
async (subscriptionId: string, since: number) => {
|
2026-09-03 17:42:50 +00:00
|
|
|
await run(`DELETE FROM events WHERE subscription_id = ? AND received_at > ?`, [
|
|
|
|
|
subscriptionId,
|
|
|
|
|
since,
|
|
|
|
|
])
|
|
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
)
|
|
|
|
|
|
2026-09-14 17:07:34 +00:00
|
|
|
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]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
export const purgeEventsOlderThan = instrument(
|
|
|
|
|
'database.purgeEventsOlderThan',
|
|
|
|
|
async (timestamp: number) => {
|
|
|
|
|
await run(`DELETE FROM events WHERE received_at < ?`, [timestamp])
|
2026-09-03 17:42:50 +00:00
|
|
|
}
|
|
|
|
|
)
|