Commitf65f600unknown_key
Add onboarding email drip sequence — T+0 welcome, T+1 spec-to-PR, T+3 AI watching
Add onboarding email drip sequence — T+0 welcome, T+1 spec-to-PR, T+3 AI watching https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
6 files changed+392−0f65f60066788250e0fceda840fed172decc9615b
6 changed files+392−0
Addeddrizzle/0080_onboarding_emails.sql+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1-- Onboarding email drip sequence — tracks which drip emails each user has
2-- already received so they are never sent twice. The `emails_sent` jsonb
3-- column is a set of string keys (e.g. "welcome", "day1", "day3").
4--
5-- Strictly additive — no existing table or column is touched.
6
7ALTER TABLE "users"
8 ADD COLUMN IF NOT EXISTS "onboarding_emails_sent" jsonb NOT NULL DEFAULT '[]'::jsonb;
Modifiedsrc/db/schema.ts+6−0View fileUnifiedSplit
@@ -125,6 +125,12 @@ export const users = pgTable("users", {
125125 personalSemanticIndexEnabled: boolean("personal_semantic_index_enabled")
126126 .default(false)
127127 .notNull(),
128 // Onboarding drip sequence (migration 0080). Stores a JSON array of string
129 // keys for emails already delivered, e.g. ["welcome","day1","day3"].
130 // The autopilot `onboarding-drip` task compares this against the canonical
131 // drip schedule and sends any outstanding emails. Never null — defaults to
132 // an empty array at insert time.
133 onboardingEmailsSent: jsonb("onboarding_emails_sent").$type<string[]>().default([]).notNull(),
128134 createdAt: timestamp("created_at").defaultNow().notNull(),
129135 updatedAt: timestamp("updated_at").defaultNow().notNull(),
130136});
Modifiedsrc/lib/autopilot.ts+21−0View fileUnifiedSplit
@@ -70,6 +70,7 @@ import { expireOldSandboxes } from "./pr-sandbox";
7070import { expireIdleEnvs } from "./dev-env";
7171import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
73import { runOnboardingDripTaskOnce } from "./onboarding-drip";
7374
7475export interface AutopilotTaskResult {
7576 name: string;
@@ -641,6 +642,26 @@ export function defaultTasks(): AutopilotTask[] {
641642 }
642643 },
643644 },
645 {
646 // Onboarding drip — sends pending T+1d and T+3d drip emails to new
647 // users. The T+0 "welcome" email is sent immediately at registration
648 // via src/routes/auth.tsx. This task handles the delayed emails.
649 // Idempotent via the per-user `onboarding_emails_sent` jsonb column
650 // (migration 0080). Silently skips when email is not configured.
651 name: "onboarding-drip",
652 run: async () => {
653 try {
654 const summary = await runOnboardingDripTaskOnce();
655 if (summary.sent > 0 || summary.errors > 0) {
656 console.log(
657 `[autopilot] onboarding-drip: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
658 );
659 }
660 } catch (err) {
661 console.error("[autopilot] onboarding-drip: threw:", err);
662 }
663 },
664 },
644665 ];
645666}
646667
Addedsrc/lib/onboarding-drip.ts+345−0View fileUnifiedSplit
@@ -0,0 +1,345 @@
1/**
2 * Onboarding email drip sequence.
3 *
4 * Sends three transactional emails to new users:
5 * "welcome" — T+0, sent immediately on registration.
6 * "day1" — T+1 day, sent by the autopilot drip task.
7 * "day3" — T+3 days, sent by the autopilot drip task.
8 *
9 * Idempotency: each key is written to `users.onboarding_emails_sent` (jsonb
10 * array) before the send call returns. If the process crashes mid-send the
11 * row is updated on the next tick when the key is already present, so the
12 * user never receives a duplicate. Keys are only appended — never removed.
13 *
14 * Contract: every exported function must never throw. Failures are logged and
15 * swallowed so a downed email provider or DB hiccup never breaks the
16 * registration path or the autopilot tick.
17 */
18
19import { eq, sql, and, lte } from "drizzle-orm";
20import { db } from "../db";
21import { users } from "../db/schema";
22import { sendEmail, absoluteUrl } from "./email";
23
24// ---------------------------------------------------------------------------
25// Drip schedule constants
26// ---------------------------------------------------------------------------
27
28/** Ordered drip keys. The "welcome" email is sent at T+0 from the auth route;
29 * "day1" and "day3" are sent by the autopilot task. */
30export const DRIP_KEYS = ["welcome", "day1", "day3"] as const;
31export type DripKey = (typeof DRIP_KEYS)[number];
32
33/** Minimum age (ms) before each drip email is eligible. */
34const DRIP_DELAY_MS: Record<DripKey, number> = {
35 welcome: 0,
36 day1: 24 * 60 * 60 * 1000,
37 day3: 3 * 24 * 60 * 60 * 1000,
38};
39
40// ---------------------------------------------------------------------------
41// Email HTML helpers
42// ---------------------------------------------------------------------------
43
44function footer(baseUrl: string): string {
45 return `
46<p style="margin:28px 0 0;font-size:12px;color:#8b949e;border-top:1px solid #21262d;padding-top:16px">
47 You're receiving this because you created a Gluecron account.<br>
48 <a href="${baseUrl}/settings/notifications" style="color:#8b949e">Unsubscribe from onboarding emails</a>
49</p>`;
50}
51
52function wrapHtml(body: string, baseUrl: string): string {
53 return `<!DOCTYPE html>
54<html lang="en">
55<head>
56<meta charset="utf-8">
57<meta name="viewport" content="width=device-width,initial-scale=1">
58<title>Gluecron</title>
59</head>
60<body style="margin:0;padding:0;background:#0d1117;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#c9d1d9">
61<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d1117;padding:32px 0">
62 <tr><td align="center">
63 <table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:32px">
64 <tr><td>
65 <div style="margin-bottom:24px">
66 <span style="font-size:18px;font-weight:600;color:#f0f6fc">gluecron</span>
67 </div>
68 ${body}
69 ${footer(baseUrl)}
70 </td></tr>
71 </table>
72 </td></tr>
73</table>
74</body>
75</html>`;
76}
77
78function welcomeHtml(username: string, baseUrl: string): string {
79 const body = `
80<h1 style="margin:0 0 12px;font-size:22px;font-weight:600;color:#f0f6fc">Welcome to Gluecron, ${username}!</h1>
81<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#c9d1d9">
82 Your AI-native code platform is ready. Push code, open issues, and let Gluecron's
83 AI review every PR, enforce your gates, and merge when conditions pass — all automatically.
84</p>
85<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#c9d1d9">
86 Start by <a href="${baseUrl}/new" style="color:#58a6ff;text-decoration:none">creating your first repository</a>
87 or exploring what's already there.
88</p>
89<a href="${baseUrl}/dashboard" style="display:inline-block;padding:10px 20px;background:#238636;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:500">
90 Go to dashboard →
91</a>`;
92 return wrapHtml(body, baseUrl);
93}
94
95function day1Html(username: string, baseUrl: string): string {
96 const body = `
97<h1 style="margin:0 0 12px;font-size:22px;font-weight:600;color:#f0f6fc">Try Spec-to-PR, ${username}</h1>
98<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#c9d1d9">
99 Pick any open issue in your repo, add the <code style="background:#21262d;padding:2px 5px;border-radius:4px;font-size:13px;color:#79c0ff">ai:build</code> label,
100 and watch Gluecron build it into a draft PR in 90 seconds.
101</p>
102<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#c9d1d9">
103 No extra config required — the AI reads the issue title and body, writes the code, and opens the PR on your behalf.
104</p>
105<a href="${baseUrl}/dashboard" style="display:inline-block;padding:10px 20px;background:#1f6feb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:500">
106 Try it now →
107</a>`;
108 return wrapHtml(body, baseUrl);
109}
110
111function day3Html(username: string, baseUrl: string): string {
112 const body = `
113<h1 style="margin:0 0 12px;font-size:22px;font-weight:600;color:#f0f6fc">Your AI is watching, ${username}</h1>
114<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#c9d1d9">
115 Here's what Gluecron autopilot does for you automatically:
116</p>
117<ul style="margin:0 0 16px;padding-left:20px;font-size:15px;line-height:1.8;color:#c9d1d9">
118 <li><strong style="color:#f0f6fc">Reviews every PR</strong> — inline AI comments the moment you push.</li>
119 <li><strong style="color:#f0f6fc">Merges when gates pass</strong> — auto-merge fires once all required checks succeed.</li>
120 <li><strong style="color:#f0f6fc">Heals failed CI</strong> — the CI healer reads the logs and opens a fix PR automatically.</li>
121</ul>
122<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#c9d1d9">
123 All of this runs in the background — no manual intervention needed.
124</p>
125<a href="${baseUrl}/dashboard" style="display:inline-block;padding:10px 20px;background:#8957e5;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px;font-weight:500">
126 View your dashboard →
127</a>`;
128 return wrapHtml(body, baseUrl);
129}
130
131// ---------------------------------------------------------------------------
132// Core send helper (used by both the auth hook and the drip task)
133// ---------------------------------------------------------------------------
134
135interface UserRow {
136 id: string;
137 email: string;
138 username: string;
139 onboardingEmailsSent: string[] | null;
140}
141
142/**
143 * Mark a drip key as sent for a user — writes to DB first, then sends.
144 * Returns true on success, false on any failure (error already logged).
145 */
146async function markAndSend(
147 user: UserRow,
148 key: DripKey,
149 subject: string,
150 text: string,
151 html: string
152): Promise<boolean> {
153 const sent: string[] = Array.isArray(user.onboardingEmailsSent)
154 ? user.onboardingEmailsSent
155 : [];
156
157 if (sent.includes(key)) return true; // already delivered — idempotent
158
159 // Persist before sending so a crash doesn't re-send on the next tick.
160 try {
161 await db
162 .update(users)
163 .set({
164 onboardingEmailsSent: [...sent, key] as string[],
165 })
166 .where(eq(users.id, user.id));
167 } catch (err) {
168 console.error(
169 `[onboarding-drip] DB update failed for user=${user.id} key=${key}:`,
170 err
171 );
172 return false;
173 }
174
175 const result = await sendEmail({ to: user.email, subject, text, html });
176 if (!result.ok) {
177 console.error(
178 `[onboarding-drip] sendEmail failed for user=${user.id} key=${key}: ${result.error ?? result.skipped ?? "unknown"}`
179 );
180 // Don't return false — the DB row is already marked, which is correct.
181 // The user won't get a duplicate even if the email bounced.
182 }
183 return true;
184}
185
186// ---------------------------------------------------------------------------
187// T+0 welcome email — called directly from the registration route
188// ---------------------------------------------------------------------------
189
190/**
191 * Send the "welcome" email immediately after a new user registers.
192 * Best-effort; never throws. Safe to fire-and-forget (`void`).
193 */
194export async function sendWelcomeEmail(userId: string): Promise<void> {
195 try {
196 const [user] = await db
197 .select({
198 id: users.id,
199 email: users.email,
200 username: users.username,
201 onboardingEmailsSent: users.onboardingEmailsSent,
202 })
203 .from(users)
204 .where(eq(users.id, userId))
205 .limit(1);
206
207 if (!user) return;
208
209 const baseUrl = absoluteUrl("/").replace(/\/$/, "");
210 await markAndSend(
211 user as UserRow,
212 "welcome",
213 "Welcome to Gluecron — your AI code platform is ready",
214 `Hi ${user.username},\n\nWelcome to Gluecron! Your AI-native code platform is ready.\n\nStart by creating your first repository: ${baseUrl}/new\n\n—Gluecron\n\nUnsubscribe: ${baseUrl}/settings/notifications`,
215 welcomeHtml(user.username, baseUrl)
216 );
217 } catch (err) {
218 console.error(
219 `[onboarding-drip] sendWelcomeEmail threw for userId=${userId}:`,
220 err
221 );
222 }
223}
224
225// ---------------------------------------------------------------------------
226// Autopilot drip task — processes T+1 and T+3 emails in bulk
227// ---------------------------------------------------------------------------
228
229export interface OnboardingDripSummary {
230 sent: number;
231 skipped: number;
232 errors: number;
233}
234
235/** Per-tick cap — avoids hammering the email provider on a large install. */
236const DRIP_CAP_PER_TICK = 50;
237
238/**
239 * One iteration of the onboarding-drip autopilot task.
240 * Scans all non-playground, non-deleted users for pending drip emails
241 * (day1 at T+1d, day3 at T+3d) and sends them. Never throws.
242 */
243export async function runOnboardingDripTaskOnce(
244 opts: { now?: Date; cap?: number } = {}
245): Promise<OnboardingDripSummary> {
246 const now = opts.now ?? new Date();
247 const cap = opts.cap ?? DRIP_CAP_PER_TICK;
248
249 let rows: UserRow[] = [];
250 try {
251 // Fetch users created at least 1 day ago (earliest pending drip) who are
252 // not playground or soft-deleted accounts. We over-fetch relative to cap
253 // and filter in JS so we can apply per-user logic without N+1 queries.
254 const cutoff = new Date(now.getTime() - DRIP_DELAY_MS.day1);
255 const raw = await db
256 .select({
257 id: users.id,
258 email: users.email,
259 username: users.username,
260 onboardingEmailsSent: users.onboardingEmailsSent,
261 createdAt: users.createdAt,
262 })
263 .from(users)
264 .where(
265 and(
266 lte(users.createdAt, cutoff),
267 eq(users.isPlayground, false),
268 // Only target users who are not soft-deleted (deletedAt IS NULL).
269 sql`${users.deletedAt} IS NULL`
270 )
271 )
272 .limit(cap * 2); // fetch 2× so we still fill cap after skips
273
274 rows = raw as unknown as (UserRow & { createdAt: Date })[];
275 } catch (err) {
276 console.error("[onboarding-drip] candidate query failed:", err);
277 return { sent: 0, skipped: 0, errors: 1 };
278 }
279
280 const baseUrl = absoluteUrl("/").replace(/\/$/, "");
281 let sent = 0;
282 let skipped = 0;
283 let errors = 0;
284
285 for (const rawRow of rows) {
286 if (sent >= cap) break;
287
288 const row = rawRow as UserRow & { createdAt: Date };
289 const emailsSent: string[] = Array.isArray(row.onboardingEmailsSent)
290 ? row.onboardingEmailsSent
291 : [];
292 const ageMs = now.getTime() - new Date(row.createdAt).getTime();
293
294 // Process day1 and day3 in order; stop at the first unsent one to avoid
295 // skipping day1 and going straight to day3 on a slow-polling instance.
296 let didSend = false;
297
298 try {
299 // day1 — T+1d
300 if (
301 ageMs >= DRIP_DELAY_MS.day1 &&
302 !emailsSent.includes("day1")
303 ) {
304 const ok = await markAndSend(
305 row,
306 "day1",
307 "[Gluecron] Try Spec-to-PR — build a feature in 90 seconds",
308 `Hi ${row.username},\n\nPick any open issue in your repo, add the ai:build label, and watch Gluecron build it into a PR in 90 seconds.\n\nGo to your dashboard: ${baseUrl}/dashboard\n\n—Gluecron\n\nUnsubscribe: ${baseUrl}/settings/notifications`,
309 day1Html(row.username, baseUrl)
310 );
311 if (ok) { sent += 1; didSend = true; }
312 else errors += 1;
313 // Refresh the sent list so day3 sees the updated state.
314 emailsSent.push("day1");
315 }
316
317 // day3 — T+3d (only if day1 is done)
318 if (
319 ageMs >= DRIP_DELAY_MS.day3 &&
320 emailsSent.includes("day1") &&
321 !emailsSent.includes("day3")
322 ) {
323 const ok = await markAndSend(
324 row,
325 "day3",
326 "[Gluecron] Your AI is watching — autopilot does this for you",
327 `Hi ${row.username},\n\nGluecron autopilot automatically reviews every PR, merges when gates pass, and heals failed CI.\n\nGo to your dashboard: ${baseUrl}/dashboard\n\n—Gluecron\n\nUnsubscribe: ${baseUrl}/settings/notifications`,
328 day3Html(row.username, baseUrl)
329 );
330 if (ok) { sent += 1; didSend = true; }
331 else errors += 1;
332 }
333
334 if (!didSend) skipped += 1;
335 } catch (err) {
336 errors += 1;
337 console.error(
338 `[onboarding-drip] per-user error for user=${row.id}:`,
339 err
340 );
341 }
342 }
343
344 return { sent, skipped, errors };
345}
Modifiedsrc/routes/admin.tsx+1−0View fileUnifiedSplit
@@ -3615,6 +3615,7 @@ const AUTOPILOT_TASK_CATALOG = [
36153615 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
36163616 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
36173617 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
3618 { name: "onboarding-drip", desc: "Send T+1d and T+3d onboarding drip emails to new users" },
36183619] as const;
36193620
36203621admin.get("/admin/autopilot", async (c) => {
Modifiedsrc/routes/auth.tsx+11−0View fileUnifiedSplit
@@ -304,6 +304,17 @@ auth.post("/register", async (c) => {
304304 });
305305 }
306306
307 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
308 // the redirect. Silently skips when email is not configured.
309 import("../lib/onboarding-drip")
310 .then((m) => m.sendWelcomeEmail(user.id))
311 .catch((err) => {
312 console.error(
313 `[auth] onboarding welcome email failed for ${user.id}:`,
314 err instanceof Error ? err.message : err
315 );
316 });
317
307318 // P3 — default landing is /onboarding (the guided first-five-minutes
308319 // flow). The `redirect=` query is still honoured for OAuth-style flows.
309320 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
310321