forked from Mike/freno-dev
nook: capture trial email and run the two lifecycle emails
Trials were anonymous — a 14-day trial you cannot talk to. trials gains an email column (idempotent ALTER), POST /api/the-nook/trial-email attaches the optional first-launch prompt's answer, and the address lands in a Brevo list when NOOK_TRIAL_LIST_ID is set. The lifecycle emails follow: a D+11 trial expiry reminder with a buy link, a purchase thank-you from the Stripe webhook, and scheduled testimonial (D+3) and review (D+14) asks via the daily /api/the-nook/lifecycle cron. Rows are marked before sending so retries never double-send; trial addresses that already bought get no buy reminder; the endpoint requires a Bearer secret (CRON_SECRET / NOOK_LIFECYCLE_SECRET) and fails closed when unconfigured — an open trigger would email real users. All sends are best-effort and logged, never thrown. Tests: trial-email (3) and lifecycle (6) suites; full run 539 pass. Issues: FRE-10, FRE-11
This commit is contained in:
108
src/server/nook-lifecycle.ts
Normal file
108
src/server/nook-lifecycle.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* The two lifecycle emails (plus their scheduled follow-ups) for The Nook —
|
||||
* the highest-ROI-per-hour writing in the whole plan, per
|
||||
* the-nook/docs/marketing-strategy.md §4.4:
|
||||
*
|
||||
* (a) trial D+11 expiry reminder with a buy link (scheduled, see
|
||||
* /api/the-nook/lifecycle)
|
||||
* (b) purchase thank-you, sent at purchase, promising a testimonial ask at
|
||||
* D+3 and a review ask at D+14 (also scheduled from the same job)
|
||||
*
|
||||
* All sends are best-effort: failures are logged, never thrown — an email
|
||||
* outage must never fail a purchase webhook or a cron run. Copy follows
|
||||
* marketing/positioning.md: category nouns, no competitor names, honest
|
||||
* privacy scoping.
|
||||
*/
|
||||
|
||||
const SENDER = { name: "The Nook", email: "support@freno.me" };
|
||||
const CHECKOUT_URL = "https://nook.freno.me/checkout";
|
||||
|
||||
/** Sends one plain-text email via the Brevo SMTP API. Best-effort. */
|
||||
async function send(to: string, subject: string, textContent: string): Promise<boolean> {
|
||||
if (!env.SENDINBLUE_KEY) {
|
||||
console.error("Lifecycle email skipped — SENDINBLUE_KEY unset:", subject);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("https://api.brevo.com/v3/smtp/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key": env.SENDINBLUE_KEY,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sender: SENDER,
|
||||
replyTo: SENDER,
|
||||
to: [{ email: to }],
|
||||
subject,
|
||||
textContent
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Lifecycle email "${subject}" to ${to}: HTTP ${res.status}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Lifecycle email "${subject}" to ${to} failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** (a) Trial D+11: the expiry reminder with a buy link. */
|
||||
export function emailTrialReminder(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"Your Nook trial ends in 3 days",
|
||||
`Hi,\n\nYour Nook trial ends in about three days.\n\n` +
|
||||
`If it's been keeping your agents unblocked and your Mac cool, you can ` +
|
||||
`keep it with a one-time purchase — no subscription:\n\n${CHECKOUT_URL}\n\n` +
|
||||
`If it's not for you, no action needed — the trial just ends and nothing ` +
|
||||
`is charged. Either way, thanks for trying it.\n\n` +
|
||||
`— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
|
||||
/** (b) Purchase thank-you, sent immediately at purchase. */
|
||||
export function emailThankYou(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"Welcome to The Nook",
|
||||
`Hi,\n\nThank you for buying The Nook — your license key just arrived in a ` +
|
||||
`separate email.\n\n` +
|
||||
`Two small things to expect from me, nothing more: in a few days I'll ` +
|
||||
`ask how it's going, and later I'll ask if you'd share a line about it. ` +
|
||||
`That's the whole newsletter.\n\n` +
|
||||
`If anything is off — activation, a hook that won't fire, anything — ` +
|
||||
`just reply to this email.\n\n— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
|
||||
/** (b1) Purchase D+3: the testimonial ask. */
|
||||
export function emailTestimonialAsk(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"How is The Nook going for you?",
|
||||
`Hi,\n\nYou've had The Nook for a few days now. How is it going?\n\n` +
|
||||
`If it's earned its place, reply with a line or two about what it does ` +
|
||||
`for you — with your permission, and only with it, I'd love to share ` +
|
||||
`your words on the site.\n\n` +
|
||||
`If something is in the way, reply with that instead: it's a faster fix ` +
|
||||
`than you'd think.\n\n— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
|
||||
/** (b2) Purchase D+14: the review ask. */
|
||||
export function emailReviewAsk(to: string): Promise<boolean> {
|
||||
return send(
|
||||
to,
|
||||
"Two weeks in — would you share a line?",
|
||||
`Hi,\n\nTwo weeks with The Nook. If it's doing its job, would you share a ` +
|
||||
`sentence about it? A reply is fine — with your name (or handle) and ` +
|
||||
`what setup you run, it helps other people with a dozen agents decide.\n\n` +
|
||||
`Thank you either way.\n\n— Mike, The Nook`
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
max_devices INTEGER NOT NULL DEFAULT 3
|
||||
max_devices INTEGER NOT NULL DEFAULT 3,
|
||||
testimonial_asked_at TEXT,
|
||||
review_asked_at TEXT
|
||||
)
|
||||
`);
|
||||
const licenseCols = await conn.execute(`PRAGMA table_info(licenses)`);
|
||||
@@ -46,6 +48,18 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||
`ALTER TABLE licenses ADD COLUMN max_devices INTEGER NOT NULL DEFAULT 3`
|
||||
);
|
||||
}
|
||||
const licenseColsAll = await conn.execute(`PRAGMA table_info(licenses)`);
|
||||
const licenseNames = new Set(
|
||||
licenseColsAll.rows.map((r) => (r as { name?: string }).name)
|
||||
);
|
||||
if (!licenseNames.has("testimonial_asked_at")) {
|
||||
await conn.execute(
|
||||
`ALTER TABLE licenses ADD COLUMN testimonial_asked_at TEXT`
|
||||
);
|
||||
}
|
||||
if (!licenseNames.has("review_asked_at")) {
|
||||
await conn.execute(`ALTER TABLE licenses ADD COLUMN review_asked_at TEXT`);
|
||||
}
|
||||
await conn.execute(`
|
||||
CREATE TABLE IF NOT EXISTS activations (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -59,9 +73,27 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
||||
await conn.execute(`
|
||||
CREATE TABLE IF NOT EXISTS trials (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
started_at TEXT NOT NULL
|
||||
started_at TEXT NOT NULL,
|
||||
email TEXT
|
||||
)
|
||||
`);
|
||||
// Pre-existing installs created `trials` without the email column.
|
||||
const trialCols = await conn.execute(`PRAGMA table_info(trials)`);
|
||||
const hasEmail = trialCols.rows.some(
|
||||
(r) => (r as { name?: string }).name === "email"
|
||||
);
|
||||
if (!hasEmail) {
|
||||
await conn.execute(`ALTER TABLE trials ADD COLUMN email TEXT`);
|
||||
}
|
||||
const trialColsAll = await conn.execute(`PRAGMA table_info(trials)`);
|
||||
const trialNames = new Set(
|
||||
trialColsAll.rows.map((r) => (r as { name?: string }).name)
|
||||
);
|
||||
if (!trialNames.has("reminder_sent_at")) {
|
||||
await conn.execute(
|
||||
`ALTER TABLE trials ADD COLUMN reminder_sent_at TEXT`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
function privateKeyObject() {
|
||||
@@ -214,6 +246,57 @@ export async function grantLicense(
|
||||
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records (or replaces) the email a user gave at trial start, and adds it to
|
||||
* the trial-list in Brevo so the lifecycle mail has an audience. Best-effort
|
||||
* on both counts: a failed Brevo call is logged, never thrown — the address
|
||||
* is already durably in `trials`.
|
||||
*
|
||||
* Returns true when a `trials` row now carries the email.
|
||||
*/
|
||||
export async function setTrialEmail(
|
||||
fingerprint: string,
|
||||
email: string
|
||||
): Promise<boolean> {
|
||||
const conn = NookConnectionFactory();
|
||||
const res = await conn.execute({
|
||||
sql: `
|
||||
INSERT INTO trials (fingerprint, started_at, email)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(fingerprint) DO UPDATE SET email = excluded.email
|
||||
`,
|
||||
args: [fingerprint, new Date().toISOString(), email]
|
||||
});
|
||||
await addTrialEmailToList(email);
|
||||
return res.rowsAffected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an address to the Brevo trial list (the same API family
|
||||
* `emailLicenseKey` speaks). No-op when NOOK_TRIAL_LIST_ID is not configured.
|
||||
*/
|
||||
async function addTrialEmailToList(email: string): Promise<void> {
|
||||
const listId = env.NOOK_TRIAL_LIST_ID;
|
||||
if (!listId) return;
|
||||
try {
|
||||
await fetch("https://api.brevo.com/v3/contacts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key": env.SENDINBLUE_KEY,
|
||||
"content-type": "application/json",
|
||||
accept: "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
updateEnabled: true,
|
||||
listIds: [listId]
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add trial email to Brevo list:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a license key to the buyer. Best-effort — failures are logged, never
|
||||
* thrown, so the caller (checkout webhook / resend) isn't interrupted.
|
||||
|
||||
Reference in New Issue
Block a user