Add Compare with Resend panel to Subscribers; stop resubscribing opted-out contacts; v2.6.1
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 08:29:56 -04:00
1 parent e74bd1df5d
commit af967d0049
5 files changed
+273 -6

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.5.0",
"version": "2.6.1",
"type": "module",
"scripts": {
"dev": "vite",
+10 -5
View File
@@ -89,17 +89,22 @@ export async function syncContactToResend(name, email) {
const { firstName, lastName } = splitName(name)
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
try {
const { error: contactError } = await contactResend.contacts.create({
// 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 })
if (existing) {
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName })
if (updateError) console.error('[resend] contact sync error:', updateError)
return
}
const { error: createError } = await contactResend.contacts.create({
email,
firstName,
lastName,
unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
})
if (contactError) {
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false })
if (updateError) console.error('[resend] contact sync error:', updateError)
}
if (createError) console.error('[resend] contact sync error:', createError)
} catch (err) {
console.error('[resend] contact sync exception:', err)
}
+145
View File
@@ -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
+97
View File
@@ -662,6 +662,19 @@ interface Subscriber {
}
interface ResendComparison {
appCount: number
resendCount: number
resendUnsubscribedCount: number
segmentConfigured: boolean
segmentCount: number | null
matched: number
onlyInApp: Array<{ name: string; email: string }>
onlyInResend: Array<{ name: string; email: string }>
unsubscribedInResend: Array<{ name: string; email: string }>
notInSegment: Array<{ name: string; email: string }>
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView =
@@ -1583,6 +1596,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [resendCmp, setResendCmp] = useState<ResendComparison | null>(null)
const [resendCmpLoading, setResendCmpLoading] = useState(false)
const [resendCmpBusy, setResendCmpBusy] = useState(false)
const [resendCmpMsg, setResendCmpMsg] = useState('')
const [subscriberSearch, setSubscriberSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
@@ -3495,12 +3512,92 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
)
async function runResendCompare() {
setResendCmpLoading(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/resend-compare')
const d = await r.json()
if (!r.ok) setResendCmpMsg(d.message || 'Comparison failed.')
else setResendCmp(d as ResendComparison)
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpLoading(false) }
}
async function runResendSync(action: 'add-to-resend' | 'add-to-segment' | 'apply-unsubscribes', prompt: string) {
if (!confirm(prompt)) return
setResendCmpBusy(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/resend-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
const d = await r.json()
if (!r.ok) { setResendCmpMsg(d.message || 'Sync failed.'); return }
setResendCmpMsg(`Done: ${d.done} updated${d.failed ? `, ${d.failed} failed` : ''}${d.remaining ? `, ${d.remaining} more — run the action again` : ''}.`)
const subsRes = await fetch('/api/admin-subscribers')
if (subsRes.ok) setSubscribers(((await subsRes.json()) as { subscribers: Subscriber[] }).subscribers ?? [])
await runResendCompare()
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpBusy(false) }
}
const renderCmpList = (title: string, note: string, rows: Array<{ name: string; email: string }>, action?: React.ReactNode) => (
<details className="cmp-group" open={rows.length > 0 && rows.length <= 10}>
<summary>
<span className={`cmp-count${rows.length === 0 ? ' is-zero' : ''}`}>{rows.length}</span>
<span className="cmp-title">{title}</span>
</summary>
<p className="cmp-note">{note}</p>
{rows.length > 0 && (
<ul className="cmp-rows">
{rows.slice(0, 200).map(r => <li key={r.email}><span>{r.name || '—'}</span><a href={`mailto:${r.email}`}>{r.email}</a></li>)}
{rows.length > 200 && <li className="cmp-more">…and {rows.length - 200} more</li>}
</ul>
)}
{rows.length > 0 && action}
</details>
)
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Subscribers</h2>
<p>{subscribers.length} people have opted in to email updates.</p>
</div>
<div className="cmp-card">
<div className="cmp-head">
<div>
<strong>Compare with Resend</strong>
<p className="cmp-note" style={{ margin: '0.2rem 0 0' }}>Checks that everyone who opted in here is also in your Resend audience, and that nobody who unsubscribed there is still active here.</p>
</div>
<button type="button" className="btn-admin-reset" disabled={resendCmpLoading || resendCmpBusy} onClick={() => void runResendCompare()}>
{resendCmpLoading ? 'Checking Resend…' : resendCmp ? 'Re-check' : 'Run comparison'}
</button>
</div>
{resendCmpMsg && <p className="admin-stats-note" role="status">{resendCmpMsg}</p>}
{resendCmp && (() => {
const c = resendCmp
const inSync = c.onlyInApp.length === 0 && c.unsubscribedInResend.length === 0 && c.notInSegment.length === 0
return (
<>
<div className="cmp-stats">
<div><b>{c.appCount}</b><span>Subscribers here</span></div>
<div><b>{c.resendCount}</b><span>Contacts in Resend{c.resendUnsubscribedCount ? ` (${c.resendUnsubscribedCount} unsubscribed)` : ''}</span></div>
{c.segmentCount !== null && <div><b>{c.segmentCount}</b><span>In your Resend segment</span></div>}
<div><b>{c.matched}</b><span>Fully matched</span></div>
</div>
{inSync && <p className="cmp-ok">✓ Every subscriber here is in Resend and active. {c.onlyInResend.length > 0 ? `Resend also has ${c.onlyInResend.length} other active contact${c.onlyInResend.length === 1 ? '' : 's'} (see below).` : ''}</p>}
{!c.segmentConfigured && <p className="cmp-note">Note: RESEND_SEGMENT_ID isn’t set on the server, so segment membership wasn’t checked and new contacts aren’t added to a segment.</p>}
{renderCmpList('Subscribed here, missing from Resend', 'These people opted in on the site but never made it into Resend, so Resend broadcasts won’t reach them.', c.onlyInApp,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-resend', `Add ${c.onlyInApp.length} subscriber${c.onlyInApp.length === 1 ? '' : 's'} to Resend? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.onlyInApp.length, 100)} to Resend`}</button>)}
{renderCmpList('Unsubscribed in Resend, still active here', 'They opted out through Resend. Marking them unsubscribed here stops the in-app Broadcast Email from sending to them.', c.unsubscribedInResend,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('apply-unsubscribes', `Mark ${c.unsubscribedInResend.length} person${c.unsubscribedInResend.length === 1 ? '' : 's'} as unsubscribed here? They will be removed from the subscriber list and future broadcasts.`)}>{resendCmpBusy ? 'Working…' : 'Mark unsubscribed here'}</button>)}
{c.segmentConfigured && renderCmpList('In Resend, but not in your segment', 'These subscribers exist in Resend but aren’t in the segment your broadcasts use.', c.notInSegment,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-segment', `Add ${c.notInSegment.length} contact${c.notInSegment.length === 1 ? '' : 's'} to the Resend segment? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.notInSegment.length, 100)} to segment`}</button>)}
{renderCmpList('In Resend, not subscribed here', 'Informational only. These are usually study-account signups, or people who opted in before the site tracked it. Nothing is changed for them.', c.onlyInResend)}
</>
)
})()}
</div>
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"
+20
View File
@@ -12969,3 +12969,23 @@
.ib-layout.has-open { grid-template-columns: minmax(0, 1fr); }
.ib-detail { position: static; max-height: none; }
}
/* ── Subscribers ⇄ Resend comparison ── */
.cmp-card { background: #0f0d09; border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 12px; padding: 1rem; margin-bottom: 1.2rem; }
.cmp-head { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.cmp-head > div { flex: 1 1 320px; min-width: 0; }
.cmp-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; margin: 0.9rem 0; }
.cmp-stats > div { background: #151209; border: 1px solid rgba(201, 168, 76, 0.15); border-radius: 8px; padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.15rem; }
.cmp-stats b { font-size: 1.4rem; color: var(--brand-gold); }
.cmp-stats span { font-size: 0.75rem; opacity: 0.75; }
.cmp-ok { color: #7fd48a; font-size: 0.9rem; }
.cmp-note { font-size: 0.8rem; opacity: 0.7; margin: 0.4rem 0; }
.cmp-group { border-top: 1px solid rgba(201, 168, 76, 0.14); padding: 0.6rem 0; }
.cmp-group summary { cursor: pointer; display: flex; align-items: center; gap: 0.6rem; }
.cmp-count { background: rgba(201, 168, 76, 0.2); color: var(--brand-gold); border-radius: 999px; min-width: 1.8rem; text-align: center; padding: 0.1rem 0.5rem; font-weight: 600; font-size: 0.85rem; }
.cmp-count.is-zero { background: rgba(127, 212, 138, 0.15); color: #7fd48a; }
.cmp-title { font-size: 0.92rem; }
.cmp-rows { list-style: none; margin: 0.4rem 0 0.7rem; padding: 0; max-height: 260px; overflow-y: auto; font-size: 0.82rem; }
.cmp-rows li { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; padding: 0.25rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); overflow-wrap: anywhere; }
.cmp-rows li span { min-width: 140px; opacity: 0.85; }
.cmp-more { opacity: 0.6; }