diff --git a/package.json b/package.json index fa1d12b..9a5b7c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.7.0", + "version": "2.7.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server/email.js b/server/email.js index 546de6c..7af6e44 100644 --- a/server/email.js +++ b/server/email.js @@ -84,6 +84,19 @@ export async function sendResendEmailWithRetry({ resend, payload, context, maxAt throw lastError ?? new Error(`[${context}] email send failed`) } +// Retry a Resend SDK call when the account's rate limit (429) is hit. The SDK returns { error } rather than throwing. +export async function resendRetry(call, maxAttempts = 5) { + let result + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + result = await call() + const err = result?.error + const limited = err && (err.statusCode === 429 || err.name === 'rate_limit_exceeded') + if (!limited) return result + await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)) + } + return result +} + export async function syncContactToResend(name, email) { if (!process.env.RESEND_API_KEY) return const { firstName, lastName } = splitName(name) @@ -91,19 +104,19 @@ export async function syncContactToResend(name, email) { try { // Never touch `unsubscribed` for a contact that already exists — otherwise a form submission // would silently resubscribe someone who opted out in Resend. - const { data: existing } = await contactResend.contacts.get({ email }) + const { data: existing } = await resendRetry(() => contactResend.contacts.get({ email })) if (existing) { - const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName }) + const { error: updateError } = await resendRetry(() => contactResend.contacts.update({ email, firstName, lastName })) if (updateError) console.error('[resend] contact sync error:', updateError) return } - const { error: createError } = await contactResend.contacts.create({ + const { error: createError } = await resendRetry(() => contactResend.contacts.create({ email, firstName, lastName, unsubscribed: false, ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), - }) + })) if (createError) console.error('[resend] contact sync error:', createError) } catch (err) { console.error('[resend] contact sync exception:', err) diff --git a/server/routes/contact.js b/server/routes/contact.js index 19fb8b5..75077f3 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -33,6 +33,7 @@ import { buildContactAdminNotificationTemplate, sendResendEmailWithRetry, syncContactToResend, + resendRetry, } from '../email.js' // RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">" @@ -446,7 +447,7 @@ export function register(app) { const out = [] let after for (let page = 0; page < 50; page += 1) { - const { data, error } = await resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }) + const { data, error } = await resendRetry(() => resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) })) if (error) throw new Error(error.message || 'Resend contacts.list failed') const rows = data?.data ?? [] out.push(...rows) @@ -550,13 +551,13 @@ export function register(app) { try { if (action === 'add-to-resend') { const { firstName, lastName } = splitName(target.name) - const { error } = await resend.contacts.create({ + const { error } = await resendRetry(() => resend.contacts.create({ email: target.email, firstName, lastName, unsubscribed: false, ...(segmentId ? { segments: [{ id: segmentId }] } : {}), - }) + })) if (error) throw new Error(error.message) } else { - const { error } = await resend.contacts.segments.add({ email: target.email, segmentId }) + const { error } = await resendRetry(() => resend.contacts.segments.add({ email: target.email, segmentId })) if (error) throw new Error(error.message) } done += 1 @@ -583,7 +584,7 @@ export function register(app) { const templates = [] let after for (let page = 0; page < 5; page += 1) { - const { data, error } = await resend.templates.list({ limit: 100, ...(after ? { after } : {}) }) + const { data, error } = await resendRetry(() => resend.templates.list({ limit: 100, ...(after ? { after } : {}) })) if (error) throw new Error(error.message) templates.push(...(data?.data ?? [])) if (!data?.has_more || !data.data?.length) break @@ -606,7 +607,7 @@ export function register(app) { } try { const resend = new Resend(process.env.RESEND_API_KEY) - const { data, error } = await resend.templates.get(req.params.id) + const { data, error } = await resendRetry(() => resend.templates.get(req.params.id)) if (error || !data) { res.status(404).json({ message: error?.message || 'Template not found.' }); return } @@ -761,7 +762,7 @@ export function register(app) { let topicId if (tagNewEpisode === true) { - const { data: topics, error: topicsError } = await resend.topics.list() + const { data: topics, error: topicsError } = await resendRetry(() => resend.topics.list()) if (topicsError) throw new Error(topicsError.message) topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id if (!topicId) { @@ -769,7 +770,7 @@ export function register(app) { } } - const { data, error } = await resend.broadcasts.create({ + const { data, error } = await resendRetry(() => resend.broadcasts.create({ name: subject.trim().slice(0, 120), segmentId, from: getResendFromAddress(), @@ -780,7 +781,7 @@ export function register(app) { ...(topicId ? { topicId } : {}), send: true, ...(scheduled ? { scheduledAt: scheduled } : {}), - }) + })) if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.') console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`) diff --git a/server/routes/newsletter.js b/server/routes/newsletter.js index bc8a67c..21a0173 100644 --- a/server/routes/newsletter.js +++ b/server/routes/newsletter.js @@ -4,6 +4,7 @@ import { requireAdminAuth } from '../auth.js' import { state } from '../state.js' import { queueNewsletterIssuesWrite } from '../data.js' import { escapeHtml } from '../helpers.js' +import { resendRetry } from '../email.js' const BASE = 'https://versebyversewithnate.us' const NEW_EPISODE_TOPIC_NAME = 'new episode' @@ -117,8 +118,8 @@ export function register(app) { try { const resend = new Resend(process.env.RESEND_API_KEY) const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([ - resend.topics.list(), - resend.broadcasts.list(), + resendRetry(() => resend.topics.list()), + resendRetry(() => resend.broadcasts.list()), ]) if (topicsError) throw new Error(topicsError.message) if (listError) throw new Error(listError.message) @@ -126,7 +127,12 @@ export function register(app) { const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME) const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent') - const details = await Promise.all(sentBroadcasts.map(b => resend.broadcasts.get(b.id))) + // Fetch a few at a time so a long broadcast history can't exceed Resend's requests-per-second limit. + const details = [] + for (let i = 0; i < sentBroadcasts.length; i += 4) { + const chunk = await Promise.all(sentBroadcasts.slice(i, i + 4).map(b => resendRetry(() => resend.broadcasts.get(b.id)))) + details.push(...chunk) + } const broadcasts = details.map(({ data }, i) => { const summary = sentBroadcasts[i]