Add Tier 2 email features: AI draft, Calendar→Email announce, delivery status, engagement badges; v1.1.24

- EmailPage: useLocation to accept pre-filled compose from Calendar; delivery status pill on sent bubbles; AI Draft button calling ANTHROPIC_API_KEY endpoint
- CalendarPage: useNavigate + announceEpisode(); ✉ Announce button in episode edit popover
- ContactsPage: emailStatus types; unreplied badge; best delivery status pill; engagement score stars
- server/routes/contact.js: POST /api/admin-contact-submissions/:id/draft-reply → Anthropic claude-haiku-4-5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 17:10:25 -04:00
parent e2c560a1ab
commit bf16deea92
6 changed files with 207 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.23", "version": "1.1.24",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+44
View File
@@ -812,4 +812,48 @@ export function register(app) {
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`) res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv) res.send(csv)
}) })
app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return }
const { id } = req.params
if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }); return }
const submission = state.contactSubmissions.find(s => s.id === id.trim())
if (!submission) { res.status(404).json({ message: 'Submission not found.' }); return }
const senderName = submission.name?.trim() || 'this listener'
const messageText = submission.message?.trim() || '(no message body)'
try {
const apiRes = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
max_tokens: 400,
messages: [{
role: 'user',
content: `Draft a warm, personal reply to this message from a podcast listener. Write only the reply body — no greeting line (handled separately), no sign-off (handled by a signature). Keep it under 150 words. Be genuine and specific to their message.\n\nFrom: ${senderName}\nMessage:\n${messageText.slice(0, 1500)}`,
}],
}),
})
if (!apiRes.ok) {
const err = await apiRes.json().catch(() => ({}))
res.status(502).json({ message: err?.error?.message || 'AI draft request failed.' }); return
}
const data = await apiRes.json()
const draft = String(data?.content?.[0]?.text ?? '').trim()
res.json({ ok: true, draft })
} catch {
res.status(502).json({ message: 'AI draft request failed.' })
}
})
} }
+39
View File
@@ -10706,6 +10706,45 @@
width: 100%; width: 100%;
} }
/* Delivery status pills (contacts page) */
.ct-delivery-pill {
border-radius: 10px;
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
}
.ct-delivery-pill--clicked { background: #1a2e1a; color: #4ade80; }
.ct-delivery-pill--opened { background: #1a2535; color: #60a5fa; }
.ct-delivery-pill--delivered { background: #1e1e1e; color: #a0a0a0; }
.ct-delivery-pill--sent { background: #1a1a1a; color: #6b6560; }
.ct-badge--unreplied {
background: #3d1a0a;
color: #fb923c;
}
.ct-engage-badge {
color: #f59e0b;
font-size: 0.75rem;
letter-spacing: -1px;
}
/* Delivery status pills (email page) */
.em-delivery-pill {
border-radius: 10px;
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
white-space: nowrap;
}
.em-delivery-pill--clicked { background: #1a2e1a; color: #4ade80; }
.em-delivery-pill--opened { background: #1a2535; color: #60a5fa; }
.em-delivery-pill--delivered { background: #1e1e1e; color: #a0a0a0; }
.em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; }
.em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; }
/* Flash message */ /* Flash message */
.ct-flash { .ct-flash {
background: #1a2a1a; background: #1a2a1a;
+14 -1
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -248,6 +248,7 @@ export default function CalendarShell() {
// ── Calendar Client ─────────────────────────────────────────────────────────── // ── Calendar Client ───────────────────────────────────────────────────────────
function CalendarClient() { function CalendarClient() {
const navigate = useNavigate()
const today = useMemo(() => new Date(), []) const today = useMemo(() => new Date(), [])
const [checklist, setChecklist] = useState<PodcastChecklistData | null>(null) const [checklist, setChecklist] = useState<PodcastChecklistData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -433,6 +434,17 @@ function CalendarClient() {
setSelectedEpisodeId(null) setSelectedEpisodeId(null)
} }
function announceEpisode(ep: PodcastChecklistEpisode) {
const parts = [ep.series, ep.episodeNumber ? `Episode ${ep.episodeNumber}` : null, ep.title].filter(Boolean)
const subject = `New Episode: ${parts.join(' ')}`
const dateStr = ep.datePublished
? new Date(ep.datePublished + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
: ''
const body = [parts.join(' '), dateStr ? `Published ${dateStr}` : ''].filter(Boolean).join('\n\n')
setEditEp(null)
navigate('/email', { state: { compose: true, subject, body } })
}
function openEdit(ep: PodcastChecklistEpisode) { function openEdit(ep: PodcastChecklistEpisode) {
setEditEp(ep) setEditEp(ep)
setEditForm({ setEditForm({
@@ -920,6 +932,7 @@ function CalendarClient() {
</div> </div>
<div className="cal-popover-actions"> <div className="cal-popover-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>Save</button> <button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>Save</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => announceEpisode(editEp)} title="Open email compose pre-filled to announce this episode"> Announce</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>Unschedule</button> <button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>Unschedule</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>Cancel</button> <button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>Cancel</button>
</div> </div>
+44
View File
@@ -3,6 +3,11 @@ import { Link } from 'react-router-dom'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
interface EmailDeliveryState {
status: string
lastEventAt: string | null
}
interface ContactSubmission { interface ContactSubmission {
id: string id: string
submittedAt: string submittedAt: string
@@ -17,6 +22,11 @@ interface ContactSubmission {
inboundTo?: string inboundTo?: string
notes?: string notes?: string
tags?: string[] tags?: string[]
emailStatus?: {
welcome: EmailDeliveryState
adminNotification: EmailDeliveryState
adminReply: EmailDeliveryState
}
} }
interface ReplyHistoryItem { interface ReplyHistoryItem {
@@ -48,6 +58,9 @@ interface Contact {
mainId: string mainId: string
allIds: string[] allIds: string[]
allSubmissions: ContactSubmission[] allSubmissions: ContactSubmission[]
bestDeliveryStatus: string | null // opened/clicked/delivered/sent/null
unreplied: boolean // has inbound messages, no reply sent
engagementScore: number
} }
type ConversationItem = type ConversationItem =
@@ -298,6 +311,27 @@ function ContactsClient() {
const emailKey = latest.email?.trim().toLowerCase() || latest.id const emailKey = latest.email?.trim().toLowerCase() || latest.id
// Tags: prefer entry that has tags, falling back to mainId submission // Tags: prefer entry that has tags, falling back to mainId submission
const withTags = sorted.find(s => s.tags && s.tags.length > 0) const withTags = sorted.find(s => s.tags && s.tags.length > 0)
// Best delivery status: clicked > opened > delivered > sent
const STATUS_RANK: Record<string, number> = { clicked: 4, opened: 3, delivered: 2, sent: 1 }
let bestDeliveryStatus: string | null = null
let bestRank = -1
for (const s of sorted) {
const st = s.emailStatus?.adminReply?.status
if (st && STATUS_RANK[st] !== undefined && STATUS_RANK[st] > bestRank) {
bestRank = STATUS_RANK[st]
bestDeliveryStatus = st
}
}
const repliesForContact = replyHistory.filter(r => r.toEmail?.trim().toLowerCase() === emailKey)
const unreplied = sorted.some(s => !s.archived) && repliesForContact.length === 0
const engagementScore =
repliesForContact.length * 3 +
sorted.filter(s => s.emailStatus?.adminReply?.status === 'clicked').length * 2 +
sorted.filter(s => s.emailStatus?.adminReply?.status === 'opened').length * 1 +
(sorted.some(s => s.source === 'download') ? 2 : 0)
return { return {
key: emailKey, key: emailKey,
email: latest.email ?? '', email: latest.email ?? '',
@@ -315,6 +349,9 @@ function ContactsClient() {
mainId: latest.id, mainId: latest.id,
allIds: sorted.map(s => s.id), allIds: sorted.map(s => s.id),
allSubmissions: sorted, allSubmissions: sorted,
bestDeliveryStatus,
unreplied,
engagementScore,
} }
}) })
.sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime()) .sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime())
@@ -709,6 +746,13 @@ function ContactsClient() {
{c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>} {c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
{sourceBadge(c.source)} {sourceBadge(c.source)}
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>} {c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
{c.unreplied && <span className="ct-badge ct-badge--unreplied" title="No reply sent yet">needs reply</span>}
{c.bestDeliveryStatus && (
<span className={`ct-delivery-pill ct-delivery-pill--${c.bestDeliveryStatus}`}>
{c.bestDeliveryStatus === 'clicked' ? '🔗 clicked' : c.bestDeliveryStatus === 'opened' ? '👁 opened' : c.bestDeliveryStatus === 'delivered' ? '✓ delivered' : '→ sent'}
</span>
)}
{c.engagementScore >= 3 && <span className="ct-engage-badge" title={`Engagement score: ${c.engagementScore}`}>{'★'.repeat(Math.min(3, Math.floor(c.engagementScore / 3)))}</span>}
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>} {c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
</div> </div>
+65 -5
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
@@ -17,6 +17,14 @@ interface Attachment {
size: number size: number
} }
interface EmailDeliveryState {
status: string
lastEventAt: string | null
lastEventType: string | null
resendEmailId: string | null
error: string | null
}
interface ContactSubmission { interface ContactSubmission {
id: string id: string
threadId: string threadId: string
@@ -34,6 +42,11 @@ interface ContactSubmission {
htmlBody?: string | null htmlBody?: string | null
messageId?: string messageId?: string
attachments?: Attachment[] attachments?: Attachment[]
emailStatus?: {
welcome: EmailDeliveryState
adminNotification: EmailDeliveryState
adminReply: EmailDeliveryState
}
} }
interface Thread { interface Thread {
@@ -207,6 +220,7 @@ function buildThreads(submissions: ContactSubmission[], history: ReplyHistoryIte
type Mailbox = 'inbox' | 'starred' | 'snoozed' | 'archived' type Mailbox = 'inbox' | 'starred' | 'snoozed' | 'archived'
function EmailClient({ onLogout }: { onLogout: () => void }) { function EmailClient({ onLogout }: { onLogout: () => void }) {
const location = useLocation()
const [submissions, setSubmissions] = useState<ContactSubmission[]>([]) const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [mailbox, setMailbox] = useState<Mailbox>('inbox') const [mailbox, setMailbox] = useState<Mailbox>('inbox')
@@ -239,6 +253,9 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
// Schedule send // Schedule send
const [showSchedule, setShowSchedule] = useState(false) const [showSchedule, setShowSchedule] = useState(false)
const [scheduleCustom, setScheduleCustom] = useState('') const [scheduleCustom, setScheduleCustom] = useState('')
// AI draft
const [draftBusy, setDraftBusy] = useState(false)
const [draftMsg, setDraftMsg] = useState('')
const composeRef = useRef<HTMLTextAreaElement>(null) const composeRef = useRef<HTMLTextAreaElement>(null)
const listRef = useRef<HTMLElement>(null) const listRef = useRef<HTMLElement>(null)
@@ -279,6 +296,23 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
useEffect(() => { loadAll() }, [loadAll]) useEffect(() => { loadAll() }, [loadAll])
// Pre-filled compose from Calendar "Announce" button
useEffect(() => {
const s = location.state as { compose?: boolean; subject?: string; body?: string } | null
if (!s?.compose) return
setReplyDraft({
submissionId: null,
threadId: null,
recipientName: '',
recipientEmail: '',
subject: s.subject ?? '',
message: s.body ?? '',
fromAddress: REPLY_FROM_OPTIONS[0].value,
scheduledAt: '',
})
window.history.replaceState({}, '', window.location.pathname)
}, [location.state])
useEffect(() => { useEffect(() => {
const id = setInterval(async () => { const id = setInterval(async () => {
try { try {
@@ -486,6 +520,19 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
setTimeout(() => setActionMsg(''), 3000) setTimeout(() => setActionMsg(''), 3000)
} }
async function handleAIDraft(submissionId: string) {
setDraftBusy(true); setDraftMsg('')
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}/draft-reply`, {
method: 'POST', credentials: 'include',
})
const d = await res.json() as { ok?: boolean; draft?: string; message?: string }
if (!res.ok) { setDraftMsg(d.message ?? 'Draft failed.'); setDraftBusy(false); return }
if (d.draft) setReplyDraft(prev => prev ? { ...prev, message: d.draft! } : prev)
} catch { setDraftMsg('Network error.') }
setDraftBusy(false)
}
function openReply(submission: ContactSubmission) { function openReply(submission: ContactSubmission) {
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there' const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
const subject = extractSubject(submission.message) const subject = extractSubject(submission.message)
@@ -902,13 +949,21 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
<div className="em-conversation"> <div className="em-conversation">
{getConversationItems(selectedFullThread).map((item, i) => { {getConversationItems(selectedFullThread).map((item, i) => {
if (item.type === 'sent') { if (item.type === 'sent') {
const deliveryStatus = submissions.find(s => s.id === item.item.submissionId)?.emailStatus?.adminReply?.status
return ( return (
<div key={item.item.id} className={`em-conv-bubble em-conv-bubble--sent${i === 0 ? ' em-conv-bubble--first' : ''}`}> <div key={item.item.id} className={`em-conv-bubble em-conv-bubble--sent${i === 0 ? ' em-conv-bubble--first' : ''}`}>
<div className="em-conv-meta"> <div className="em-conv-meta">
<span className="em-conv-author">You {item.item.toEmail}</span> <span className="em-conv-author">You {item.item.toEmail}</span>
<span className="em-conv-date"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{item.item.scheduledAt ? `Scheduled: ${formatDate(item.item.scheduledAt)}` : formatDate(item.item.sentAt)} {deliveryStatus && deliveryStatus !== 'idle' && (
</span> <span className={`em-delivery-pill em-delivery-pill--${deliveryStatus}`}>
{deliveryStatus === 'clicked' ? '🔗 clicked' : deliveryStatus === 'opened' ? '👁 opened' : deliveryStatus === 'delivered' ? '✓ delivered' : deliveryStatus === 'bounced' ? '⚠ bounced' : deliveryStatus === 'sent' ? '→ sent' : deliveryStatus}
</span>
)}
<span className="em-conv-date">
{item.item.scheduledAt ? `Scheduled: ${formatDate(item.item.scheduledAt)}` : formatDate(item.item.sentAt)}
</span>
</div>
</div> </div>
<div className="em-conv-subject">{item.item.subject}</div> <div className="em-conv-subject">{item.item.subject}</div>
<pre className="em-detail-plain em-conv-body">{item.item.preview}</pre> <pre className="em-detail-plain em-conv-body">{item.item.preview}</pre>
@@ -1079,7 +1134,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
)} )}
</div> </div>
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setReplyDraft(null); setShowSchedule(false) }}>Cancel</button> <button type="button" className="em-btn em-btn--ghost" onClick={() => { setReplyDraft(null); setShowSchedule(false) }}>Cancel</button>
{replyMsg && <span className="em-status-msg em-status-msg--inline">{replyMsg}</span>} {replyDraft.submissionId && (
<button type="button" className="em-btn em-btn--ghost" onClick={() => handleAIDraft(replyDraft.submissionId!)} disabled={draftBusy}>
{draftBusy ? 'Drafting…' : '✦ AI Draft'}
</button>
)}
{(replyMsg || draftMsg) && <span className="em-status-msg em-status-msg--inline">{replyMsg || draftMsg}</span>}
</div> </div>
</div> </div>
</div> </div>