Add Compare with Resend panel to Subscribers; stop resubscribing opted-out contacts; v2.6.1
Publish Container / build (push) Successful in 16m27s
Publish Container / build (push) Successful in 16m27s
- Subscribers: compare in-app list with Resend audience/segment, with one-click fixes - syncContactToResend no longer resets unsubscribed on existing contacts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e74bd1df5d
commit
af967d0049
5 files changed
+273
-6
No files matched your search
@@ -429,6 +429,151 @@ export function register(app) {
|
||||
res.send(csv)
|
||||
})
|
||||
|
||||
// ── Subscribers ⇄ Resend comparison ─────────────────────────────────────────
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function getAppSubscribers() {
|
||||
const byEmail = new Map()
|
||||
for (const entry of state.contactSubmissions) {
|
||||
if (entry.subscribe !== true || !entry.email) continue
|
||||
const email = String(entry.email).trim().toLowerCase()
|
||||
if (!byEmail.has(email)) byEmail.set(email, { name: entry.name ?? '', email })
|
||||
}
|
||||
return byEmail
|
||||
}
|
||||
|
||||
async function listAllResendContacts(resend, segmentId) {
|
||||
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 } : {}) })
|
||||
if (error) throw new Error(error.message || 'Resend contacts.list failed')
|
||||
const rows = data?.data ?? []
|
||||
out.push(...rows)
|
||||
if (!data?.has_more || rows.length === 0) break
|
||||
after = rows[rows.length - 1].id
|
||||
await sleep(550) // Resend allows ~2 requests/second
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function buildResendComparison() {
|
||||
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID || null
|
||||
const appSubs = getAppSubscribers()
|
||||
const resendContacts = await listAllResendContacts(resend)
|
||||
let segmentEmails = null
|
||||
if (segmentId) {
|
||||
await sleep(550)
|
||||
segmentEmails = new Set((await listAllResendContacts(resend, segmentId)).map(c => String(c.email).toLowerCase()))
|
||||
}
|
||||
|
||||
const resendByEmail = new Map(resendContacts.map(c => [String(c.email).toLowerCase(), c]))
|
||||
const nameOf = c => [c.first_name, c.last_name].filter(Boolean).join(' ')
|
||||
|
||||
const onlyInApp = []
|
||||
const unsubscribedInResend = []
|
||||
const notInSegment = []
|
||||
let matched = 0
|
||||
for (const sub of appSubs.values()) {
|
||||
const contact = resendByEmail.get(sub.email)
|
||||
if (!contact) { onlyInApp.push(sub); continue }
|
||||
if (contact.unsubscribed) { unsubscribedInResend.push(sub); continue }
|
||||
if (segmentEmails && !segmentEmails.has(sub.email)) { notInSegment.push(sub); continue }
|
||||
matched += 1
|
||||
}
|
||||
const onlyInResend = resendContacts
|
||||
.filter(c => !appSubs.has(String(c.email).toLowerCase()) && !c.unsubscribed)
|
||||
.map(c => ({ name: nameOf(c), email: String(c.email).toLowerCase() }))
|
||||
|
||||
return {
|
||||
appCount: appSubs.size,
|
||||
resendCount: resendContacts.length,
|
||||
resendUnsubscribedCount: resendContacts.filter(c => c.unsubscribed).length,
|
||||
segmentConfigured: Boolean(segmentId),
|
||||
segmentCount: segmentEmails ? segmentEmails.size : null,
|
||||
matched,
|
||||
onlyInApp,
|
||||
onlyInResend,
|
||||
unsubscribedInResend,
|
||||
notInSegment,
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/admin-subscribers/resend-compare', requireAdminAuth, async (_req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
res.json(await buildResendComparison())
|
||||
} catch (err) {
|
||||
console.error('[subscribers] resend compare failed:', err)
|
||||
res.status(502).json({ message: 'Could not read contacts from Resend. Try again in a moment.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-subscribers/resend-sync', requireAdminAuth, async (req, res) => {
|
||||
const { action } = req.body ?? {}
|
||||
if (!['add-to-resend', 'add-to-segment', 'apply-unsubscribes'].includes(action)) {
|
||||
res.status(400).json({ message: 'Unknown action.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const diff = await buildResendComparison()
|
||||
|
||||
if (action === 'apply-unsubscribes') {
|
||||
const emails = new Set(diff.unsubscribedInResend.map(s => s.email))
|
||||
let changed = 0
|
||||
state.contactSubmissions = state.contactSubmissions.map(entry => {
|
||||
if (entry.subscribe === true && emails.has(String(entry.email ?? '').trim().toLowerCase())) {
|
||||
changed += 1
|
||||
return { ...entry, subscribe: false }
|
||||
}
|
||||
return entry
|
||||
})
|
||||
if (changed > 0) queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, done: emails.size, failed: 0 }); return
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID || null
|
||||
const targets = (action === 'add-to-resend' ? diff.onlyInApp : diff.notInSegment).slice(0, 100)
|
||||
if (action === 'add-to-segment' && !segmentId) {
|
||||
res.status(400).json({ message: 'RESEND_SEGMENT_ID is not configured.' }); return
|
||||
}
|
||||
|
||||
let done = 0
|
||||
let failed = 0
|
||||
for (const target of targets) {
|
||||
try {
|
||||
if (action === 'add-to-resend') {
|
||||
const { firstName, lastName } = splitName(target.name)
|
||||
const { error } = await 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 })
|
||||
if (error) throw new Error(error.message)
|
||||
}
|
||||
done += 1
|
||||
} catch (err) {
|
||||
failed += 1
|
||||
console.error(`[subscribers] resend sync failed for ${target.email}:`, err?.message ?? err)
|
||||
}
|
||||
await sleep(550)
|
||||
}
|
||||
const total = action === 'add-to-resend' ? diff.onlyInApp.length : diff.notInSegment.length
|
||||
res.json({ ok: true, done, failed, remaining: Math.max(total - targets.length, 0) })
|
||||
} catch (err) {
|
||||
console.error('[subscribers] resend sync failed:', err)
|
||||
res.status(502).json({ message: 'Resend sync failed. Try again in a moment.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
|
||||
Reference in new issue
Block a user