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.
| f65f600 | 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 | ||
| 19 | import { eq, sql, and, lte } from "drizzle-orm"; | |
| 20 | import { db } from "../db"; | |
| 21 | import { users } from "../db/schema"; | |
| 22 | import { 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. */ | |
| 30 | export const DRIP_KEYS = ["welcome", "day1", "day3"] as const; | |
| 31 | export type DripKey = (typeof DRIP_KEYS)[number]; | |
| 32 | ||
| 33 | /** Minimum age (ms) before each drip email is eligible. */ | |
| 34 | const 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 | ||
| 44 | function 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 | ||
| 52 | function 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 | ||
| 78 | function 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 | ||
| 95 | function 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 | ||
| 111 | function 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 | ||
| 135 | interface 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 | */ | |
| 146 | async 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 | */ | |
| 194 | export 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 | ||
| 229 | export 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. */ | |
| 236 | const 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 | */ | |
| 243 | export 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 | } |