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)
62 lines
No EOL
1.7 KiB
TypeScript
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}`
|
|
} |