refactor(emails): refresh Pro welcome email — surface WM Analyst, Widgets, MCP (#3300)

* refactor(emails): refresh Pro welcome email — surface WM Analyst, Widgets, MCP

The Pro welcome email rendered a stale 4-card 2×2 grid (Near-Real-Time,
AI Analyst, Multi-Channel Alerts, "10 Dashboards") that missed three of
the release's signature differentiators and carried a wrong stat ("10
Dashboards" — the actual Pro surface is 50+ panels per the pricing
table). Net effect: paying users got a welcome email that undersold what
they bought and didn't point at the highest-retention action (the brief).

This commit rewrites the Pro path of userWelcomeHtml + featureCardsHtml
to the "signature-first" 2×3 grid designed via the playground at
docs/plans/pro-welcome-email-playground.html:

  WM Analyst 🤖        Create Custom Widgets 🧩
  MCP Integration 🔌   Daily AI Brief ☀️
  Multi-Channel        50+ Pro Panels 📐
  Delivery 📬

Other shifts on the Pro path:
- Headline: "Welcome to {planName}!" → "Welcome to {planName} — your
  intel, delivered." — parameterized so pro_annual/pro_monthly both read
  correctly.
- CTA: "Open Dashboard" (→ /) → "Open My Brief" (→ /brief). The brief
  is the single highest-retention action for a new Pro.
- New "Invite your team" referral block above the CTA.
- New support contact line under the CTA, pointing at ADMIN_EMAIL so
  replies route correctly.

API-plan path (api_starter/api_starter_annual/api_business/enterprise)
preserved byte-for-byte — same 4 cards, same "Welcome to {planName}!"
headline, same "Open Dashboard" CTA, no referral, no support line. A
follow-up will refresh that variant with its own MCP / Webhooks / API
lead after this ships.

Size: 6.4 KB rendered (was ~4 KB; Gmail clips at ~102 KB).

Playground (for future refreshes): docs/plans/pro-welcome-email-playground.html

* fix(emails): allowlist Pro plans + drop referral block pending Phase 9

Addresses two P1 review comments from @greptile-apps on #3300.

1. `isPro` was a deny-list (`!API_PLANS.has(planKey)`) — every plan key
   outside API_PLANS fell into the Pro branch, including `free` (already
   in PLAN_DISPLAY) and any future tier (e.g. `pro_team`) added to
   PLAN_DISPLAY without a matching API_PLANS update. Switched to an
   explicit allowlist: `PRO_PLANS = Set(['pro_monthly','pro_annual'])`.
   Anything outside falls back to the neutral "Welcome to {planName}!"
   shell + "Open Dashboard" CTA.

2. The "Invite your team — earn a month free" referral block linked to
   `https://worldmonitor.app/referrals`, but that page isn't mounted and
   `src/services/referral.ts` is still flagged "Phase 9 / Todo #223".
   Shipping the block today would send paying users to a 404 and promise
   a credit the backend can't grant. Removed the block entirely;
   reinstate in a follow-up PR once the referral endpoint + credit logic
   ship.

Playground (`docs/plans/pro-welcome-email-playground.html`) remains the
source of truth for future refreshes.

* fix(emails): align card gate with shell gate + wire reply_to for support line

Addresses two more review findings on #3300.

1. `featureCardsHtml()` still branched on `API_PLANS.has(planKey)`, so
   every non-API plan (including `free` and any future tier added to
   PLAN_DISPLAY without a matching PRO_PLANS update) got the 6-card Pro
   marketing grid even though the shell gate (`userWelcomeHtml`) now
   allowlists Pro correctly. Result: `free` or unknown-tier users saw
   "Welcome to Free!" + "Open Dashboard" but still received "WM Analyst",
   "Create Custom Widgets", "MCP Integration", etc. card content.

   Fixed by parallel-allowlisting on `!PRO_PLANS.has(planKey)` — falls
   through to the 4-card generic grid (same cards the API variant shows)
   for anyone who isn't on pro_monthly / pro_annual. The generic grid is
   safe for unknown tiers: no Pro-only claims, no specific promises.

2. The Pro support line reads "Questions? Reply to this email or ping
   elie@worldmonitor.app" but FROM is `noreply@worldmonitor.app` and the
   Resend payload omitted reply_to — replies would bounce.

   Added an optional `replyTo` param to `sendEmail()` and thread
   ADMIN_EMAIL through on the user welcome call. Gmail and every other
   major client honour Reply-To over From when both are present.

   The admin notification intentionally passes no replyTo to avoid
   routing replies back to ADMIN_EMAIL (self-loop).
This commit is contained in:
Elie Habib
2026-04-22 23:18:32 +04:00
committed by GitHub
parent b8615dd106
commit 3e7bf49393

View File

@@ -22,21 +22,33 @@ const PLAN_DISPLAY: Record<string, string> = {
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<void> {
// 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<string, unknown> = { 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 `
<tr>
<td style="width: 50%; padding: 12px; vertical-align: top;">
@@ -82,43 +98,75 @@ function featureCardsHtml(planKey: string): string {
</td>
</tr>`;
}
// 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 `
<tr>
<td style="width: 50%; padding: 12px; vertical-align: top;">
<div style="background: #111; border: 1px solid #1a1a1a; padding: 16px; height: 100%;">
<div style="font-size: 20px; margin-bottom: 8px;">&#9889;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">Near-Real-Time Data</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Priority pipeline with sub-60s refresh</div>
<div style="font-size: 20px; margin-bottom: 8px;">&#129302;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">WM Analyst</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Chat with your monitor. Ask anything, get cited answers.</div>
</div>
</td>
<td style="width: 50%; padding: 12px; vertical-align: top;">
<div style="background: #111; border: 1px solid #1a1a1a; padding: 16px; height: 100%;">
<div style="font-size: 20px; margin-bottom: 8px;">&#129504;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">AI Analyst</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Morning briefs, flash alerts, pattern detection</div>
<div style="font-size: 20px; margin-bottom: 8px;">&#129513;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">Create Custom Widgets</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Describe a widget in plain English &mdash; AI builds it live.</div>
</div>
</td>
</tr>
<tr>
<td style="width: 50%; padding: 12px; vertical-align: top;">
<div style="background: #111; border: 1px solid #1a1a1a; padding: 16px; height: 100%;">
<div style="font-size: 20px; margin-bottom: 8px;">&#128232;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">Multi-Channel Alerts</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Slack, Telegram, WhatsApp, Email, Discord</div>
<div style="font-size: 20px; margin-bottom: 8px;">&#128268;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">MCP Integration</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Connect Claude Desktop, Cursor, or any MCP client to your monitor.</div>
</div>
</td>
<td style="width: 50%; padding: 12px; vertical-align: top;">
<div style="background: #111; border: 1px solid #1a1a1a; padding: 16px; height: 100%;">
<div style="font-size: 20px; margin-bottom: 8px;">&#128202;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">10 Dashboards</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Custom layouts with CSV + PDF export</div>
<div style="font-size: 20px; margin-bottom: 8px;">&#9728;&#65039;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">Daily AI Brief</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Your morning intel, topic-grouped, before your coffee.</div>
</div>
</td>
</tr>
<tr>
<td style="width: 50%; padding: 12px; vertical-align: top;">
<div style="background: #111; border: 1px solid #1a1a1a; padding: 16px; height: 100%;">
<div style="font-size: 20px; margin-bottom: 8px;">&#128236;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">Multi-Channel Delivery</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">Slack, Telegram, WhatsApp, Email, Discord.</div>
</div>
</td>
<td style="width: 50%; padding: 12px; vertical-align: top;">
<div style="background: #111; border: 1px solid #1a1a1a; padding: 16px; height: 100%;">
<div style="font-size: 20px; margin-bottom: 8px;">&#128208;</div>
<div style="font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 4px;">50+ Pro Panels</div>
<div style="font-size: 12px; color: #888; line-height: 1.4;">50+ panels across markets, geopolitics, supply chain, climate.</div>
</div>
</td>
</tr>`;
}
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
? `<p style="font-size: 11px; color: #666; text-align: center; margin: 0 0 20px;">Questions? Reply to this email or ping <a href="mailto:${ADMIN_EMAIL}" style="color: #4ade80;">${ADMIN_EMAIL}</a>.</p>`
: "";
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto; background: #0a0a0a; color: #e0e0e0;">
<div style="background: #4ade80; height: 4px;"></div>
@@ -135,7 +183,7 @@ function userWelcomeHtml(planName: string, planKey: string): string {
</table>
<div style="background: #111; border: 1px solid #1a1a1a; border-left: 3px solid #4ade80; padding: 20px 24px; margin-bottom: 28px;">
<p style="font-size: 18px; font-weight: 600; color: #fff; margin: 0 0 8px;">Welcome to ${planName}!</p>
<p style="font-size: 18px; font-weight: 600; color: #fff; margin: 0 0 8px;">${headline}</p>
<p style="font-size: 14px; color: #999; margin: 0; line-height: 1.5;">Your subscription is now active. Here's what's unlocked:</p>
</div>
@@ -143,9 +191,10 @@ function userWelcomeHtml(planName: string, planKey: string): string {
${featureCardsHtml(planKey)}
</table>
<div style="text-align: center; margin-bottom: 36px;">
<a href="https://worldmonitor.app" style="display: inline-block; background: #4ade80; color: #0a0a0a; padding: 14px 36px; text-decoration: none; font-weight: 800; font-size: 13px; text-transform: uppercase; letter-spacing: 1.5px; border-radius: 2px;">Open Dashboard</a>
<div style="text-align: center; margin-bottom: 28px;">
<a href="${ctaHref}" style="display: inline-block; background: #4ade80; color: #0a0a0a; padding: 14px 36px; text-decoration: none; font-weight: 800; font-size: 13px; text-transform: uppercase; letter-spacing: 1.5px; border-radius: 2px;">${ctaLabel}</a>
</div>
${supportLine}
</div>
<div style="border-top: 1px solid #1a1a1a; padding: 24px 32px; text-align: center;">
@@ -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}`);