diff --git a/convex/payments/subscriptionEmails.ts b/convex/payments/subscriptionEmails.ts index 31c95b204..315e1e08b 100644 --- a/convex/payments/subscriptionEmails.ts +++ b/convex/payments/subscriptionEmails.ts @@ -22,21 +22,33 @@ const PLAN_DISPLAY: Record = { enterprise: "Enterprise", }; -const API_PLANS = new Set(["api_starter", "api_starter_annual", "api_business", "enterprise"]); +// Allowlist for the Pro welcome shell. Anything outside this set (free, api_*, +// future tiers) falls back to the neutral "Welcome to {planName}!" shell + +// 4-card generic grid — safer than a deny-list that would silently opt-in +// every new plan key added to PLAN_DISPLAY without a matching update here. +// See `featureCardsHtml` and `userWelcomeHtml` for the parallel gates. +const PRO_PLANS = new Set(["pro_monthly", "pro_annual"]); async function sendEmail( apiKey: string, to: string, subject: string, html: string, + replyTo?: string, ): Promise { + // FROM is a noreply address, so the welcome email's "Reply to this email" + // support copy only routes correctly when we explicitly set reply_to on the + // Resend payload. Admin notifications pass no replyTo so replies don't + // self-loop back to ADMIN_EMAIL. + const payload: Record = { from: FROM, to: [to], subject, html }; + if (replyTo) payload.reply_to = replyTo; const res = await fetch(RESEND_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ from: FROM, to: [to], subject, html }), + body: JSON.stringify(payload), }); if (!res.ok) { const body = await res.text(); @@ -47,7 +59,11 @@ async function sendEmail( } function featureCardsHtml(planKey: string): string { - if (API_PLANS.has(planKey)) { + // Pro allowlist must match the shell gate in userWelcomeHtml — otherwise a + // `free` or unknown-tier user gets the neutral headline + "Open Dashboard" + // CTA but still sees the 6-card Pro marketing grid below. API + unknown + // tiers fall through to the 4-card generic grid (safe: no Pro-only claims). + if (!PRO_PLANS.has(planKey)) { return ` @@ -82,43 +98,75 @@ function featureCardsHtml(planKey: string): string { `; } - // Pro plans: no API access + // Pro plans: signature-first grid — leads with WM Analyst, Custom Widgets, MCP + // (the three differentiators the old email buried), followed by Brief + + // Delivery + 50+ Panels. Source of truth: docs/plans/pro-welcome-email-playground.html. return `
-
-
Near-Real-Time Data
-
Priority pipeline with sub-60s refresh
+
🤖
+
WM Analyst
+
Chat with your monitor. Ask anything, get cited answers.
-
🧠
-
AI Analyst
-
Morning briefs, flash alerts, pattern detection
+
🧩
+
Create Custom Widgets
+
Describe a widget in plain English — AI builds it live.
-
📨
-
Multi-Channel Alerts
-
Slack, Telegram, WhatsApp, Email, Discord
+
🔌
+
MCP Integration
+
Connect Claude Desktop, Cursor, or any MCP client to your monitor.
-
📊
-
10 Dashboards
-
Custom layouts with CSV + PDF export
+
☀️
+
Daily AI Brief
+
Your morning intel, topic-grouped, before your coffee.
+
+ + + + +
+
📬
+
Multi-Channel Delivery
+
Slack, Telegram, WhatsApp, Email, Discord.
+
+ + +
+
📐
+
50+ Pro Panels
+
50+ panels across markets, geopolitics, supply chain, climate.
`; } function userWelcomeHtml(planName: string, planKey: string): string { + const isPro = PRO_PLANS.has(planKey); + // Pro path: headline leads with the value prop, CTA points at the brief + // (the single highest-retention action for a new Pro). API path preserved + // byte-for-byte from the previous template pending a separate refresh. + // Referral block deliberately omitted — the /referrals page + credit-granting + // logic are still Phase 9 (Todo #223). Reinstate in a follow-up once live. + const headline = isPro + ? `Welcome to ${planName} — your intel, delivered.` + : `Welcome to ${planName}!`; + const ctaLabel = isPro ? "Open My Brief" : "Open Dashboard"; + const ctaHref = isPro ? "https://worldmonitor.app/brief" : "https://worldmonitor.app"; + const supportLine = isPro + ? `

Questions? Reply to this email or ping ${ADMIN_EMAIL}.

` + : ""; return `
@@ -135,7 +183,7 @@ function userWelcomeHtml(planName: string, planKey: string): string {
-

Welcome to ${planName}!

+

${headline}

Your subscription is now active. Here's what's unlocked:

@@ -143,9 +191,10 @@ function userWelcomeHtml(planName: string, planKey: string): string { ${featureCardsHtml(planKey)} -
- Open Dashboard + + ${supportLine}
@@ -181,12 +230,15 @@ export const sendSubscriptionEmails = internalAction({ const planName = PLAN_DISPLAY[args.planKey] ?? args.planKey; - // 1. Welcome email to user + // 1. Welcome email to user. reply_to routes "Reply to this email" (in the + // Pro support line) to ADMIN_EMAIL — FROM is noreply@ and Gmail honours + // Reply-To over From when both are present. await sendEmail( apiKey, args.userEmail, `Welcome to World Monitor ${planName}`, userWelcomeHtml(planName, args.planKey), + ADMIN_EMAIL, ); console.log(`[subscriptionEmails] Welcome email sent to ${args.userEmail}`);