mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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;">⚡</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;">🤖</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;">🧠</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;">🧩</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 — 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;">📨</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;">🔌</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;">📊</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;">☀️</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;">📬</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;">📐</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}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user