diff --git a/package.json b/package.json index fab6e44..fa1d12b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.6.1", + "version": "2.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/contact.js b/server/routes/contact.js index bf0bfed..19fb8b5 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -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 = '

Unsubscribe

' + + // 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}`) : `${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'}` }) + } }) } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 5cd0c0d..4ee506d 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -658,7 +658,7 @@ interface Subscriber { name: string email: string subscribedAt: string - source: 'contact-form' | 'download' + source: 'contact-form' | 'download' | 'resend-import' } @@ -1600,6 +1600,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [resendCmpLoading, setResendCmpLoading] = useState(false) const [resendCmpBusy, setResendCmpBusy] = useState(false) const [resendCmpMsg, setResendCmpMsg] = useState('') + const [resendImportSel, setResendImportSel] = useState>(new Set()) const [subscriberSearch, setSubscriberSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) // Study comments moderation state @@ -1655,7 +1656,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [broadcastSubject, setBroadcastSubject] = useState('') const [broadcastBody, setBroadcastBody] = useState('') const [broadcastSending, setBroadcastSending] = useState(false) - const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null) + const [broadcastResult, setBroadcastResult] = useState<{ broadcastId: string; scheduledAt: string | null } | null>(null) + const [bcPreviewText, setBcPreviewText] = useState('') + const [bcTagNewEpisode, setBcTagNewEpisode] = useState(false) + const [bcSchedule, setBcSchedule] = useState('') + const [bcTestTo, setBcTestTo] = useState('') + const [bcTesting, setBcTesting] = useState(false) + const [bcAudience, setBcAudience] = useState<{ segmentConfigured: boolean; count: number | null } | null>(null) + const [bcAudienceLoading, setBcAudienceLoading] = useState(false) const [broadcastMsg, setBroadcastMsg] = useState('') const [broadcastConfirm, setBroadcastConfirm] = useState(false) const [bcTemplates, setBcTemplates] = useState>([]) @@ -3399,7 +3407,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{subscribers.length}
Email Subscribers
-
{contactSubmissions.length} total contact submissions
+
{contactSubmissions.filter(c => c.source !== 'resend-import').length} total contact submissions
@@ -3540,7 +3548,28 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) } catch { setResendCmpMsg('Network error. Try again.') } finally { setResendCmpBusy(false) } } - const renderCmpList = (title: string, note: string, rows: Array<{ name: string; email: string }>, action?: React.ReactNode) => ( + async function importFromResend() { + const emails = [...resendImportSel] + if (emails.length === 0) return + if (!confirm(`Add ${emails.length} contact${emails.length === 1 ? '' : 's'} from Resend to your subscriber list here?\n\nOnly do this for people who agreed to receive your emails.`)) return + setResendCmpBusy(true); setResendCmpMsg('') + try { + const r = await fetch('/api/admin-subscribers/import-from-resend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ emails }), + }) + const d = await r.json() + if (!r.ok) { setResendCmpMsg(d.message || 'Import failed.'); return } + setResendCmpMsg(`Imported ${d.imported}${d.skipped ? `, skipped ${d.skipped}` : ''}.`) + setResendImportSel(new Set()) + 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, selectable = false) => (
0 && rows.length <= 10}> {rows.length} @@ -3549,7 +3578,17 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

{note}

{rows.length > 0 && (
    - {rows.slice(0, 200).map(r =>
  • {r.name || '—'}{r.email}
  • )} + {selectable && ( +
  • + +
  • + )} + {rows.slice(0, 200).map(r => ( +
  • + {selectable && setResendImportSel(prev => { const n = new Set(prev); if (n.has(r.email)) n.delete(r.email); else n.add(r.email); return n })} aria-label={`Select ${r.email}`} />} + {r.name || '—'}{r.email} +
  • + ))} {rows.length > 200 &&
  • …and {rows.length - 200} more
  • }
)} @@ -3593,7 +3632,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) )} {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, )} - {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)} + {renderCmpList('In Resend, not subscribed here', 'These are usually study-account signups, or people who opted in before the site tracked it. Nothing changes unless you tick people and import them — only import people who agreed to receive your emails.', c.onlyInResend, + , true)} ) })()} @@ -3628,7 +3668,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) {sub.name} {sub.email} - {sub.source === 'download' ? 'Download' : 'Contact Form'} + {sub.source === 'download' ? 'Download' : sub.source === 'resend-import' ? 'Imported from Resend' : 'Contact Form'} {formatDate(sub.subscribedAt)} ))} @@ -6126,10 +6166,31 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) {/* ── BROADCAST EMAIL ── */} {adminView === 'broadcast' && (() => { - const recipientCount = (() => { - const seen = new Set() - return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length - })() + const recipientCount = bcAudience?.count ?? null + async function loadBcAudience() { + setBcAudienceLoading(true) + try { + const r = await fetch('/api/admin-broadcast/audience') + const d = await r.json() + setBcAudience(r.ok ? d : { segmentConfigured: false, count: null }) + if (!r.ok) setBroadcastMsg(d.message || 'Could not read the Resend segment.') + } catch { setBcAudience({ segmentConfigured: false, count: null }) } + finally { setBcAudienceLoading(false) } + } + if (bcAudience === null && !bcAudienceLoading) void loadBcAudience() + async function sendBcTest() { + setBcTesting(true); setBroadcastMsg(''); setBroadcastResult(null) + try { + const r = await fetch('/api/admin-broadcast/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody, to: bcTestTo }), + }) + const d = await r.json() + setBroadcastMsg(r.ok ? `Test sent to ${bcTestTo}.` : d.message || 'Test failed.') + } catch { setBroadcastMsg('Network error. Try again.') } + finally { setBcTesting(false) } + } async function loadBcTemplates() { setBcTplLoading(true); setBcTplNote('') try { @@ -6154,7 +6215,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) setBroadcastBody(d.html || '') setBroadcastConfirm(false) const warnings: string[] = [] - if (d.hasUnsubscribeLink) warnings.push('This template has Resend’s unsubscribe link, which only works in Resend-sent broadcasts — replace it before sending.') if (Array.isArray(d.unresolved) && d.unresolved.length) warnings.push(`Unfilled variables: ${d.unresolved.join(', ')}.`) setBcTplNote(`Loaded “${d.name}”. ${warnings.join(' ')}`.trim()) } catch { setBcTplNote('Network error loading template.') } @@ -6168,12 +6228,20 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) const r = await fetch('/api/admin-broadcast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }), + body: JSON.stringify({ + subject: broadcastSubject, + html: broadcastBody, + previewText: bcPreviewText, + tagNewEpisode: bcTagNewEpisode, + scheduledAt: bcSchedule ? new Date(bcSchedule).toISOString() : undefined, + }), }) const d = await r.json() if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else { setBroadcastResult(d); setBroadcastConfirm(false) - setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`) + setBroadcastMsg(d.scheduledAt + ? `Scheduled in Resend for ${new Date(d.scheduledAt).toLocaleString()}.` + : 'Sent through Resend. Delivery and open stats are in your Resend dashboard under Broadcasts.') } } catch { setBroadcastMsg('Network error. Try again.') } finally { setBroadcastSending(false) } @@ -6182,7 +6250,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

Broadcast Email

-

Compose and send a one-off email to all {subscribers.length} subscribers.

+

Compose an email and send it as a Resend Broadcast to your Resend segment{recipientCount !== null ? ` (${recipientCount} active contacts)` : ''}. Unsubscribe links and opt-outs are handled by Resend.

@@ -6200,9 +6268,28 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
- +