Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

onboarding-drip.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

onboarding-drip.tsBlame345 lines · 1 contributor
f65f600Claude1/**
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}