diff --git a/apps/website/migrations/0003_activation_and_emails.sql b/apps/website/migrations/0003_activation_and_emails.sql new file mode 100644 index 00000000..5511df39 --- /dev/null +++ b/apps/website/migrations/0003_activation_and_emails.sql @@ -0,0 +1,13 @@ +-- Activation: the app checks its key at start and every 6 hours (user agent GodmodeBot/). +ALTER TABLE orders ADD COLUMN first_seen_at INTEGER; +ALTER TABLE orders ADD COLUMN last_seen_at INTEGER; +ALTER TABLE orders ADD COLUMN app_version TEXT; + +-- Lifecycle emails, at most one of each kind per order. +CREATE TABLE emails ( + session_id TEXT NOT NULL, + kind TEXT NOT NULL, + sent_at INTEGER NOT NULL, + message_id TEXT, + PRIMARY KEY (session_id, kind) +); diff --git a/apps/website/src/env.d.ts b/apps/website/src/env.d.ts index 540c3002..5a4608af 100644 --- a/apps/website/src/env.d.ts +++ b/apps/website/src/env.d.ts @@ -22,6 +22,11 @@ declare namespace Cloudflare { ADMIN_PASSWORD: string; SESSION_SECRET: string; GITHUB_TOKEN?: string; + EMAIL?: SendEmail; + LIFECYCLE_EMAILS?: string; + MAIL_FROM?: string; + MAIL_FROM_NAME?: string; + MAIL_REPLY_TO?: string; STRIPE_AUTOMATIC_TAX?: string; STRIPE_MANAGED_PAYMENTS?: string; STRIPE_REQUIRE_TOS?: string; diff --git a/apps/website/src/lib/lifecycle.ts b/apps/website/src/lib/lifecycle.ts new file mode 100644 index 00000000..340dfadf --- /dev/null +++ b/apps/website/src/lib/lifecycle.ts @@ -0,0 +1,109 @@ +import { FOUNDING, PRICING } from '@/config/site'; + +export type EmailKind = 'welcome' | 'day3' | 'trial_ending'; + +export type LifecycleOrder = { + session_id: string; + plan: string; + email: string; + name: string | null; + license_key: string; + amount_total: number | null; + currency: string | null; + customer_id: string | null; + trial_end: number | null; + first_seen_at: number | null; +}; + +export type Composed = { subject: string; text: string; html: string }; + +const DAY = 86_400_000; + +/** Lifecycle emails an order is due for now, most urgent first. Each kind goes out once (the caller records it). */ +export function dueKinds(o: LifecycleOrder & { created_at: number; sub_status: string | null; cancel_at_period_end: number | null }, now: number): EmailKind[] { + const kinds: EmailKind[] = []; + const trialing = o.sub_status === 'trialing'; + if (now - o.created_at < 14 * DAY) kinds.push('welcome'); + if (trialing && !o.cancel_at_period_end && o.trial_end && o.trial_end > now && o.trial_end - now <= 3 * DAY) kinds.push('trial_ending'); + if (trialing && now - o.created_at >= 3 * DAY && o.trial_end && o.trial_end - now > 3 * DAY) kinds.push('day3'); + return kinds; +} + +const COMPANY = /\b(gmbh|ug|ag|kg|ohg|ltd|limited|inc|llc|corp|co|company|trade|shop|store|team|info)\b/i; +const firstName = (name: string | null) => { + const first = name?.trim().split(/\s+/)[0] ?? ''; + return /^[\p{L}'-]{2,30}$/u.test(first) && !COMPANY.test(name ?? '') ? first : ''; +}; + +const longDate = (ms: number) => + new Date(ms).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' }); + +function price(o: LifecycleOrder) { + const plan = o.plan === 'yearly' ? PRICING.plans.yearly : PRICING.plans.monthly; + return `$${plan.price}/${plan.interval}`; +} + +const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!); + +/** Plain text is the source; the HTML part is the same words with links, no images or tracking. */ +function render(subject: string, paragraphs: string[]): Composed { + const text = paragraphs.join('\n\n'); + const linkify = (p: string) => esc(p).replace(/https:\/\/[^\s<]+/g, (u) => `${u}`); + const html = `
${paragraphs + .map((p) => `

${linkify(p)}

`) + .join('')}
`; + return { subject, text, html }; +} + +export function compose(kind: EmailKind, o: LifecycleOrder, base: string): Composed { + const hi = firstName(o.name) ? `Hi ${firstName(o.name)},` : 'Hi,'; + const download = `${base}/download?key=${o.license_key}`; + const manage = o.customer_id ? `${base}/api/portal?key=${o.license_key}` : ''; + const sign = 'Daniel\nFounder, Godmode'; + const trialEnd = o.trial_end ? longDate(o.trial_end) : ''; + + if (kind === 'welcome') { + return render('Your Godmode license key', [ + hi, + trialEnd ? 'thanks for starting your Godmode trial. Here is everything you need in one place.' : 'thanks for getting Godmode. Here is everything you need in one place.', + `Your license key: ${o.license_key}`, + `Download for Mac, Windows or Linux (your key is already filled in):\n${download}`, + 'Getting started takes about five minutes:\n1. Install Godmode and open it. Onboarding sets up Claude Code for you.\n2. Sign in with your Claude Pro or Max plan, or paste an Anthropic API key.\n3. Hand over your first task in one sentence, for example "Log into my Amazon account and download all invoices from this year into a folder."', + `As a founding member you also get a ${FOUNDING.setupMinutes}-minute setup call with me. Reply with two times that work for you and the three things you want Godmode to take off your plate, and we set them up together.`, + ...(trialEnd ? [`Your trial runs until ${trialEnd}. You can cancel any time${manage ? `, right here:\n${manage}` : ' by replying to this email.'}`] : []), + 'If anything gets stuck, just reply. This comes straight to me.', + sign, + ]); + } + + if (kind === 'day3') { + if (!o.first_seen_at) { + return render('Did Godmode install OK?', [ + hi, + 'I noticed your Godmode key has not been activated yet, so I wanted to check if something got in the way.', + `Here is your download again, with the key filled in. Open it on your Mac or PC, not on your phone:\n${download}`, + `Your key, in case you need to paste it: ${o.license_key}`, + 'If the install stopped somewhere, reply with a screenshot or one line about where, and I will help you get it running. Or send me two times for the setup call and we do it together in 20 minutes.', + sign, + ]); + } + return render('3 things to try with Godmode this week', [ + hi, + 'good to see Godmode running on your computer. Three good first tasks, each one message:', + '1. "Every weekday at 8, go through my inbox and send me a short list of what needs an answer today."\n2. "Log into my supplier portals and download this month\'s invoices into one folder."\n3. "Find 20 companies that match my ideal customer, with website and contact, and put them in a spreadsheet."', + 'Anything you do in a browser more than once a week is a good candidate. Ask Godmode to turn it into an agent with a schedule and it keeps doing it for you.', + 'Tell me what you would like to automate first and I will send you the exact message to give it. The setup call is still open too, just reply with two times.', + sign, + ]); + } + + return render(`Your Godmode trial ends on ${trialEnd}`, [ + hi, + `a quick heads-up: your free trial ends on ${trialEnd}. After that your card is charged ${price(o)}, the founding price, locked for as long as you stay.`, + 'Nothing to do if you want to keep going.', + `If Godmode is not for you, cancel before ${trialEnd} and you will not be charged${manage ? `:\n${manage}` : ' (just reply to this email).'}`, + `And if you stay but it does not pay off, the ${PRICING.guaranteeDays}-day money-back guarantee still applies after the first charge.`, + 'If something is not working the way you hoped, reply and tell me. I read every answer.', + sign, + ]); +} diff --git a/apps/website/src/lib/mail.ts b/apps/website/src/lib/mail.ts new file mode 100644 index 00000000..496e643d --- /dev/null +++ b/apps/website/src/lib/mail.ts @@ -0,0 +1,78 @@ +import { env } from 'cloudflare:workers'; +import { SITE } from '@/config/site'; +import { db } from './db'; +import { minLivemode } from './entitlement'; +import { compose, dueKinds, type EmailKind, type LifecycleOrder } from './lifecycle'; + +const DAY = 86_400_000; + +/** Off unless LIFECYCLE_EMAILS=on: until then due emails are only logged, nothing is recorded or sent. */ +const enabled = () => env.LIFECYCLE_EMAILS === 'on' && Boolean(env.EMAIL); + +type Candidate = LifecycleOrder & { + created_at: number; + sub_status: string | null; + cancel_at_period_end: number | null; + last_email_at: number | null; +}; + +const SELECT = `SELECT o.session_id, o.created_at, o.plan, o.email, o.name, o.license_key, o.amount_total, o.currency, + o.customer_id, o.trial_end, o.first_seen_at, o.sub_status, o.cancel_at_period_end, + (SELECT MAX(sent_at) FROM emails e WHERE e.session_id = o.session_id) AS last_email_at + FROM orders o + WHERE o.status = 'paid' AND COALESCE(o.comp, 0) = 0 AND o.email IS NOT NULL AND o.license_key IS NOT NULL + AND o.livemode >= ?`; + +async function send(kind: EmailKind, o: Candidate): Promise { + const claim = await db() + .prepare(`INSERT OR IGNORE INTO emails (session_id, kind, sent_at) VALUES (?, ?, ?)`) + .bind(o.session_id, kind, Date.now()) + .run(); + if (!claim.meta.changes) return false; + const mail = compose(kind, o, SITE.url.replace(/\/$/, '')); + try { + const res = await env.EMAIL!.send({ + from: { name: env.MAIL_FROM_NAME ?? 'Daniel from Godmode', email: env.MAIL_FROM ?? 'daniel@usegodmode.com' }, + replyTo: env.MAIL_REPLY_TO ?? SITE.email, + to: o.email, + subject: mail.subject, + text: mail.text, + html: mail.html, + }); + await db().prepare(`UPDATE emails SET message_id = ? WHERE session_id = ? AND kind = ?`).bind(res.messageId, o.session_id, kind).run(); + return true; + } catch (e) { + console.error('lifecycle email failed', kind, o.session_id, e); + await db().prepare(`DELETE FROM emails WHERE session_id = ? AND kind = ?`).bind(o.session_id, kind).run(); + return false; + } +} + +/** One email per order per day at most, the most urgent due kind first. */ +async function deliver(o: Candidate, now: number) { + if (o.last_email_at && now - o.last_email_at < DAY) return; + const sent = await db().prepare(`SELECT kind FROM emails WHERE session_id = ?`).bind(o.session_id).all<{ kind: string }>(); + const done = new Set((sent.results ?? []).map((r) => r.kind)); + const kind = dueKinds(o, now).find((k) => !done.has(k)); + if (!kind) return; + if (!enabled()) { + console.log('lifecycle email due (sending is off)', kind, o.session_id); + return; + } + await send(kind, o); +} + +/** Right after checkout: the license key by email, so it isn't lost with the welcome page. */ +export async function sendWelcome(sessionId: string) { + const o = await db().prepare(`${SELECT} AND o.session_id = ?`).bind(minLivemode(), sessionId).first(); + if (o) await deliver(o, Date.now()); +} + +/** Hourly cron: welcome backfill, day-3 getting started, and the reminder three days before a trial ends. */ +export async function runLifecycle(now = Date.now()) { + const rows = await db() + .prepare(`${SELECT} AND o.created_at >= ? ORDER BY o.created_at`) + .bind(minLivemode(), now - 30 * DAY) + .all(); + for (const o of rows.results ?? []) await deliver(o, now); +} diff --git a/apps/website/src/pages/admin/index.astro b/apps/website/src/pages/admin/index.astro index 9d6b1ec3..086cf48b 100644 --- a/apps/website/src/pages/admin/index.astro +++ b/apps/website/src/pages/admin/index.astro @@ -100,7 +100,9 @@ const [visitorsRow, stepRows, orderRows, allTime, daily, dailyRev, sources, sour ), all( `SELECT o.created_at, o.plan, o.status, o.sub_status, o.trial_end, o.comp, o.amount_total, o.currency, o.email, - o.country, o.utm_source, o.utm_campaign, o.twclid, ${REPEAT_TRIAL} AS repeat_trial + o.country, o.utm_source, o.utm_campaign, o.twclid, ${REPEAT_TRIAL} AS repeat_trial, + o.first_seen_at, o.last_seen_at, o.app_version, + (SELECT group_concat(kind, ' · ') FROM emails e WHERE e.session_id = o.session_id) AS emails FROM orders o WHERE o.livemode = ? AND o.status NOT IN ('open') ORDER BY o.created_at DESC LIMIT 25`, liveFlag, ), @@ -188,6 +190,10 @@ const sourceRows = sources.map((s) => { const money = (v: number, cur = 'usd') => v.toLocaleString('en-US', { style: 'currency', currency: cur.toUpperCase(), maximumFractionDigits: 0 }); const date = (ms: unknown) => new Date(n(ms)).toISOString().slice(0, 16).replace('T', ' '); +const ago = (ms: number) => { + const m = Math.max(0, Math.round((now - ms) / 60000)); + return m < 60 ? `${m}m ago` : m < 2880 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`; +}; const q = (days: number, mode = live ? 'live' : 'test') => `?days=${days}&mode=${mode}`; --- @@ -357,9 +363,9 @@ const q = (days: number, mode = live ? 'live' : 'test') => `?days=${days}&mode=$ Leads CSV - +
- + {recent.map((o) => ( @@ -374,6 +380,10 @@ const q = (days: number, mode = live ? 'live' : 'test') => `?days=${days}&mode=$ + + ))} {!recent.length && } diff --git a/apps/website/src/pages/api/license.ts b/apps/website/src/pages/api/license.ts index 8a787d6c..8a378cc8 100644 --- a/apps/website/src/pages/api/license.ts +++ b/apps/website/src/pages/api/license.ts @@ -52,8 +52,22 @@ function statusOf(row: Row): Status { return 'expired'; } +/** Activation for /admin and the lifecycle emails: only the app's own checks count, at most one write an hour. */ +async function markSeen(key: string, userAgent: string | null) { + const version = userAgent?.match(/^GodmodeBot\/([\w.+-]{1,40})/)?.[1]; + if (!version) return; + const now = Date.now(); + await db() + .prepare( + `UPDATE orders SET first_seen_at = COALESCE(first_seen_at, ?1), last_seen_at = ?1, app_version = ?2 + WHERE license_key = ?3 AND (last_seen_at IS NULL OR last_seen_at < ?4 OR app_version IS NOT ?2)`, + ) + .bind(now, version, key, now - 3_600_000) + .run(); +} + /** For the desktop app: GET /api/license?key=GM-… (response shape in the pricing contract, v2). */ -export const GET: APIRoute = async ({ url }) => { +export const GET: APIRoute = async ({ url, request }) => { const key = url.searchParams.get('key')?.trim().toUpperCase(); if (!isLicenseKey(key)) return Response.json({ valid: false, reason: 'malformed' }, { status: 400, headers }); const row = await db() @@ -64,6 +78,7 @@ export const GET: APIRoute = async ({ url }) => { .bind(key, minLivemode()) .first(); if (!row) return Response.json({ valid: false, reason: 'unknown' }, { status: 404, headers }); + await markSeen(key, request.headers.get('user-agent')).catch((e) => console.error('license seen update failed', e)); const status = statusOf(row); const valid = status === 'trialing' || status === 'active' || status === 'past_due'; diff --git a/apps/website/src/pages/api/stripe-webhook.ts b/apps/website/src/pages/api/stripe-webhook.ts index 9eabb835..49d8d88f 100644 --- a/apps/website/src/pages/api/stripe-webhook.ts +++ b/apps/website/src/pages/api/stripe-webhook.ts @@ -3,6 +3,7 @@ import type Stripe from 'stripe'; import { env } from 'cloudflare:workers'; import { cryptoProvider, stripe } from '@/lib/stripe'; import { db, revokeForCharge, syncSubscription, upsertFromSession } from '@/lib/db'; +import { sendWelcome } from '@/lib/mail'; export const prerender = false; @@ -63,6 +64,7 @@ async function handle(event: Stripe.Event) { // Trials complete without a payment; store the trial end right away instead of waiting for an update event. const subId = typeof s.subscription === 'string' ? s.subscription : s.subscription?.id; if (s.mode === 'subscription' && subId) await syncSubscription(await stripe().subscriptions.retrieve(subId)); + if (paid) await sendWelcome(s.id).catch((e) => console.error('welcome email failed', e)); break; } case 'checkout.session.async_payment_succeeded': diff --git a/apps/website/src/worker.ts b/apps/website/src/worker.ts new file mode 100644 index 00000000..61f6e109 --- /dev/null +++ b/apps/website/src/worker.ts @@ -0,0 +1,9 @@ +import { handle } from '@astrojs/cloudflare/handler'; +import { runLifecycle } from '@/lib/mail'; + +export default { + fetch: handle, + async scheduled(_controller, _env, ctx) { + ctx.waitUntil(runLifecycle().catch((e) => console.error('lifecycle run failed', e))); + }, +} satisfies ExportedHandler; diff --git a/apps/website/wrangler.jsonc b/apps/website/wrangler.jsonc index 0c65ac72..d240430f 100644 --- a/apps/website/wrangler.jsonc +++ b/apps/website/wrangler.jsonc @@ -4,9 +4,12 @@ "account_id": "a511f1dbd91f9fb01269b6be0af25ca9", "compatibility_date": "2026-09-01", "compatibility_flags": ["nodejs_compat"], - "main": "@astrojs/cloudflare/entrypoints/server", + "main": "./src/worker.ts", "assets": { "binding": "ASSETS", "directory": "./dist" }, "observability": { "enabled": true }, + // Hourly: lifecycle emails to buyers and trials (src/lib/mail.ts), sent only with LIFECYCLE_EMAILS = "on". + "triggers": { "crons": ["17 * * * *"] }, + "send_email": [{ "name": "EMAIL", "remote": true }], "routes": [ { "pattern": "usegodmode.com", "custom_domain": true }, { "pattern": "www.usegodmode.com", "custom_domain": true }, @@ -30,7 +33,11 @@ "STRIPE_AUTOMATIC_TAX": "false", "STRIPE_MANAGED_PAYMENTS": "false", "STRIPE_REQUIRE_TOS": "false", - "STRIPE_COLLECT_PROMOTIONS": "false" + "STRIPE_COLLECT_PROMOTIONS": "false", + // Needs usegodmode.com onboarded in Cloudflare Email Sending (MAIL_FROM's domain). + "LIFECYCLE_EMAILS": "off", + "MAIL_FROM": "daniel@usegodmode.com", + "MAIL_REPLY_TO": "daniel.ehrhardt@codext.de" } // Secrets (wrangler secret put …): STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, ADMIN_PASSWORD, SESSION_SECRET, // GITHUB_TOKEN (fine-grained, read-only Contents on codextde/godmode-bot — serves downloads and updates)
Date (UTC)PlanStatusAmountEmailCountrySource
Date (UTC)PlanStatusAmountEmailCountrySourceAppEmails
{o.email ?? '—'} {o.country ?? '—'} {o.utm_source ?? (o.twclid ? 'x (click id)' : '—')}{o.utm_campaign ? ` · ${o.utm_campaign}` : ''} + {o.last_seen_at ? {ago(n(o.last_seen_at))} · v{o.app_version} : not activated} + {o.emails ?? '—'}
No orders yet.