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
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
change keeps the existing confirmation. The pubkey is extracted from the NIP-98
Authorization header — the body does not include a `pubkey` field.
or schedule change keeps the existing confirmation. The pubkey is extracted from
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)
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
Look up an existing subscription for the authenticated pubkey, so clients can
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)
Response: { key, callback, email, frequency, confirmed }
Response: { key, callback, email, frequency, hour, minute, dayOfWeek, timezone, confirmed }
```
### DELETE /subscription/:key

View file

@ -13,17 +13,36 @@ export type RegisterSubscriptionParams = {
pubkey: string
email: 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(
'actions.registerSubscription',
async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency)
async ({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone }: RegisterSubscriptionParams) => {
const sub = await db.insertSubscription(pubkey, email, frequency, hour, minute, dayOfWeek, timezone)
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
if (!sub.confirmed_at) {
// New or email-changed subscription — send a confirmation email.
await mailer.sendConfirm(sub)
// New or email-changed subscription — send a confirmation email, but never
// 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 {
// Already confirmed (e.g. frequency-only change) — reschedule the
// cron job so it uses the new cadence immediately.

View file

@ -4,10 +4,15 @@ export type Subscription = {
pubkey: string
email: string
frequency: string
hour: number
minute: number
day_of_week?: number
timezone: string
created_at: number
confirmed_at?: number
unsubscribed_at?: number
last_digest_at?: number
last_confirm_sent_at?: number
}
export const getSubscriptionError = (sub: Subscription) => {
@ -19,14 +24,39 @@ export const getSubscriptionError = (sub: Subscription) => {
return 'Frequency must be "daily" or "weekly"'
}
// Daily: fire at 17:00 UTC. Weekly: fire Monday at 17:00 UTC.
// Validation: just ensure frequency is valid, cron is generated internally.
if (sub.hour < 0 || sub.hour > 23 || !Number.isInteger(sub.hour)) {
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') {
return `0 ${minute} ${hour} * * *`
}
// Weekly on Monday
return `0 ${minute} ${hour} * * 1`
// Weekly: default to Monday (1) when not specified
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 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>
@ -58,10 +58,15 @@ export const migrate = () =>
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
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(
`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
`
)
// 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) {
@ -125,54 +152,122 @@ const parseSubscription = (row: any): Subscription | undefined => {
export const updateSubscription = instrument(
'database.updateSubscription',
async (existing: Subscription, email: string, frequency: string) => {
if (existing.email === email && existing.frequency === frequency) {
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 = ?, unsubscribed_at = NULL
`UPDATE subscriptions SET frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, unsubscribed_at = NULL
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(
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 *`,
[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(
'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)
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 {
return assertResult(
parseSubscription(
await get(
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
`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(),
]
)
@ -186,7 +281,7 @@ export const insertSubscription = instrument(
const concurrent = await getSubscriptionByPubkey(pubkey)
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[]
})
// 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(
'database.updateLastDigestAt',
async (id: string, timestamp: number) => {

View file

@ -186,6 +186,10 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
callback,
email: sub.email,
frequency: sub.frequency,
hour: sub.hour,
minute: sub.minute,
dayOfWeek: sub.day_of_week,
timezone: sub.timezone,
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
// the auth event, not from the request body.
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)
@ -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"' })
}
// 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 {
const result = await registerSubscription({ pubkey, email, frequency })
const result = await registerSubscription({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone })
res.json(result)
} catch (error: any) {
// 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 cron = getCronExpression(sub.frequency)
const cron = getCronExpression(sub.frequency, sub.hour, sub.minute, sub.day_of_week)
const run = async () => {
// Re-fetch the subscription to pick up the latest last_digest_at.
@ -64,7 +64,7 @@ const createJob = (sub: Subscription) => {
cronTime: cron,
onTick: run,
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)
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
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)
})