Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/website/migrations/0003_activation_and_emails.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Activation: the app checks its key at start and every 6 hours (user agent GodmodeBot/<version>).
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)
);
5 changes: 5 additions & 0 deletions apps/website/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions apps/website/src/lib/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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) => `<a href="${u}" style="color:#0a7f5a">${u}</a>`);
const html = `<!doctype html><html><body style="margin:0;padding:24px;background:#fff;color:#111;font:15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"><div style="max-width:560px">${paragraphs
.map((p) => `<p style="margin:0 0 16px;white-space:pre-line">${linkify(p)}</p>`)
.join('')}</div></body></html>`;
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,
]);
}
78 changes: 78 additions & 0 deletions apps/website/src/lib/mail.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<Candidate>();
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<Candidate>();
for (const o of rows.results ?? []) await deliver(o, now);
}
16 changes: 13 additions & 3 deletions apps/website/src/pages/admin/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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}`;
---

Expand Down Expand Up @@ -357,9 +363,9 @@ const q = (days: number, mode = live ? 'live' : 'test') => `?days=${days}&mode=$
<a href="/admin/export.csv?what=leads" class="btn btn-ghost btn-sm">Leads CSV</a>
</div>
</div>
<table class="mt-4 w-full min-w-[760px] text-[13px]">
<table class="mt-4 w-full min-w-[980px] text-[13px]">
<thead class="text-left text-[12px] text-dim">
<tr><th class="py-2 font-normal">Date (UTC)</th><th class="font-normal">Plan</th><th class="font-normal">Status</th><th class="text-right font-normal">Amount</th><th class="font-normal pl-4">Email</th><th class="font-normal">Country</th><th class="font-normal">Source</th></tr>
<tr><th class="py-2 font-normal">Date (UTC)</th><th class="font-normal">Plan</th><th class="font-normal">Status</th><th class="text-right font-normal">Amount</th><th class="font-normal pl-4">Email</th><th class="font-normal">Country</th><th class="font-normal">Source</th><th class="font-normal">App</th><th class="font-normal">Emails</th></tr>
</thead>
<tbody>
{recent.map((o) => (
Expand All @@ -374,6 +380,10 @@ const q = (days: number, mode = live ? 'live' : 'test') => `?days=${days}&mode=$
<td class="pl-4 text-text-2">{o.email ?? '—'}</td>
<td class="text-muted">{o.country ?? '—'}</td>
<td class="text-muted">{o.utm_source ?? (o.twclid ? 'x (click id)' : '—')}{o.utm_campaign ? ` · ${o.utm_campaign}` : ''}</td>
<td class="whitespace-nowrap" title={o.first_seen_at ? `first ${date(o.first_seen_at)}` : undefined}>
{o.last_seen_at ? <span class="chip chip-brand">{ago(n(o.last_seen_at))} · v{o.app_version}</span> : <span class="text-dim">not activated</span>}
</td>
<td class="whitespace-nowrap text-muted">{o.emails ?? '—'}</td>
</tr>
))}
{!recent.length && <tr><td class="py-2 text-dim">No orders yet.</td></tr>}
Expand Down
17 changes: 16 additions & 1 deletion apps/website/src/pages/api/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -64,6 +78,7 @@ export const GET: APIRoute = async ({ url }) => {
.bind(key, minLivemode())
.first<Row>();
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';
Expand Down
2 changes: 2 additions & 0 deletions apps/website/src/pages/api/stripe-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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':
Expand Down
9 changes: 9 additions & 0 deletions apps/website/src/worker.ts
Original file line number Diff line number Diff line change
@@ -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<Cloudflare.Env>;
11 changes: 9 additions & 2 deletions apps/website/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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)
Expand Down
Loading