Send app broadcasts as Resend Broadcasts; add selective import from Resend; v2.7.0
Publish Container / build (push) Successful in 15m22s
Publish Container / build (push) Successful in 15m22s
- Broadcast Email creates and sends a Resend Broadcast to the Resend segment (unsubscribe handling, reports, New Episode topic, scheduling, test send) - Subscribers: import chosen Resend-only contacts into the app list Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af967d0049
commit
66cdb9c4c9
5 files changed
+259
-53
No files matched your search
+143
-30
@@ -407,7 +407,7 @@ export function register(app) {
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
subscribedAt: entry.submittedAt,
|
||||
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
||||
source: entry.source === 'resend-import' ? 'resend-import' : entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
||||
}))
|
||||
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
|
||||
res.json({ subscribers, total: subscribers.length })
|
||||
@@ -632,8 +632,108 @@ export function register(app) {
|
||||
}
|
||||
})
|
||||
|
||||
// Import chosen Resend-only contacts into the app's subscriber list (explicit, per-person opt-in by the admin).
|
||||
app.post('/api/admin-subscribers/import-from-resend', requireAdminAuth, async (req, res) => {
|
||||
const { emails } = req.body ?? {}
|
||||
if (!Array.isArray(emails) || emails.length === 0 || emails.length > 200 || !emails.every(e => typeof e === 'string')) {
|
||||
res.status(400).json({ message: 'Choose between 1 and 200 contacts to import.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const diff = await buildResendComparison()
|
||||
const allowed = new Map(diff.onlyInResend.map(c => [c.email, c]))
|
||||
const now = new Date().toISOString()
|
||||
const created = []
|
||||
for (const raw of emails) {
|
||||
const email = raw.trim().toLowerCase()
|
||||
const contact = allowed.get(email)
|
||||
if (!contact) continue // not active in Resend, or already a subscriber here
|
||||
created.push({
|
||||
id: randomUUID(),
|
||||
submittedAt: now,
|
||||
name: contact.name || email,
|
||||
email,
|
||||
message: 'Imported from Resend audience',
|
||||
messageType: 'general',
|
||||
subscribe: true,
|
||||
archived: true,
|
||||
read: true,
|
||||
source: 'resend-import',
|
||||
})
|
||||
}
|
||||
if (created.length > 0) {
|
||||
state.contactSubmissions = [...created, ...state.contactSubmissions]
|
||||
queueContactSubmissionsWrite()
|
||||
}
|
||||
res.json({ ok: true, imported: created.length, skipped: emails.length - created.length })
|
||||
} catch (err) {
|
||||
console.error('[subscribers] import from resend failed:', err)
|
||||
res.status(502).json({ message: 'Import failed. Try again in a moment.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Broadcast Email: sent as a real Resend Broadcast to the Resend segment ──────
|
||||
const UNSUB_FOOTER = '<p style="margin:32px 0 0;font-size:12px;color:#888;text-align:center;"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#888;">Unsubscribe</a></p>'
|
||||
|
||||
// App-side {{name}} becomes Resend's per-contact variable; guarantee an unsubscribe link exists.
|
||||
function toResendBroadcastHtml(html) {
|
||||
let out = html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, '{{{FIRST_NAME|Friend}}}')
|
||||
if (!/RESEND_UNSUBSCRIBE_URL/i.test(out)) {
|
||||
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${UNSUB_FOOTER}</body>`) : `${out}\n${UNSUB_FOOTER}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
app.get('/api/admin-broadcast/audience', requireAdminAuth, async (_req, res) => {
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID
|
||||
if (!process.env.RESEND_API_KEY || !segmentId) {
|
||||
res.json({ segmentConfigured: false, count: null }); return
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
const contacts = await listAllResendContacts(resend, segmentId)
|
||||
res.json({ segmentConfigured: true, count: contacts.filter(c => !c.unsubscribed).length })
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] audience count failed:', err)
|
||||
res.status(502).json({ message: 'Could not read the Resend segment.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcast/test', requireAdminAuth, async (req, res) => {
|
||||
const { subject, html, to } = req.body ?? {}
|
||||
if (typeof subject !== 'string' || !subject.trim() || typeof html !== 'string' || !html.trim()) {
|
||||
res.status(400).json({ message: 'Subject and body are required.' }); return
|
||||
}
|
||||
if (typeof to !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to.trim())) {
|
||||
res.status(400).json({ message: 'Enter a valid test email address.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const preview = toResendBroadcastHtml(html)
|
||||
.replace(/\{\{\{\s*FIRST_NAME\s*\|\s*([^}]*?)\s*\}\}\}/gi, (_m, fallback) => escapeHtml(fallback || 'Friend'))
|
||||
.replace(/\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/gi, '#')
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { error } = await resend.emails.send({
|
||||
from: getResendFromAddress(),
|
||||
to: to.trim(),
|
||||
replyTo: getResendReplyToAddress(),
|
||||
subject: `[TEST] ${subject.trim()}`,
|
||||
html: preview,
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] test send failed:', err)
|
||||
res.status(502).json({ message: 'Test email failed to send.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
|
||||
const { subject, html } = req.body ?? {}
|
||||
const { subject, html, previewText, tagNewEpisode, scheduledAt } = req.body ?? {}
|
||||
if (!subject || typeof subject !== 'string' || !subject.trim()) {
|
||||
res.status(400).json({ message: 'Subject is required.' }); return
|
||||
}
|
||||
@@ -643,38 +743,51 @@ export function register(app) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
const recipients = state.contactSubmissions
|
||||
.filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email))
|
||||
.map(e => ({ name: e.name ?? '', email: e.email }))
|
||||
|
||||
if (recipients.length === 0) {
|
||||
res.status(400).json({ message: 'No subscribers to send to.' }); return
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID
|
||||
if (!segmentId) {
|
||||
res.status(500).json({ message: 'RESEND_SEGMENT_ID is not configured, so there is no segment to send to.' }); return
|
||||
}
|
||||
let scheduled
|
||||
if (typeof scheduledAt === 'string' && scheduledAt.trim()) {
|
||||
const when = Date.parse(scheduledAt)
|
||||
if (!Number.isFinite(when) || when < Date.now() + 60 * 1000) {
|
||||
res.status(400).json({ message: 'Schedule time must be at least a minute in the future.' }); return
|
||||
}
|
||||
scheduled = new Date(when).toISOString()
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = getResendFromAddress()
|
||||
const replyTo = getResendReplyToAddress()
|
||||
const BATCH = 50
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
for (let i = 0; i < recipients.length; i += BATCH) {
|
||||
const batch = recipients.slice(i, i + BATCH)
|
||||
const emails = batch.map(r => ({
|
||||
from,
|
||||
to: r.email,
|
||||
reply_to: replyTo,
|
||||
let topicId
|
||||
if (tagNewEpisode === true) {
|
||||
const { data: topics, error: topicsError } = await 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) {
|
||||
res.status(400).json({ message: 'No "New Episode" topic exists in Resend.' }); return
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await resend.broadcasts.create({
|
||||
name: subject.trim().slice(0, 120),
|
||||
segmentId,
|
||||
from: getResendFromAddress(),
|
||||
replyTo: getResendReplyToAddress(),
|
||||
subject: subject.trim(),
|
||||
html: html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, escapeHtml(r.name || 'Friend')),
|
||||
}))
|
||||
try {
|
||||
const { error } = await resend.batch.send(emails)
|
||||
if (error) { failed += batch.length } else { sent += batch.length }
|
||||
} catch { failed += batch.length }
|
||||
}
|
||||
html: toResendBroadcastHtml(html),
|
||||
...(typeof previewText === 'string' && previewText.trim() ? { previewText: previewText.trim().slice(0, 200) } : {}),
|
||||
...(topicId ? { topicId } : {}),
|
||||
send: true,
|
||||
...(scheduled ? { scheduledAt: scheduled } : {}),
|
||||
})
|
||||
if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
|
||||
|
||||
res.json({ ok: true, sent, failed, total: recipients.length })
|
||||
console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
|
||||
res.json({ ok: true, broadcastId: data.id, scheduledAt: scheduled ?? null })
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] send failed:', err)
|
||||
res.status(502).json({ message: `Resend rejected the broadcast: ${err instanceof Error ? err.message : 'unknown error'}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user