mailship/src/alert.ts
mplorentz 34c5e28fd1
All checks were successful
CI / checks (pull_request) Successful in 41s
Rate-limit confirmation emails and restore daily/weekly digest cron
- 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

62 lines
No EOL
1.7 KiB
TypeScript

export type Subscription = {
id: string
key: string
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) => {
if (!sub.email?.includes('@')) {
return 'Please provide a valid email address'
}
if (!['daily', 'weekly'].includes(sub.frequency)) {
return 'Frequency must be "daily" or "weekly"'
}
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, dayOfWeek?: number) => {
if (frequency === 'daily') {
return `0 ${minute} ${hour} * * *`
}
// Weekly: default to Monday (1) when not specified
return `0 ${minute} ${hour} * * ${dayOfWeek ?? 1}`
}