Compare commits

...

7 commits

Author SHA1 Message Date
9ad5c4eef4 Merge pull request 'Port cron-minutely fixes: reactivate tombstoned subs + confirm-email rate limit' (#29) from mailship-fc-merge-cron-fixes-df6 into main
Some checks failed
CI / checks (push) Successful in 39s
Docker / docker (push) Has been cancelled
Reviewed-on: #29
Reviewed-by: matt <matt@lorentz.is>
2026-09-23 17:56:11 +00:00
mplorentz
34c5e28fd1 Rate-limit confirmation emails and restore daily/weekly digest cron
All checks were successful
CI / checks (pull_request) Successful in 41s
- Add last_confirm_sent_at to subscriptions, with a migration for
  existing databases. registerSubscription now sends at most one
  confirmation email per 10 minutes per subscription, so a page-refresh
  or client retry storm can't spam an inbox. The cooldown resets when
  the address changes or a tombstoned row is reactivated (fresh key),
  so genuinely new addresses always get an email immediately.
- Restore getCronExpression to the documented daily (17:00 UTC) and
  weekly (Monday 17:00 UTC) schedules with the 6-field cron syntax the
  cron package expects, fixing the pre-existing reschedule tests that the
  'run every minute' testing hack had broken.
- Add confirm-email-cooldown.test.ts covering first send, refresh storms,
  frequency changes, email changes, reactivation, and the 10-minute constant.

(cherry picked from commit 051c1e88fb)
2026-09-23 12:14:28 -04:00
mplorentz
0ff8984a94 fix reactivating old rows
(cherry picked from commit 5675ebdf52)
2026-09-23 12:13:09 -04:00
mplorentz
4a74a5284b Re-activate tomstoned subscriptions when email is the same
(cherry picked from commit fd815282c2)
2026-09-23 12:12:51 -04:00
5007a79a3a Merge pull request 'Allow users to choose digest schedule (time of day, day of week, timezone)' (#28) from mailship-200-allow-users-to-choose-digest-schedule-ti-60c into main
Some checks are pending
Docker / docker (push) Waiting to run
CI / checks (push) Successful in 16m26s
Reviewed-on: #28
Reviewed-by: matt <matt@lorentz.is>
2026-09-23 15:57:56 +00:00
Agent
e34f1cd821 feat: allow users to choose digest schedule (hour, minute, dayOfWeek, timezone)
All checks were successful
CI / checks (pull_request) Successful in 44s
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 10:50:04 -04:00
27bb4a5342 Merge pull request 'digest email: replace reply/reaction counts with posting relay URL' (#27) from mailship-ihb-digest-email-replace-reply-reaction-coun-281 into main
Some checks are pending
Docker / docker (push) Waiting to run
CI / checks (push) Successful in 38s
Reviewed-on: #27
Reviewed-by: matt <matt@lorentz.is>
2026-09-22 14:58:00 +00:00
11 changed files with 757 additions and 30 deletions

View file

@ -51,15 +51,30 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
### PUT /subscription/email ### PUT /subscription/email
Idempotently register or update an email subscription. Re-sends the confirmation 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 email only when the subscription is new or the email address changed; a frequency
change keeps the existing confirmation. The pubkey is extracted from the NIP-98 or schedule change keeps the existing confirmation. The pubkey is extracted from
Authorization header — the body does not include a `pubkey` field. the NIP-98 Authorization header — the body does not include a `pubkey` field.
``` ```
Body: { email, frequency } Body: { email, frequency, hour?, minute?, dayOfWeek?, timezone? }
Auth: NIP-98 (Nostr <base64> Authorization header) Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback } Response: { key, callback }
``` ```
**Optional schedule fields (defaults: `hour=17`, `minute=0`, `timezone="UTC"`):**
| Field | Type | Constraints | Default |
|---|---|---|---|
| `hour` | integer | 0–23 | `17` |
| `minute` | integer | 0–59 | `0` |
| `dayOfWeek` | integer | 1=Mon … 7=Sun (0 also accepted as Sun); only valid when `frequency="weekly"` | `1` (Monday) for weekly, omitted for daily |
| `timezone` | string | IANA timezone name, e.g. `"America/New_York"` | `"UTC"` |
When omitted, each field falls back to its default. `dayOfWeek` is silently
ignored for daily frequency (the stored value is `NULL`).
**DOW convention:** `dayOfWeek` follows cron: 1=Monday, 2=Tuesday, …, 7=Sunday.
`0` is also accepted as Sunday (standard cron alias).
### GET /subscription/email ### GET /subscription/email
Look up an existing subscription for the authenticated pubkey, so clients can Look up an existing subscription for the authenticated pubkey, so clients can
avoid re-registering (and re-confirming) when settings haven't changed. avoid re-registering (and re-confirming) when settings haven't changed.
@ -67,7 +82,7 @@ Returns 404 if none exists.
``` ```
Auth: NIP-98 (Nostr <base64> Authorization header) Auth: NIP-98 (Nostr <base64> Authorization header)
Response: { key, callback, email, frequency, confirmed } Response: { key, callback, email, frequency, hour, minute, dayOfWeek, timezone, confirmed }
``` ```
### DELETE /subscription/:key ### DELETE /subscription/:key

View file

@ -13,17 +13,36 @@ export type RegisterSubscriptionParams = {
pubkey: string pubkey: string
email: string email: string
frequency: string frequency: string
hour?: number
minute?: number
dayOfWeek?: number
timezone?: string
} }
// Floor on how often a confirmation email can be sent for the same subscription.
// The client is expected to only PUT on an explicit save (GET-first on boot),
// but a retry loop or refresh storm shouldn't be able to spam an inbox either.
export const CONFIRM_EMAIL_COOLDOWN_SECONDS = 10 * 60
export const registerSubscription = instrument( export const registerSubscription = instrument(
'actions.registerSubscription', 'actions.registerSubscription',
async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => { async ({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency) const sub = await db.insertSubscription(pubkey, email, frequency, hour, minute, dayOfWeek, timezone)
const callback = `${process.env.BASE_URL}/notify/${sub.id}` const callback = `${process.env.BASE_URL}/notify/${sub.id}`
if (!sub.confirmed_at) { if (!sub.confirmed_at) {
// New or email-changed subscription — send a confirmation email. // New or email-changed subscription — send a confirmation email, but never
await mailer.sendConfirm(sub) // more than once per cooldown window. The email-change path resets
// last_confirm_sent_at, so a genuinely new address always gets an email
// right away; this only throttles repeated sends of the same address.
const lastSent = sub.last_confirm_sent_at
const cooldownElapsed =
!lastSent || Math.floor(Date.now() / 1000) - lastSent >= CONFIRM_EMAIL_COOLDOWN_SECONDS
if (cooldownElapsed) {
await mailer.sendConfirm(sub)
await db.markConfirmSent(sub.id)
}
} else { } else {
// Already confirmed (e.g. frequency-only change) — reschedule the // Already confirmed (e.g. frequency-only change) — reschedule the
// cron job so it uses the new cadence immediately. // cron job so it uses the new cadence immediately.

View file

@ -4,10 +4,15 @@ export type Subscription = {
pubkey: string pubkey: string
email: string email: string
frequency: string frequency: string
hour: number
minute: number
day_of_week?: number
timezone: string
created_at: number created_at: number
confirmed_at?: number confirmed_at?: number
unsubscribed_at?: number unsubscribed_at?: number
last_digest_at?: number last_digest_at?: number
last_confirm_sent_at?: number
} }
export const getSubscriptionError = (sub: Subscription) => { export const getSubscriptionError = (sub: Subscription) => {
@ -19,14 +24,39 @@ export const getSubscriptionError = (sub: Subscription) => {
return 'Frequency must be "daily" or "weekly"' return 'Frequency must be "daily" or "weekly"'
} }
// Daily: fire at 17:00 UTC. Weekly: fire Monday at 17:00 UTC. if (sub.hour < 0 || sub.hour > 23 || !Number.isInteger(sub.hour)) {
// Validation: just ensure frequency is valid, cron is generated internally. return 'Hour must be an integer between 0 and 23'
}
if (sub.minute < 0 || sub.minute > 59 || !Number.isInteger(sub.minute)) {
return 'Minute must be an integer between 0 and 59'
}
if (sub.frequency === 'weekly') {
if (sub.day_of_week === undefined || sub.day_of_week === null) {
return 'dayOfWeek is required for weekly frequency'
}
const dow = sub.day_of_week
if (dow < 0 || dow > 7 || !Number.isInteger(dow)) {
return 'dayOfWeek must be an integer between 0 and 7 (1=Mon, 7=Sun, 0=Sun)'
}
}
if (!sub.timezone || typeof sub.timezone !== 'string') {
return 'A valid IANA timezone is required'
}
try {
Intl.DateTimeFormat(undefined, { timeZone: sub.timezone })
} catch {
return `"${sub.timezone}" is not a valid IANA timezone`
}
} }
export const getCronExpression = (frequency: string, hour = 17, minute = 0) => { export const getCronExpression = (frequency: string, hour = 17, minute = 0, dayOfWeek?: number) => {
if (frequency === 'daily') { if (frequency === 'daily') {
return `0 ${minute} ${hour} * * *` return `0 ${minute} ${hour} * * *`
} }
// Weekly on Monday // Weekly: default to Monday (1) when not specified
return `0 ${minute} ${hour} * * 1` return `0 ${minute} ${hour} * * ${dayOfWeek ?? 1}`
} }

View file

@ -9,7 +9,7 @@ import type { Subscription } from './alert.js'
const DATA_DIR = process.env.DATA_DIR || '.' const DATA_DIR = process.env.DATA_DIR || '.'
const db = new sqlite3.Database(DATA_DIR + '/mailship.db') const db = new sqlite3.Database(DATA_DIR + '/mailship.db')
type Param = number | string | boolean type Param = number | string | boolean | null | undefined
type Row = Record<string, any> type Row = Record<string, any>
@ -58,10 +58,15 @@ export const migrate = () =>
pubkey TEXT NOT NULL, pubkey TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
frequency TEXT NOT NULL DEFAULT 'daily', 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, created_at INTEGER NOT NULL,
confirmed_at INTEGER, confirmed_at INTEGER,
unsubscribed_at INTEGER, unsubscribed_at INTEGER,
last_digest_at INTEGER last_digest_at INTEGER,
last_confirm_sent_at INTEGER
) )
` `
) )
@ -77,6 +82,14 @@ export const migrate = () =>
) )
` `
) )
// Add last_confirm_sent_at for existing databases created before the
// confirmation-email cooldown migration.
const columns = await all(
`SELECT name FROM pragma_table_info('subscriptions')`
)
if (!columns.some((c: any) => c.name === 'last_confirm_sent_at')) {
await run(`ALTER TABLE subscriptions ADD COLUMN last_confirm_sent_at INTEGER`)
}
await run( await run(
`CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)` `CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)`
) )
@ -108,6 +121,20 @@ export const migrate = () =>
WHERE unsubscribed_at IS NULL 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() resolve()
}) })
} catch (err) { } catch (err) {
@ -125,54 +152,122 @@ const parseSubscription = (row: any): Subscription | undefined => {
export const updateSubscription = instrument( export const updateSubscription = instrument(
'database.updateSubscription', 'database.updateSubscription',
async (existing: Subscription, email: string, frequency: string) => { async (existing: Subscription, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
if (existing.email === email && existing.frequency === frequency) { 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 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 // 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). // re-activated alongside this one (the unique index would reject that anyway).
if (existing.email === email) { if (existing.email === email) {
return parseSubscription( return parseSubscription(
await get( await get(
`UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL `UPDATE subscriptions SET frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, unsubscribed_at = NULL
WHERE id = ? RETURNING *`, WHERE id = ? RETURNING *`,
[frequency, existing.id] [frequency, hr, mn, dow, tz, existing.id]
) )
) )
} }
// Address changed: it's a new inbox to verify, so reset the confirm-email
// cooldown or the new address would inherit the old one's lockout.
return parseSubscription( return parseSubscription(
await get( await get(
`UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL `UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL, last_confirm_sent_at = NULL
WHERE id = ? RETURNING *`, WHERE id = ? RETURNING *`,
[email, frequency, existing.id] [email, frequency, hr, mn, dow, tz, existing.id]
) )
) )
} }
) )
// Record that a confirmation email was sent, for the send-cooldown.
export const markConfirmSent = instrument(
'database.markConfirmSent',
async (id: string) => {
await run(`UPDATE subscriptions SET last_confirm_sent_at = ? WHERE id = ?`, [now(), id])
}
)
const getMostRecentTombstonedSubscription = async (pubkey: string, email: string) =>
parseSubscription(
await get(
`SELECT * FROM subscriptions
WHERE pubkey = ? AND email = ? AND unsubscribed_at IS NOT NULL
ORDER BY rowid DESC LIMIT 1`,
[pubkey, email]
)
)
const reactivateSubscription = async (tombstoned: Subscription, frequency: string) =>
parseSubscription(
await get(
// A fresh key invalidates any previously emailed confirm link, so a new
// confirmation email must be allowed immediately (reset the cooldown).
`UPDATE subscriptions SET key = ?, frequency = ?, unsubscribed_at = NULL, last_confirm_sent_at = NULL
WHERE id = ? RETURNING *`,
[crypto.randomBytes(32).toString('hex'), frequency, tombstoned.id]
)
)
export const insertSubscription = instrument( export const insertSubscription = instrument(
'database.insertSubscription', 'database.insertSubscription',
async (pubkey: string, email: string, frequency: string) => { async (pubkey: string, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
const existing = await getSubscriptionByPubkey(pubkey) const existing = await getSubscriptionByPubkey(pubkey)
if (existing) { if (existing) {
return assertResult(await updateSubscription(existing, email, frequency)) return assertResult(await updateSubscription(existing, email, frequency, hour, minute, dayOfWeek, timezone))
}
// No active row. If this account previously subscribed to this email and
// was unsubscribed, reactivate the most recent such 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, email)
if (tombstoned) {
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
}
} }
try { try {
return assertResult( return assertResult(
parseSubscription( parseSubscription(
await get( await get(
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at) `INSERT INTO subscriptions (id, key, pubkey, email, frequency, hour, minute, day_of_week, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
[ [
crypto.randomUUID(), crypto.randomUUID(),
crypto.randomBytes(32).toString('hex'), crypto.randomBytes(32).toString('hex'),
pubkey, pubkey,
email, email,
frequency, frequency,
hour ?? 17,
minute ?? 0,
dayOfWeek ?? null,
timezone ?? 'UTC',
now(), now(),
] ]
) )
@ -186,7 +281,7 @@ export const insertSubscription = instrument(
const concurrent = await getSubscriptionByPubkey(pubkey) const concurrent = await getSubscriptionByPubkey(pubkey)
if (concurrent) { if (concurrent) {
return assertResult(await updateSubscription(concurrent, email, frequency)) return assertResult(await updateSubscription(concurrent, email, frequency, hour, minute, dayOfWeek, timezone))
} }
} }
@ -276,6 +371,20 @@ export const getActiveSubscriptions = instrument('database.getActiveSubscription
return rows.map(parseSubscription) as Subscription[] return rows.map(parseSubscription) as Subscription[]
}) })
// 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[]
}
)
export const updateLastDigestAt = instrument( export const updateLastDigestAt = instrument(
'database.updateLastDigestAt', 'database.updateLastDigestAt',
async (id: string, timestamp: number) => { async (id: string, timestamp: number) => {

View file

@ -186,6 +186,10 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
callback, callback,
email: sub.email, email: sub.email,
frequency: sub.frequency, frequency: sub.frequency,
hour: sub.hour,
minute: sub.minute,
dayOfWeek: sub.day_of_week,
timezone: sub.timezone,
confirmed: Boolean(sub.confirmed_at), confirmed: Boolean(sub.confirmed_at),
}) })
}) })
@ -194,7 +198,7 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
// auth proving the caller controls the pubkey — the pubkey is extracted from // auth proving the caller controls the pubkey — the pubkey is extracted from
// the auth event, not from the request body. // the auth event, not from the request body.
addRoute('put', '/subscription/email', async (req: Request, res: Response) => { addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
const { email, frequency } = req.body const { email, frequency, hour, minute, dayOfWeek, timezone } = req.body
const pubkey = await verifyNip98Auth(req) const pubkey = await verifyNip98Auth(req)
@ -210,8 +214,38 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' }) return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
} }
// Validate optional schedule fields
if (hour !== undefined) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
return res.status(400).json({ error: 'Hour must be an integer between 0 and 23' })
}
}
if (minute !== undefined) {
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
return res.status(400).json({ error: 'Minute must be an integer between 0 and 59' })
}
}
if (dayOfWeek !== undefined) {
if (frequency !== 'weekly') {
return res.status(400).json({ error: 'dayOfWeek is only valid for weekly frequency' })
}
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 7) {
return res.status(400).json({ error: 'dayOfWeek must be an integer between 0 and 7 (1=Mon, 7=Sun, 0=Sun)' })
}
}
if (timezone !== undefined) {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone })
} catch {
return res.status(400).json({ error: `"${timezone}" is not a valid IANA timezone` })
}
}
try { try {
const result = await registerSubscription({ pubkey, email, frequency }) const result = await registerSubscription({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone })
res.json(result) res.json(result)
} catch (error: any) { } catch (error: any) {
// The subscription was still created, but sending the confirmation email // The subscription was still created, but sending the confirmation email

View file

@ -49,7 +49,7 @@ export const runJob = async (sub: Subscription) => {
} }
const createJob = (sub: Subscription) => { const createJob = (sub: Subscription) => {
const cron = getCronExpression(sub.frequency) const cron = getCronExpression(sub.frequency, sub.hour, sub.minute, sub.day_of_week)
const run = async () => { const run = async () => {
// Re-fetch the subscription to pick up the latest last_digest_at. // Re-fetch the subscription to pick up the latest last_digest_at.
@ -64,7 +64,7 @@ const createJob = (sub: Subscription) => {
cronTime: cron, cronTime: cron,
onTick: run, onTick: run,
start: true, start: true,
timeZone: 'UTC', timeZone: sub.timezone ?? 'UTC',
}) })
} }

View file

@ -0,0 +1,109 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription } from '../src/actions.js'
import * as mailer from '../src/mailer.js'
// Regression guards for the row-reactivation fix (src/database.ts):
// * same-email re-subscribe → reactivates the SAME row and must NOT email a
// stale confirmation code to an unrelated address
// * new-email re-subscribe → MUST create a fresh row (fresh key) so the
// confirmation email carries a code bound to the new address
//
// We mock the mailer so these run hermetically (the unit env's SMTP is a dummy).
vi.mock('../src/mailer.js', async (importOriginal) => {
const actual: any = await importOriginal()
return { ...actual, sendConfirm: vi.fn(async () => {}) }
})
const pubkey = 'new-email-' + Date.now() + '-' + Math.random().toString(36).slice(2)
const oldEmail = `${pubkey}-old@example.com`
const newEmail = `${pubkey}-new@example.com`
let subscribedIds: string[] = []
const subscribeIdsFor = async () => {
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
subscribedIds = rows.map((r: any) => r.id)
return rows
}
describe('Re-subscribe with a NEW email dispatches the correct confirmation code', () => {
beforeAll(async () => {
await db.migrate()
vi.mocked(mailer.sendConfirm).mockClear()
})
afterAll(async () => {
const key = (await db.getAllSubscriptionsByPubkey(pubkey))[0]?.key
if (key) await db.unsubscribeSubscription(key)
})
it('registers + confirms the original email', async () => {
const result = await registerSubscription({ pubkey, email: oldEmail, frequency: 'daily' })
expect(result.key).toBeTruthy()
await db.confirmSubscription(result.key)
expect(vi.mocked(mailer.sendConfirm)).toHaveBeenCalledTimes(1)
})
it('unsubscribes (DELETE flow)', async () => {
const active = await db.getSubscriptionByPubkey(pubkey)
await db.unsubscribeSubscription(active!.key)
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('re-registering with the NEW email creates a fresh row, not a reactivation', async () => {
vi.mocked(mailer.sendConfirm).mockClear()
const result = await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' })
// A NEW confirmation email was sent — to the NEW address, with the key
// returned to the caller (which the caller uses to confirm).
const sent = vi.mocked(mailer.sendConfirm).mock.calls[0][0]
expect(sent.email).toBe(newEmail)
expect(sent.key).toBe(result.key)
// And that key actually confirms the new-email row (not the old one).
const confirmed = await db.confirmSubscription(result.key)
expect(confirmed!.sub.email).toBe(newEmail)
expect(confirmed!.alreadyConfirmed).toBe(false)
})
it('leaves the old row and new row as distinct rows', async () => {
const rows = await subscribeIdsFor()
expect(rows).toHaveLength(2)
expect(new Set(subscribedIds).size).toBe(2)
})
})
describe('Reactivating the SAME email never emails an unrelated address', () => {
const k = 'same-email-' + Date.now() + '-' + Math.random().toString(36).slice(2)
const email = `${k}@example.com`
beforeAll(async () => {
vi.mocked(mailer.sendConfirm).mockClear()
})
afterAll(async () => {
const key = (await db.getAllSubscriptionsByPubkey(k))[0]?.key
if (key) await db.unsubscribeSubscription(key)
})
it('register + confirm + unsubscribe, then re-register the same email', async () => {
const r1 = await registerSubscription({ pubkey: k, email, frequency: 'daily' })
await db.confirmSubscription(r1.key)
const row1 = (await db.getAllSubscriptionsByPubkey(k))[0]
await db.unsubscribeSubscription(r1.key)
vi.mocked(mailer.sendConfirm).mockClear()
const r2 = await registerSubscription({ pubkey: k, email, frequency: 'daily' })
// Same row, still confirmed, but a FRESH key is issued — no new
// confirmation email, and old tokens for this row are invalidated.
const rows = await db.getAllSubscriptionsByPubkey(k)
expect(rows).toHaveLength(1)
expect(r2.key).not.toBe(r1.key)
expect(rows[0].id).toBe(row1.id)
expect(rows[0].confirmed_at).toBeTruthy()
expect(vi.mocked(mailer.sendConfirm)).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,101 @@
import { describe, it, expect, beforeAll, vi, afterEach, beforeEach } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription, CONFIRM_EMAIL_COOLDOWN_SECONDS } from '../src/actions.js'
import * as mailer from '../src/mailer.js'
// Guard: at most one confirmation email per subscription per cooldown window,
// no matter how many PUTs arrive (e.g. a page-refresh storm while unconfirmed).
vi.mock('../src/mailer.js', async (importOriginal) => {
const actual: any = await importOriginal()
return { ...actual, sendConfirm: vi.fn(async () => {}) }
})
const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
const pubkey = unique('cooldown')
const email = `${unique('cooldown')}@example.com`
const confirmCalls = () => vi.mocked(mailer.sendConfirm).mock.calls.length
describe('Confirmation email cooldown', () => {
beforeAll(async () => {
await db.migrate()
})
beforeEach(() => {
vi.mocked(mailer.sendConfirm).mockClear()
})
it('sends a confirmation email on the first register', async () => {
const res = await registerSubscription({ pubkey, email, frequency: 'daily' })
expect(res.key).toBeTruthy()
expect(confirmCalls()).toBe(1)
})
it('does NOT re-send a confirmation email on re-register (refresh/retry storm)', async () => {
// Simulate several PUTs arriving in quick succession (e.g. page reloads).
for (let i = 0; i < 5; i++) {
await registerSubscription({ pubkey, email, frequency: 'daily' })
}
expect(confirmCalls()).toBe(0)
})
it('a new frequency (setting change) does not re-send', async () => {
await registerSubscription({ pubkey, email, frequency: 'weekly' })
expect(confirmCalls()).toBe(0)
})
it('changing the email address sends immediately (cooldown reset)', async () => {
const newEmail = `${unique('cooldown')}@example.com`
await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' })
expect(confirmCalls()).toBe(1)
})
it('a fresh unconfirmed subscription is still throttled after confirm-sent marker', async () => {
// New pubkey: first PUT sends one email; immediate re-PUT is suppressed.
const pk2 = unique('cooldown2')
const em2 = `${unique('cooldown2')}@example.com`
await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' })
await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' })
expect(confirmCalls()).toBe(1)
})
it('cooldown window constant is 10 minutes', () => {
expect(CONFIRM_EMAIL_COOLDOWN_SECONDS).toBe(600)
})
})
describe('Cooldown reset on subscription reactivation (same email, off/on cycle)', () => {
const k = unique('cooldown-reactivate')
const e = `${unique('cooldown-reactivate')}@example.com`
beforeAll(async () => {
await db.migrate()
})
beforeEach(() => {
vi.mocked(mailer.sendConfirm).mockClear()
})
it('register, confirm, unsubscribe, re-register same email → fresh key emails immediately', async () => {
const r1 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' })
const active = await db.getSubscriptionByPubkey(k)
// Confirm first so reactivation is a "keep confirmed" path — but that also
// means no confirm email on re-register (already confirmed). Verify the row
// is reactivated with a fresh key, not a duplicate.
const confirmed = await db.confirmSubscription(active!.key)
expect(confirmed).toBeTruthy()
await db.unsubscribeSubscription(active!.key)
vi.mocked(mailer.sendConfirm).mockClear()
const r2 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' })
expect(r2.key).not.toBe(r1.key) // fresh key issued
expect(confirmCalls()).toBe(0) // still confirmed → no new email
const rows = await db.getAllSubscriptionsByPubkey(k)
expect(rows).toHaveLength(1)
})
})

View file

@ -266,6 +266,31 @@ echo "14. Delete without auth returns 401"
DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE) DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE)
check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required" check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required"
# Test 15: Re-subscribe after delete reactivates the same row (no duplicate)
# Regression: DELETE tombstones the row; re-subscribing with the SAME pubkey +
# email must reactivate it, not insert a second row (off/on cycle previously
# accumulated one row per cycle).
echo ""
echo "15. Re-subscribe after delete (de-dupe regression)"
OLD_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY' AND unsubscribed_at IS NOT NULL ORDER BY created_at DESC LIMIT 1;" 2>/dev/null)
AUTH_RE=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}')
RE_REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \
-H "Authorization: $AUTH_RE" \
-d '{"email":"test@example.com","frequency":"daily"}')
NEW_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY' AND unsubscribed_at IS NULL LIMIT 1;" 2>/dev/null)
if [ -n "$OLD_ID" ] && [ "$NEW_ID" = "$OLD_ID" ]; then
pass "Re-subscribe reactivates the same row"
else
fail "Re-subscribe created a duplicate row (old=$OLD_ID, new=$NEW_ID)"
fi
TOTAL_ROWS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null)
if [ "$TOTAL_ROWS" = "1" ]; then
pass "Exactly one row for this pubkey"
else
fail "Expected 1 row for this pubkey, got $TOTAL_ROWS"
fi
# Summary # Summary
echo "" echo ""
echo "=========================================" echo "========================================="

View file

@ -0,0 +1,188 @@
import { describe, it, expect, beforeAll } from 'vitest'
import * as db from '../src/database.js'
// Regression test for the "two rows created when turning email alerts back on" bug.
//
// Decoded production sequence (Sep 18 2026):
// 13:42:05 register → row 1 (unconfirmed), confirm email #1
// 13:44:35 DELETE → row 1 tombstoned (unsubscribed_at set)
// 13:44:36 register → OLD BUG: a brand-new row 2 was inserted, confirm email #2
// 14:02:32 confirm → row 2 finally confirmed
//
// Fix: when the same pubkey re-subscribes to the same email after being
// unsubscribed, reactivate the tombstoned row instead of inserting a new one,
// preserving the existing confirmed state and key.
const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`
describe('Re-subscribe after DELETE reactivates the same subscription (off/on cycle)', () => {
const pubkey = unique('resub')
const email = `${unique('resub')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('creates an unconfirmed subscription', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
expect(sub).toBeTruthy()
expect(sub.confirmed_at).toBeFalsy()
expect(sub.unsubscribed_at).toBeFalsy()
})
it('confirms it (user clicks the confirm link)', async () => {
const active = await db.getSubscriptionByPubkey(pubkey)
const result = await db.confirmSubscription(active!.key)
expect(result).toBeTruthy()
expect(result!.alreadyConfirmed).toBe(false)
expect(result!.sub.confirmed_at).toBeTruthy()
})
it('unsubscribes it (the flotilla DELETE path)', async () => {
const active = await db.getSubscriptionByPubkey(pubkey)
const gone = await db.unsubscribeSubscription(active!.key)
expect(gone).toBeTruthy()
expect(gone!.unsubscribed_at).toBeTruthy()
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('re-registers the SAME email — must reactivate row, NOT create a second row', async () => {
const resigned = await db.insertSubscription(pubkey, email, 'daily')
// Restores the very same row
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active).toBeTruthy()
expect(active!.key).toBe(resigned.key)
expect(active!.unsubscribed_at).toBeFalsy()
// Confirmed state is preserved — no second confirmation email needed
expect(active!.confirmed_at).toBeTruthy()
// Exhaustive: there exists exactly one row total for this pubkey
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe(active!.id)
})
})
describe('Re-subscribe after DELETE before ever confirming', () => {
const pubkey = unique('resub-unconfirmed')
const email = `${unique('resub-unconfirmed')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('registers then unsubscribes without confirming', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
await db.unsubscribeSubscription(sub.key)
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('re-registers the same email — reactivates same row, still unconfirmed', async () => {
const resigned = await db.insertSubscription(pubkey, email, 'daily')
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active!.key).toBe(resigned.key)
expect(active!.id).toBe(resigned.id)
expect(active!.unsubscribed_at).toBeFalsy()
// Was never confirmed, and re-subscribing does not skip confirmation
expect(active!.confirmed_at).toBeFalsy()
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(1)
})
})
describe('Re-subscribe with a DIFFERENT email after DELETE', () => {
const pubkey = unique('resub-2')
const email = `${unique('resub-2')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('registers, confirms, and unsubscribes', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
await db.confirmSubscription(sub.key)
await db.unsubscribeSubscription(sub.key)
expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy()
})
it('a new email creates a new row, leaving the old one tombstoned', async () => {
const newEmail = `${unique('resub-2-new')}@example.com`
const resigned = await db.insertSubscription(pubkey, newEmail, 'daily')
expect(resigned.email).toBe(newEmail)
expect(resigned.confirmed_at).toBeFalsy() // new address must re-confirm
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(2)
expect(rows[0].email).toBe(email) // old, tombstoned
expect(rows[0].unsubscribed_at).toBeTruthy()
expect(rows[1].email).toBe(newEmail) // new, active
expect(rows[1].unsubscribed_at).toBeFalsy()
})
})
describe('Re-subscribe with a changed frequency reactivates and updates cadence', () => {
const pubkey = unique('resub-freq')
const email = `${unique('resub-freq')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('registers confirm-free, unsubscribes', async () => {
const sub = await db.insertSubscription(pubkey, email, 'daily')
await db.unsubscribeSubscription(sub.key)
})
it('re-registers with weekly — reactivates the same row at the new cadence', async () => {
const resigned = await db.insertSubscription(pubkey, email, 'weekly')
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active!.key).toBe(resigned.key)
expect(active!.frequency).toBe('weekly')
expect(active!.unsubscribed_at).toBeFalsy()
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(1)
})
})
describe('Re-subscribe with mixed emails for one pubkey picks the right row', () => {
const pubkey = unique('resub-mixed')
const emailA = `${unique('resub-mixed-a')}@example.com`
const emailB = `${unique('resub-mixed-b')}@example.com`
beforeAll(async () => {
await db.migrate()
})
it('creates and tombstones two different emails, then re-subscribes email B', async () => {
// Email A cycle
const subA = await db.insertSubscription(pubkey, emailA, 'daily')
await db.unsubscribeSubscription(subA.key)
// Email B cycle
const subB = await db.insertSubscription(pubkey, emailB, 'daily')
const bId = subB!.id
await db.unsubscribeSubscription(subB.key)
// Re-subscribe with email B — must reactivate B's own row, not A's,
// and must not resurrect the wrong email.
const resigned = await db.insertSubscription(pubkey, emailB, 'daily')
const active = await db.getSubscriptionByPubkey(pubkey)
expect(active!.id).toBe(bId)
expect(active!.id).not.toBe(subA!.id)
expect(active!.email).toBe(emailB)
expect(active!.unsubscribed_at).toBeFalsy()
const rows = await db.getAllSubscriptionsByPubkey(pubkey)
expect(rows).toHaveLength(2)
expect(rows.filter(r => r.email === emailA)).toHaveLength(1)
expect(rows.filter(r => r.email === emailB)).toHaveLength(1)
})
})

View file

@ -0,0 +1,97 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as db from '../src/database.js'
import { registerSubscription } from '../src/actions.js'
import { getCronExpression } from '../src/alert.js'
import { getJobCronSource, removeJob } from '../src/worker/email.js'
import { registerSubscription as regSub } from '../src/worker/index.js'
const pubkey = 'schedule-test-' + Date.now()
const email = 'schedule-test-' + Date.now() + '@example.com'
let sub: any = null
describe('Schedule fields', () => {
beforeAll(async () => {
await db.migrate()
})
it('inserts subscription with custom hour/minute/timezone', async () => {
const s = await db.insertSubscription(pubkey, email, 'daily', 7, 30, undefined, 'America/New_York')
expect(s).toBeTruthy()
expect(s!.hour).toBe(7)
expect(s!.minute).toBe(30)
expect(s!.timezone).toBe('America/New_York')
expect(s!.day_of_week).toBeNull()
sub = s
})
it('inserts subscription with custom weekly dayOfWeek', async () => {
const pk2 = 'schedule-test-weekly-' + Date.now()
const em2 = pk2 + '@example.com'
const s = await db.insertSubscription(pk2, em2, 'weekly', 9, 0, 6, 'UTC')
expect(s).toBeTruthy()
expect(s!.hour).toBe(9)
expect(s!.minute).toBe(0)
expect(s!.day_of_week).toBe(6)
expect(s!.timezone).toBe('UTC')
})
it('inserts subscription with defaults when schedule omitted', async () => {
const pk3 = 'schedule-test-defaults-' + Date.now()
const em3 = pk3 + '@example.com'
const s = await db.insertSubscription(pk3, em3, 'daily')
expect(s).toBeTruthy()
expect(s!.hour).toBe(17)
expect(s!.minute).toBe(0)
expect(s!.timezone).toBe('UTC')
expect(s!.day_of_week).toBeNull()
})
it('getCronExpression returns daily with custom hour/minute', () => {
expect(getCronExpression('daily', 7, 30)).toBe('0 30 7 * * *')
})
it('getCronExpression returns weekly with custom dayOfWeek', () => {
expect(getCronExpression('weekly', 9, 0, 6)).toBe('0 0 9 * * 6')
})
it('getCronExpression defaults to Monday for weekly', () => {
expect(getCronExpression('weekly', 17, 0)).toBe('0 0 17 * * 1')
})
it('confirms and registers job with custom schedule', async () => {
// Confirm the daily subscription created above
const confirmed = await db.confirmSubscription(sub.key)
expect(confirmed).toBeTruthy()
sub = confirmed!.sub
regSub(sub)
const dailySource = getJobCronSource(sub.id)
// Expect 0 30 7 * * * (from custom hour=7, minute=30)
expect(dailySource).toBe('0 30 7 * * *')
})
it('updateSubscription preserves schedule fields through frequency change', async () => {
const updated = await db.updateSubscription(sub, email, 'weekly', undefined, undefined, 2, undefined)
expect(updated).toBeTruthy()
expect(updated!.frequency).toBe('weekly')
// hour/minute should keep the previously set values (7/30) since we passed undefined
expect(updated!.hour).toBe(7)
expect(updated!.minute).toBe(30)
expect(updated!.day_of_week).toBe(2)
expect(updated!.timezone).toBe('America/New_York')
})
it('registerSubscription preserves schedule fields', async () => {
await registerSubscription({ pubkey, email, frequency: 'daily', hour: 10, minute: 15, timezone: 'Europe/London' })
const reloaded = await db.getSubscriptionByPubkey(pubkey)
expect(reloaded).toBeTruthy()
expect(reloaded!.hour).toBe(10)
expect(reloaded!.minute).toBe(15)
expect(reloaded!.timezone).toBe('Europe/London')
})
})
afterAll(async () => {
const updated = await db.getSubscriptionByPubkey(pubkey)
if (updated) removeJob(updated)
})