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
+44
View File
@@ -3,6 +3,11 @@ import { Link } from 'react-router-dom'
// ── Types ────────────────────────────────────────────────────────────────────
interface EmailDeliveryState {
status: string
lastEventAt: string | null
}
interface ContactSubmission {
id: string
submittedAt: string
@@ -17,6 +22,11 @@ interface ContactSubmission {
inboundTo?: string
notes?: string
tags?: string[]
emailStatus?: {
welcome: EmailDeliveryState
adminNotification: EmailDeliveryState
adminReply: EmailDeliveryState
}
}
interface ReplyHistoryItem {
@@ -48,6 +58,9 @@ interface Contact {
mainId: string
allIds: string[]
allSubmissions: ContactSubmission[]
bestDeliveryStatus: string | null // opened/clicked/delivered/sent/null
unreplied: boolean // has inbound messages, no reply sent
engagementScore: number
}
type ConversationItem =
@@ -298,6 +311,27 @@ function ContactsClient() {
const emailKey = latest.email?.trim().toLowerCase() || latest.id
// Tags: prefer entry that has tags, falling back to mainId submission
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 {
key: emailKey,
email: latest.email ?? '',
@@ -315,6 +349,9 @@ function ContactsClient() {
mainId: latest.id,
allIds: sorted.map(s => s.id),
allSubmissions: sorted,
bestDeliveryStatus,
unreplied,
engagementScore,
}
})
.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>}
{sourceBadge(c.source)}
{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>}
</div>