Third-pass audit finding. renderShell auto-linkifies any https?:// in body lines so the manageUrl line becomes a clickable link in the HTML email. The same regex would happily turn a malicious URL injected into a user-controlled field (member display_name, equipment / class / form title, submitter label, space name) into a clickable link. In Phase 2 dues emails this was self-only: the recipient is the same member whose display_name appears, so self-phishing is silly. Phase 4 introduces the first email type where one user's display_name lands in a different user's inbox: form_submission_admin embeds the submitter's display_name (or, when the form was submitted anonymously and an email match attributed it to a member, that member's display_name) in an email read by every forms.manage holder. An authenticated member can set display_name to "Click https://evil.com for free stuff", submit a form, and the admin's inbox now has a clickable phishing URL. body.email is Zod-validated against email format so the typed-email path is closed, but display_name (z.string().min(1).max(100)) is a free string. No format restriction will catch a determined attacker. Mitigation: stripUrls(s) replaces https?://\S+ with [link]. Applied to every user-controlled input across all four renderers (dues, booking, class, form) including spaceName, memberName / recipientName, equipment / class / form title, location, and submitterLabel. manageUrl, which buildManageUrl constructs from NEXT_PUBLIC_APP_URL or a Stripe- authenticated webhook host, stays untouched and remains the only linkified URL in the rendered email. 5 new tests cover the new attack vector (form_submission_admin) and defense-in-depth across booking, class, and dues renderers. Suite 572, build clean.
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
// Pure notification logic: dedupe-key construction, dues email rendering,
|
|
// and the dispatcher's terminal-attempt rule. No I/O, no Supabase, no Stripe
|
|
// imports so this stays unit-testable in isolation. The Stripe webhook builds
|
|
// rows with these helpers; the dispatcher cron sends them.
|
|
|
|
export type DuesNotificationType =
|
|
| 'dues_renewed'
|
|
| 'dues_payment_failed'
|
|
| 'dues_lapsed'
|
|
|
|
// After this many failed send attempts the dispatcher gives up and marks the
|
|
// row 'failed' so it stops being retried every minute forever.
|
|
export const MAX_NOTIFICATION_ATTEMPTS = 5
|
|
|
|
// A row is terminal once it has burned through its attempt budget. The
|
|
// dispatcher uses this to decide 'failed' vs leaving it 'pending' for retry.
|
|
export function isTerminalAttempt(
|
|
attempts: number,
|
|
max: number = MAX_NOTIFICATION_ATTEMPTS,
|
|
): boolean {
|
|
return attempts >= max
|
|
}
|
|
|
|
// Deterministic dedupe key. The (space_id, dedupe_key) unique index makes the
|
|
// webhook's enqueue idempotent: the same Stripe event replayed (different
|
|
// event id, same invoice) collapses to one notification. Empty/nullish parts
|
|
// are dropped so callers can pass optional ids without changing the shape.
|
|
export function notificationDedupeKey(
|
|
parts: Array<string | number | null | undefined>,
|
|
): string {
|
|
return parts
|
|
.filter(p => p !== null && p !== undefined && String(p).length > 0)
|
|
.map(String)
|
|
.join(':')
|
|
}
|
|
|
|
// Renewal + payment-failed are keyed by the Stripe invoice (one mail per
|
|
// invoice outcome). Lapse is keyed by member + the period it lapsed for, so a
|
|
// member can lapse again next cycle but not be re-mailed for the same one.
|
|
// periodEnd can be absent (canceled/deleted subscriptions carry no item
|
|
// current_period_end); without a fallback every lapse would collapse to
|
|
// `dues_lapsed:<member>` and suppress every future lapse notice. Fall back to
|
|
// the subscription id (a new comeback subscription has a new id, so distinct
|
|
// lapses stay distinct).
|
|
export function duesDedupeKey(
|
|
type: DuesNotificationType,
|
|
ref: {
|
|
invoiceId?: string | null
|
|
memberId?: string | null
|
|
periodEnd?: string | null
|
|
subscriptionId?: string | null
|
|
},
|
|
): string {
|
|
if (type === 'dues_lapsed') {
|
|
return notificationDedupeKey([
|
|
'dues_lapsed',
|
|
ref.memberId,
|
|
ref.periodEnd || ref.subscriptionId,
|
|
])
|
|
}
|
|
return notificationDedupeKey([type, ref.invoiceId])
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
// renderShell auto-linkifies any https?:// in the body so the manageUrl line
|
|
// becomes a clickable link. That same regex turns ANY URL embedded in a
|
|
// user-controlled input (member display_name, equipment/class title, form
|
|
// title, submitter label, space name) into a clickable link too, opening a
|
|
// phishing vector wherever those fields land in an email to a different user
|
|
// (notably form_submission_admin, which surfaces a submitter's display_name
|
|
// to an admin). Scrub URL patterns from every user-controlled input before
|
|
// rendering. Only manageUrl, which is server-built from NEXT_PUBLIC_APP_URL,
|
|
// is allowed to reach the linkify regex.
|
|
function stripUrls(s: string | null | undefined): string {
|
|
if (!s) return ''
|
|
return s.replace(/https?:\/\/\S+/gi, '[link]')
|
|
}
|
|
|
|
const CURRENCY_SYMBOL: Record<string, string> = { USD: '$', CAD: '$', EUR: '€', GBP: '£' }
|
|
|
|
// Stripe zero-decimal currencies: the smallest unit IS the major unit, so
|
|
// amounts are NOT scaled by 100 and have no fractional display. Source:
|
|
// Stripe "zero-decimal currencies" list.
|
|
export const ZERO_DECIMAL_CURRENCIES = new Set([
|
|
'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF',
|
|
'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF',
|
|
])
|
|
|
|
export function isZeroDecimalCurrency(currency: string | null | undefined): boolean {
|
|
return ZERO_DECIMAL_CURRENCIES.has((currency || 'USD').toUpperCase())
|
|
}
|
|
|
|
// amount is in major units, matching how the payments ledger stores it.
|
|
// Zero-decimal currencies render with no fractional part.
|
|
export function formatMoney(
|
|
amount: number | null | undefined,
|
|
currency: string | null | undefined,
|
|
): string {
|
|
if (amount === null || amount === undefined || Number.isNaN(amount)) return ''
|
|
const code = (currency || 'USD').toUpperCase()
|
|
const sym = CURRENCY_SYMBOL[code]
|
|
const n = isZeroDecimalCurrency(code) ? String(Math.round(amount)) : amount.toFixed(2)
|
|
return sym ? `${sym}${n} ${code}` : `${n} ${code}`
|
|
}
|
|
|
|
function formatDate(iso: string | null | undefined): string {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
if (Number.isNaN(d.getTime())) return ''
|
|
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
}
|
|
|
|
function formatDateTime(iso: string | null | undefined): string {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
if (Number.isNaN(d.getTime())) return ''
|
|
return d.toLocaleString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
// "Friday, May 22, 2026, 3:00 PM to 5:00 PM" if both sides parse; else best
|
|
// effort with whichever side is available. Used by booking + class renders.
|
|
function formatRange(
|
|
startIso: string | null | undefined,
|
|
endIso: string | null | undefined,
|
|
): string {
|
|
const start = formatDateTime(startIso)
|
|
const end = formatDateTime(endIso)
|
|
if (start && end) return `${start} to ${end}`
|
|
return start || end || ''
|
|
}
|
|
|
|
function renderShell(lines: string[]): { html: string; text: string } {
|
|
const text = lines.join('\n').trim() + '\n'
|
|
const html =
|
|
`<div style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px;line-height:1.6;color:#111">` +
|
|
lines
|
|
.map(l => {
|
|
if (l === '') return '<br/>'
|
|
const safe = escapeHtml(l)
|
|
const linked = safe.replace(
|
|
/(https?:\/\/[^\s]+)/g,
|
|
'<a href="$1" style="color:#2563eb">$1</a>',
|
|
)
|
|
return `<p style="margin:0 0 8px">${linked}</p>`
|
|
})
|
|
.join('') +
|
|
`</div>`
|
|
return { html, text }
|
|
}
|
|
|
|
export type DuesEmailInput = {
|
|
type: DuesNotificationType
|
|
spaceName: string
|
|
memberName?: string | null
|
|
amount?: number | null
|
|
currency?: string | null
|
|
periodEnd?: string | null
|
|
manageUrl: string
|
|
}
|
|
|
|
export type RenderedEmail = { subject: string; html: string; text: string }
|
|
|
|
// Brand-neutral copy: this is a generic multi-space platform, the space name
|
|
// is injected, never hard-coded. Returns both an HTML and a plain-text body
|
|
// (Resend will also auto-generate text, but we send an explicit one).
|
|
export function renderDuesEmail(input: DuesEmailInput): RenderedEmail {
|
|
const space = stripUrls(input.spaceName) || 'your hackerspace'
|
|
const name = stripUrls(input.memberName).trim() || null
|
|
const greeting = name ? `Hi ${name},` : 'Hi,'
|
|
const money = formatMoney(input.amount, input.currency)
|
|
const renews = formatDate(input.periodEnd)
|
|
|
|
let subject: string
|
|
const lines: string[] = [greeting, '']
|
|
|
|
if (input.type === 'dues_renewed') {
|
|
subject = `Your ${space} dues renewed`
|
|
lines.push(
|
|
`Your membership dues at ${space} were charged${money ? ` (${money})` : ''} and your membership is current.`,
|
|
)
|
|
if (renews) lines.push(`Your next renewal is ${renews}.`)
|
|
lines.push('', `Manage your billing: ${input.manageUrl}`)
|
|
} else if (input.type === 'dues_payment_failed') {
|
|
subject = `Action needed: ${space} dues payment failed`
|
|
lines.push(
|
|
`We could not process your latest membership dues payment at ${space}${money ? ` (${money})` : ''}.`,
|
|
'Please update your payment method so your membership stays active. There is a short grace period before your membership is marked late.',
|
|
'',
|
|
`Update your payment method: ${input.manageUrl}`,
|
|
)
|
|
} else {
|
|
subject = `Your ${space} membership is now marked late`
|
|
lines.push(
|
|
`Your membership dues at ${space} were not paid within the grace period, so your membership is now marked late.`,
|
|
'You can restore it at any time by paying your dues.',
|
|
'',
|
|
`Pay your dues: ${input.manageUrl}`,
|
|
)
|
|
}
|
|
|
|
const { html, text } = renderShell(lines)
|
|
return { subject, html, text }
|
|
}
|
|
|
|
// ─── Class signup ────────────────────────────────────────────────────────────
|
|
|
|
export type ClassNotificationType =
|
|
| 'class_signup_registered'
|
|
| 'class_signup_waitlisted'
|
|
| 'class_signup_promoted'
|
|
| 'class_session_cancelled'
|
|
|
|
// Signup-keyed events use the signup_id (which is stable across a
|
|
// waitlist→registered promotion). class_session_cancelled fans out one row
|
|
// per (session, member) so each affected member's email is independent.
|
|
export function classDedupeKey(
|
|
type: ClassNotificationType,
|
|
ids: { signupId?: string | null; sessionId?: string | null; memberId?: string | null },
|
|
): string {
|
|
if (type === 'class_session_cancelled') {
|
|
return notificationDedupeKey([type, ids.sessionId, ids.memberId])
|
|
}
|
|
return notificationDedupeKey([type, ids.signupId])
|
|
}
|
|
|
|
export type ClassEmailInput = {
|
|
type: ClassNotificationType
|
|
spaceName: string
|
|
memberName?: string | null
|
|
className: string
|
|
location?: string | null
|
|
startsAt: string | null
|
|
endsAt?: string | null
|
|
manageUrl: string
|
|
}
|
|
|
|
export function renderClassEmail(input: ClassEmailInput): RenderedEmail {
|
|
const space = stripUrls(input.spaceName) || 'your hackerspace'
|
|
const name = stripUrls(input.memberName).trim() || null
|
|
const greeting = name ? `Hi ${name},` : 'Hi,'
|
|
const cls = stripUrls(input.className) || 'the class'
|
|
const range = formatRange(input.startsAt, input.endsAt)
|
|
const location = stripUrls(input.location).trim()
|
|
|
|
let subject: string
|
|
const lines: string[] = [greeting, '']
|
|
|
|
if (input.type === 'class_signup_registered') {
|
|
subject = `Signed up for ${cls} at ${space}`
|
|
lines.push(`You are registered for ${cls} at ${space}${range ? ` on ${range}` : ''}.`)
|
|
if (location) lines.push(`Location: ${location}.`)
|
|
lines.push('', `View your signups: ${input.manageUrl}`)
|
|
} else if (input.type === 'class_signup_waitlisted') {
|
|
subject = `Waitlisted for ${cls} at ${space}`
|
|
lines.push(
|
|
`You are on the waitlist for ${cls} at ${space}${range ? ` on ${range}` : ''}.`,
|
|
'You will be moved into the session by email if a spot opens up.',
|
|
)
|
|
if (location) lines.push(`Location: ${location}.`)
|
|
lines.push('', `View your signups: ${input.manageUrl}`)
|
|
} else if (input.type === 'class_signup_promoted') {
|
|
subject = `You're in: ${cls} at ${space}`
|
|
lines.push(
|
|
`A spot opened up and you have been moved from the waitlist into ${cls} at ${space}${range ? ` on ${range}` : ''}.`,
|
|
)
|
|
if (location) lines.push(`Location: ${location}.`)
|
|
lines.push('', `View your signups: ${input.manageUrl}`)
|
|
} else {
|
|
subject = `Cancelled: ${cls} at ${space}`
|
|
lines.push(
|
|
`The session of ${cls} at ${space}${range ? ` scheduled for ${range}` : ''} has been cancelled.`,
|
|
'',
|
|
`View your signups: ${input.manageUrl}`,
|
|
)
|
|
}
|
|
|
|
const { html, text } = renderShell(lines)
|
|
return { subject, html, text }
|
|
}
|
|
|
|
// ─── Form submission ─────────────────────────────────────────────────────────
|
|
|
|
export type FormNotificationType = 'form_submission_received' | 'form_submission_admin'
|
|
|
|
// Submitter confirmation is one row per submission. Admin fan-out is one row
|
|
// per (submission, admin) so each admin's email is independent.
|
|
export function formDedupeKey(
|
|
type: FormNotificationType,
|
|
ids: { submissionId: string; adminMemberId?: string | null },
|
|
): string {
|
|
if (type === 'form_submission_admin') {
|
|
return notificationDedupeKey([type, ids.submissionId, ids.adminMemberId])
|
|
}
|
|
return notificationDedupeKey([type, ids.submissionId])
|
|
}
|
|
|
|
export type FormEmailInput = {
|
|
type: FormNotificationType
|
|
spaceName: string
|
|
recipientName?: string | null
|
|
formTitle: string
|
|
// For the submitter confirmation, the link goes to /me. For admin alerts,
|
|
// pass the /forms/<id>/results URL so the admin lands on the result.
|
|
manageUrl: string
|
|
// Admin-only: name (or generic) of the person who submitted, to give
|
|
// context. For submitter confirmation this is unused. Always optional.
|
|
submitterLabel?: string | null
|
|
}
|
|
|
|
export function renderFormEmail(input: FormEmailInput): RenderedEmail {
|
|
const space = stripUrls(input.spaceName) || 'your hackerspace'
|
|
const name = stripUrls(input.recipientName).trim() || null
|
|
const greeting = name ? `Hi ${name},` : 'Hi,'
|
|
const form = stripUrls(input.formTitle) || 'the form'
|
|
|
|
let subject: string
|
|
const lines: string[] = [greeting, '']
|
|
|
|
if (input.type === 'form_submission_received') {
|
|
subject = `Received: ${form} at ${space}`
|
|
lines.push(
|
|
`We received your submission to ${form} at ${space}. Thanks.`,
|
|
'',
|
|
`Manage your account: ${input.manageUrl}`,
|
|
)
|
|
} else {
|
|
subject = `New submission: ${form} at ${space}`
|
|
const who = stripUrls(input.submitterLabel).trim() || 'someone'
|
|
lines.push(
|
|
`${who} submitted ${form} at ${space}.`,
|
|
'',
|
|
`View the submission: ${input.manageUrl}`,
|
|
)
|
|
}
|
|
|
|
const { html, text } = renderShell(lines)
|
|
return { subject, html, text }
|
|
}
|
|
|
|
// ─── Booking (equipment reservation) ─────────────────────────────────────────
|
|
|
|
export type BookingNotificationType = 'booking_confirmed' | 'booking_cancelled'
|
|
|
|
// One row per reservation per event. A cancel-then-re-reserve produces a new
|
|
// reservation_id, so confirmation + cancel of the same reservation each fire
|
|
// once and never collide.
|
|
export function bookingDedupeKey(
|
|
type: BookingNotificationType,
|
|
reservationId: string,
|
|
): string {
|
|
return notificationDedupeKey([type, reservationId])
|
|
}
|
|
|
|
export type BookingEmailInput = {
|
|
type: BookingNotificationType
|
|
spaceName: string
|
|
memberName?: string | null
|
|
equipmentName: string
|
|
location?: string | null
|
|
startsAt: string
|
|
endsAt: string
|
|
manageUrl: string
|
|
}
|
|
|
|
export function renderBookingEmail(input: BookingEmailInput): RenderedEmail {
|
|
const space = stripUrls(input.spaceName) || 'your hackerspace'
|
|
const name = stripUrls(input.memberName).trim() || null
|
|
const greeting = name ? `Hi ${name},` : 'Hi,'
|
|
const equipment = stripUrls(input.equipmentName) || 'the equipment'
|
|
const range = formatRange(input.startsAt, input.endsAt)
|
|
const location = stripUrls(input.location).trim()
|
|
|
|
let subject: string
|
|
const lines: string[] = [greeting, '']
|
|
|
|
if (input.type === 'booking_confirmed') {
|
|
subject = `Reservation confirmed: ${equipment} at ${space}`
|
|
lines.push(
|
|
`Your reservation for ${equipment} at ${space} is confirmed${range ? ` for ${range}` : ''}.`,
|
|
)
|
|
if (location) lines.push(`Location: ${location}.`)
|
|
lines.push('', `View your reservations: ${input.manageUrl}`)
|
|
} else {
|
|
subject = `Reservation cancelled: ${equipment} at ${space}`
|
|
lines.push(
|
|
`Your reservation for ${equipment} at ${space}${range ? ` for ${range}` : ''} was cancelled.`,
|
|
)
|
|
lines.push('', `View your reservations: ${input.manageUrl}`)
|
|
}
|
|
|
|
const { html, text } = renderShell(lines)
|
|
return { subject, html, text }
|
|
}
|