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

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.

email-verification.tsBlame323 lines · 1 contributor
c63b860Claude1/**
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
19import { randomBytes, createHash } from "node:crypto";
20import { and, eq, gt, isNull } from "drizzle-orm";
21import { db } from "../db";
22import { users, emailVerificationTokens } from "../db/schema";
23import {
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
34type EmailSender = (msg: EmailMessage) => Promise<EmailResult>;
35let _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 */
41export 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. */
52export 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 */
58export 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. */
68export 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 */
81export 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
121export 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
162export 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
181function escapeHtml(s: string): string {
182 return s
183 .replace(/&/g, "&amp;")
184 .replace(/</g, "&lt;")
185 .replace(/>/g, "&gt;")
186 .replace(/"/g, "&quot;")
187 .replace(/'/g, "&#39;");
188}
189
190export 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)}"
6fd5915Claude219 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">
c63b860Claude220 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
237export 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> &mdash;
278 <a style="color:#79c0ff" href="${escapeHtml(newRepo)}">gluecron.com/new</a></li>
279 <li><strong>Import from GitHub</strong> &mdash;
280 <a style="color:#79c0ff" href="${escapeHtml(importUrl)}">gluecron.com/import</a></li>
281 <li><strong>Watch Claude work</strong> &mdash;
282 <a style="color:#79c0ff" href="${escapeHtml(demoUrl)}">gluecron.com/demo</a></li>
283 <li><strong>Install Claude Desktop integration</strong> &mdash;
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
301function 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">`,
6fd5915Claude311 `<div style="background:linear-gradient(135deg,#5b6ee8 0%,#5f8fa0 100%);color:#fff;padding:24px">`,
c63b860Claude312 `<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}