CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
email-verification.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.
| c63b860 | 1 | /** |
| 2 | * Block P2 — email verification + welcome email. | |
| 3 | * | |
| 4 | * Responsibilities: | |
| 5 | * - Issue verification tokens (plaintext 32-byte hex, sha256-hashed at rest, | |
| 6 | * 24-hour expiry). | |
| 7 | * - Consume tokens, marking `users.email_verified_at`. | |
| 8 | * - Fire the welcome email AFTER a successful verification. | |
| 9 | * | |
| 10 | * Contract: every exported function never throws. Email failures degrade | |
| 11 | * silently — the caller's primary code path (registration, etc.) must not | |
| 12 | * be coupled to email-provider liveness. | |
| 13 | * | |
| 14 | * Test seam: a `__setEmailForTests` setter lets unit tests inject a recorder | |
| 15 | * in place of the live `sendEmail` import. Tests must restore the previous | |
| 16 | * sender in `afterAll` to keep the module graph clean. | |
| 17 | */ | |
| 18 | ||
| 19 | import { randomBytes, createHash } from "node:crypto"; | |
| 20 | import { and, eq, gt, isNull } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { users, emailVerificationTokens } from "../db/schema"; | |
| 23 | import { | |
| 24 | sendEmail as realSendEmail, | |
| 25 | absoluteUrl, | |
| 26 | type EmailMessage, | |
| 27 | type EmailResult, | |
| 28 | } from "./email"; | |
| 29 | ||
| 30 | // --------------------------------------------------------------------------- | |
| 31 | // Test seam — swap the email sender out without touching the module graph. | |
| 32 | // --------------------------------------------------------------------------- | |
| 33 | ||
| 34 | type EmailSender = (msg: EmailMessage) => Promise<EmailResult>; | |
| 35 | let _sender: EmailSender = realSendEmail; | |
| 36 | ||
| 37 | /** | |
| 38 | * Swap the email sender for the duration of a test. Returns the previous | |
| 39 | * sender so the test can restore it in `afterAll`. Never call from prod. | |
| 40 | */ | |
| 41 | export function __setEmailForTests(fn: EmailSender): EmailSender { | |
| 42 | const prev = _sender; | |
| 43 | _sender = fn; | |
| 44 | return prev; | |
| 45 | } | |
| 46 | ||
| 47 | // --------------------------------------------------------------------------- | |
| 48 | // Token generation | |
| 49 | // --------------------------------------------------------------------------- | |
| 50 | ||
| 51 | /** Token validity window. Tunable here; the migration enforces nothing. */ | |
| 52 | export const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; | |
| 53 | ||
| 54 | /** | |
| 55 | * Produce a fresh plaintext token + its sha256 hash. Only the hash is | |
| 56 | * persisted; the plaintext is delivered exactly once via email. | |
| 57 | */ | |
| 58 | export function generateVerificationToken(): { | |
| 59 | plaintext: string; | |
| 60 | hash: string; | |
| 61 | } { | |
| 62 | const plaintext = randomBytes(32).toString("hex"); | |
| 63 | const hash = hashToken(plaintext); | |
| 64 | return { plaintext, hash }; | |
| 65 | } | |
| 66 | ||
| 67 | /** Stable hash for lookups. Public so tests can compute the same value. */ | |
| 68 | export function hashToken(plaintext: string): string { | |
| 69 | return createHash("sha256").update(plaintext).digest("hex"); | |
| 70 | } | |
| 71 | ||
| 72 | // --------------------------------------------------------------------------- | |
| 73 | // startEmailVerification | |
| 74 | // --------------------------------------------------------------------------- | |
| 75 | ||
| 76 | /** | |
| 77 | * Issue a fresh token for `userId` + `email` and send the verification email. | |
| 78 | * Fire-and-forget safe: never throws, returns `{ok}` so callers can audit | |
| 79 | * failures if they wish. | |
| 80 | */ | |
| 81 | export async function startEmailVerification( | |
| 82 | userId: string, | |
| 83 | email: string | |
| 84 | ): Promise<{ ok: boolean }> { | |
| 85 | try { | |
| 86 | const { plaintext, hash } = generateVerificationToken(); | |
| 87 | const expiresAt = new Date(Date.now() + TOKEN_TTL_MS); | |
| 88 | await db.insert(emailVerificationTokens).values({ | |
| 89 | userId, | |
| 90 | email, | |
| 91 | tokenHash: hash, | |
| 92 | expiresAt, | |
| 93 | }); | |
| 94 | ||
| 95 | let username = "there"; | |
| 96 | try { | |
| 97 | const [u] = await db | |
| 98 | .select({ username: users.username }) | |
| 99 | .from(users) | |
| 100 | .where(eq(users.id, userId)) | |
| 101 | .limit(1); | |
| 102 | if (u?.username) username = u.username; | |
| 103 | } catch { | |
| 104 | // username lookup is cosmetic. | |
| 105 | } | |
| 106 | ||
| 107 | const link = absoluteUrl(`/verify-email?token=${encodeURIComponent(plaintext)}`); | |
| 108 | const { subject, text, html } = renderVerificationEmail({ username, link }); | |
| 109 | const result = await _sender({ to: email, subject, text, html }); | |
| 110 | return { ok: result.ok }; | |
| 111 | } catch (err) { | |
| 112 | console.error("[email-verification] startEmailVerification:", err); | |
| 113 | return { ok: false }; | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | // --------------------------------------------------------------------------- | |
| 118 | // consumeVerificationToken | |
| 119 | // --------------------------------------------------------------------------- | |
| 120 | ||
| 121 | export async function consumeVerificationToken( | |
| 122 | token: string | |
| 123 | ): Promise<{ ok: boolean; userId?: string; email?: string }> { | |
| 124 | if (!token || typeof token !== "string") return { ok: false }; | |
| 125 | try { | |
| 126 | const hash = hashToken(token); | |
| 127 | const now = new Date(); | |
| 128 | const [row] = await db | |
| 129 | .select() | |
| 130 | .from(emailVerificationTokens) | |
| 131 | .where( | |
| 132 | and( | |
| 133 | eq(emailVerificationTokens.tokenHash, hash), | |
| 134 | isNull(emailVerificationTokens.usedAt), | |
| 135 | gt(emailVerificationTokens.expiresAt, now) | |
| 136 | ) | |
| 137 | ) | |
| 138 | .limit(1); | |
| 139 | if (!row) return { ok: false }; | |
| 140 | ||
| 141 | await db | |
| 142 | .update(emailVerificationTokens) | |
| 143 | .set({ usedAt: now }) | |
| 144 | .where(eq(emailVerificationTokens.id, row.id)); | |
| 145 | ||
| 146 | await db | |
| 147 | .update(users) | |
| 148 | .set({ emailVerifiedAt: now }) | |
| 149 | .where(eq(users.id, row.userId)); | |
| 150 | ||
| 151 | return { ok: true, userId: row.userId, email: row.email }; | |
| 152 | } catch (err) { | |
| 153 | console.error("[email-verification] consumeVerificationToken:", err); | |
| 154 | return { ok: false }; | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | // --------------------------------------------------------------------------- | |
| 159 | // Welcome email — sent AFTER successful verification. | |
| 160 | // --------------------------------------------------------------------------- | |
| 161 | ||
| 162 | export async function sendWelcomeEmail(userId: string): Promise<void> { | |
| 163 | try { | |
| 164 | const [u] = await db | |
| 165 | .select({ username: users.username, email: users.email }) | |
| 166 | .from(users) | |
| 167 | .where(eq(users.id, userId)) | |
| 168 | .limit(1); | |
| 169 | if (!u || !u.email) return; | |
| 170 | const { subject, text, html } = renderWelcomeEmail({ username: u.username }); | |
| 171 | await _sender({ to: u.email, subject, text, html }); | |
| 172 | } catch (err) { | |
| 173 | console.error("[email-verification] sendWelcomeEmail:", err); | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | // --------------------------------------------------------------------------- | |
| 178 | // Email templates | |
| 179 | // --------------------------------------------------------------------------- | |
| 180 | ||
| 181 | function escapeHtml(s: string): string { | |
| 182 | return s | |
| 183 | .replace(/&/g, "&") | |
| 184 | .replace(/</g, "<") | |
| 185 | .replace(/>/g, ">") | |
| 186 | .replace(/"/g, """) | |
| 187 | .replace(/'/g, "'"); | |
| 188 | } | |
| 189 | ||
| 190 | export function renderVerificationEmail(opts: { | |
| 191 | username: string; | |
| 192 | link: string; | |
| 193 | }): { subject: string; text: string; html: string } { | |
| 194 | const u = opts.username; | |
| 195 | const link = opts.link; | |
| 196 | const subject = "Confirm your email for Gluecron"; | |
| 197 | ||
| 198 | const text = [ | |
| 199 | `Hi ${u},`, | |
| 200 | "", | |
| 201 | "Thanks for signing up for Gluecron — the git host built around Claude.", | |
| 202 | "", | |
| 203 | `Confirm your email: ${link}`, | |
| 204 | "", | |
| 205 | "This link expires in 24 hours. If you didn't sign up, ignore this email.", | |
| 206 | ].join("\n"); | |
| 207 | ||
| 208 | const html = renderHtmlShell({ | |
| 209 | title: "Confirm your email", | |
| 210 | heroSubtitle: "Welcome to Gluecron", | |
| 211 | heroLine: `Hi <strong>${escapeHtml(u)}</strong>, thanks for signing up.`, | |
| 212 | body: ` | |
| 213 | <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9"> | |
| 214 | Gluecron is the git host built around Claude. Confirm your email | |
| 215 | address to finish setting up your account. | |
| 216 | </p> | |
| 217 | <p style="margin:0 0 24px;text-align:center"> | |
| 218 | <a href="${escapeHtml(link)}" | |
| 6fd5915 | 219 | style="display:inline-block;padding:12px 24px;background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 100%);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:14px"> |
| c63b860 | 220 | Confirm email |
| 221 | </a> | |
| 222 | </p> | |
| 223 | <p style="margin:0;font-size:12px;color:#8b949e;line-height:1.55"> | |
| 224 | Or paste this link into your browser:<br /> | |
| 225 | <span style="word-break:break-all">${escapeHtml(link)}</span> | |
| 226 | </p> | |
| 227 | <p style="margin:24px 0 0;font-size:12px;color:#8b949e"> | |
| 228 | This link expires in 24 hours. If you didn't sign up for Gluecron, | |
| 229 | you can safely ignore this email. | |
| 230 | </p> | |
| 231 | `, | |
| 232 | }); | |
| 233 | ||
| 234 | return { subject, text, html }; | |
| 235 | } | |
| 236 | ||
| 237 | export function renderWelcomeEmail(opts: { username: string }): { | |
| 238 | subject: string; | |
| 239 | text: string; | |
| 240 | html: string; | |
| 241 | } { | |
| 242 | const u = opts.username; | |
| 243 | const subject = "Welcome to Gluecron \u{1F389}"; | |
| 244 | ||
| 245 | const newRepo = absoluteUrl("/new"); | |
| 246 | const importUrl = absoluteUrl("/import"); | |
| 247 | const demoUrl = absoluteUrl("/demo"); | |
| 248 | const installUrl = absoluteUrl("/install"); | |
| 249 | const onboarding = absoluteUrl("/onboarding"); | |
| 250 | const docs = absoluteUrl("/docs"); | |
| 251 | const help = absoluteUrl("/help"); | |
| 252 | ||
| 253 | const text = [ | |
| 254 | `Welcome aboard, ${u}!`, | |
| 255 | "", | |
| 256 | "Your email is verified. Here's what to try first:", | |
| 257 | "", | |
| 258 | `• Create your first repo — ${newRepo}`, | |
| 259 | `• Import from GitHub — ${importUrl}`, | |
| 260 | `• Watch Claude work — ${demoUrl}`, | |
| 261 | `• Install Claude Desktop integration — ${installUrl}`, | |
| 262 | "", | |
| 263 | `Next steps: ${onboarding}`, | |
| 264 | `Docs: ${docs}`, | |
| 265 | `Need help? Reply to this email or visit ${help}.`, | |
| 266 | ].join("\n"); | |
| 267 | ||
| 268 | const html = renderHtmlShell({ | |
| 269 | title: "Welcome to Gluecron", | |
| 270 | heroSubtitle: "You're in", | |
| 271 | heroLine: `Welcome aboard, <strong>${escapeHtml(u)}</strong>.`, | |
| 272 | body: ` | |
| 273 | <p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#c9d1d9"> | |
| 274 | Your email is verified. Here's what to try first: | |
| 275 | </p> | |
| 276 | <ul style="margin:0 0 24px;padding-left:18px;font-size:14px;line-height:1.7;color:#c9d1d9"> | |
| 277 | <li><strong>Create your first repo</strong> — | |
| 278 | <a style="color:#79c0ff" href="${escapeHtml(newRepo)}">gluecron.com/new</a></li> | |
| 279 | <li><strong>Import from GitHub</strong> — | |
| 280 | <a style="color:#79c0ff" href="${escapeHtml(importUrl)}">gluecron.com/import</a></li> | |
| 281 | <li><strong>Watch Claude work</strong> — | |
| 282 | <a style="color:#79c0ff" href="${escapeHtml(demoUrl)}">gluecron.com/demo</a></li> | |
| 283 | <li><strong>Install Claude Desktop integration</strong> — | |
| 284 | <a style="color:#79c0ff" href="${escapeHtml(installUrl)}">gluecron.com/install</a></li> | |
| 285 | </ul> | |
| 286 | <p style="margin:0 0 8px;font-size:13px;color:#8b949e"> | |
| 287 | New here? Start with the | |
| 288 | <a style="color:#79c0ff" href="${escapeHtml(onboarding)}">onboarding tour</a> | |
| 289 | or skim the <a style="color:#79c0ff" href="${escapeHtml(docs)}">docs</a>. | |
| 290 | </p> | |
| 291 | <p style="margin:0;font-size:13px;color:#8b949e"> | |
| 292 | Need help? Reply to this email or visit | |
| 293 | <a style="color:#79c0ff" href="${escapeHtml(help)}">gluecron.com/help</a>. | |
| 294 | </p> | |
| 295 | `, | |
| 296 | }); | |
| 297 | ||
| 298 | return { subject, text, html }; | |
| 299 | } | |
| 300 | ||
| 301 | function renderHtmlShell(opts: { | |
| 302 | title: string; | |
| 303 | heroSubtitle: string; | |
| 304 | heroLine: string; | |
| 305 | body: string; | |
| 306 | }): string { | |
| 307 | return [ | |
| 308 | `<!doctype html><html><head><meta charset="utf-8" /><title>${escapeHtml(opts.title)}</title></head>`, | |
| 309 | `<body style="margin:0;padding:24px;background:#0d1117;font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#c9d1d9">`, | |
| 310 | `<div style="max-width:560px;margin:0 auto;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden">`, | |
| 6fd5915 | 311 | `<div style="background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 100%);color:#fff;padding:24px">`, |
| c63b860 | 312 | `<div style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;opacity:0.85">${escapeHtml(opts.heroSubtitle)}</div>`, |
| 313 | `<h1 style="margin:8px 0 0;font-size:22px;font-weight:600">${opts.heroLine}</h1>`, | |
| 314 | `</div>`, | |
| 315 | `<div style="padding:24px">`, | |
| 316 | opts.body, | |
| 317 | `</div>`, | |
| 318 | `<div style="padding:12px 24px;border-top:1px solid #30363d;background:#0d1117;color:#6e7681;font-size:11px;text-align:center">`, | |
| 319 | `Gluecron — the git host built around Claude.`, | |
| 320 | `</div>`, | |
| 321 | `</div></body></html>`, | |
| 322 | ].join("\n"); | |
| 323 | } |