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-digest.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-digest.tsBlame312 lines · 1 contributor
08420cdClaude1/**
2 * Block I7 — Weekly email digest.
3 *
4 * Composes a per-user digest of activity over the last 7 days (or a custom
5 * window) and sends it via the shared email module. Run from a cron or
6 * manually via `POST /admin/digests/run`.
7 *
8 * Data sources:
9 * - notifications (unread + read-last-7d)
10 * - gate_runs (failed / repaired)
11 * - pull_requests (merged by the user's repos)
12 *
13 * Never throws — the caller can fire-and-forget.
14 */
15
16import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
17import { db } from "./../db";
18import {
19 gateRuns,
20 notifications,
21 pullRequests,
22 repositories,
23 users,
24} from "./../db/schema";
25import { sendEmail, type EmailResult } from "./email";
26import { config } from "./config";
27
28export interface DigestInput {
29 userId: string;
30 since?: Date;
31 /** When false, skip `sendEmail` and just compose. Used for preview. */
32 send?: boolean;
33}
34
35export interface DigestBody {
36 subject: string;
37 text: string;
38 html: string;
39 counts: {
40 notifications: number;
41 failedGates: number;
42 repairedGates: number;
43 mergedPrs: number;
44 };
45}
46
47function fmtRange(from: Date, to: Date): string {
48 const f = from.toISOString().slice(0, 10);
49 const t = to.toISOString().slice(0, 10);
50 return f === t ? f : `${f} \u2192 ${t}`;
51}
52
53export async function composeDigest(
54 userId: string,
55 since?: Date
56): Promise<DigestBody | null> {
57 const now = new Date();
58 const from = since || new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
59 try {
60 const [user] = await db
61 .select()
62 .from(users)
63 .where(eq(users.id, userId))
64 .limit(1);
65 if (!user) return null;
66
67 // Pull notifications
68 const notifs = await db
69 .select()
70 .from(notifications)
71 .where(
72 and(
73 eq(notifications.userId, userId),
74 gte(notifications.createdAt, from)
75 )
76 )
77 .orderBy(desc(notifications.createdAt))
78 .limit(25);
79
80 // User's repos (owner only — org-aware digest can come later)
81 const ownedRepos = await db
82 .select({ id: repositories.id, name: repositories.name })
83 .from(repositories)
84 .where(eq(repositories.ownerId, userId));
85 const repoIds = ownedRepos.map((r) => r.id);
86
87 let failedGates: Array<{ repoName: string; gateName: string; sha: string }> = [];
88 let repairedGates: Array<{ repoName: string; gateName: string; sha: string }> = [];
89 let mergedPrs: Array<{ repoName: string; title: string }> = [];
90
91 if (repoIds.length > 0) {
92 const gates = await db
93 .select()
94 .from(gateRuns)
95 .where(
96 and(
97 inArray(gateRuns.repositoryId, repoIds),
98 gte(gateRuns.createdAt, from)
99 )
100 )
101 .orderBy(desc(gateRuns.createdAt))
102 .limit(50);
103 const byId = new Map(ownedRepos.map((r) => [r.id, r.name]));
104 for (const g of gates) {
105 const repoName = byId.get(g.repositoryId) || "?";
106 if (g.status === "failed") {
107 failedGates.push({
108 repoName,
109 gateName: g.gateName,
110 sha: g.commitSha.slice(0, 7),
111 });
112 } else if (g.status === "repaired") {
113 repairedGates.push({
114 repoName,
115 gateName: g.gateName,
116 sha: g.commitSha.slice(0, 7),
117 });
118 }
119 }
120
121 const merged = await db
122 .select()
123 .from(pullRequests)
124 .where(
125 and(
126 inArray(pullRequests.repositoryId, repoIds),
127 eq(pullRequests.state, "merged"),
128 gte(pullRequests.updatedAt, from)
129 )
130 )
131 .limit(25);
132 for (const pr of merged) {
133 mergedPrs.push({
134 repoName: byId.get(pr.repositoryId) || "?",
135 title: pr.title,
136 });
137 }
138 }
139
140 const counts = {
141 notifications: notifs.length,
142 failedGates: failedGates.length,
143 repairedGates: repairedGates.length,
144 mergedPrs: mergedPrs.length,
145 };
146
147 const base = config.appBaseUrl || "https://gluecron.com";
148 const subject = `Your Gluecron digest (${fmtRange(from, now)})`;
149 const lines: string[] = [];
150 lines.push(`Hi ${user.username},`);
151 lines.push("");
152 lines.push(`Here's what happened across your repos this week.`);
153 lines.push("");
154 lines.push(
155 `Notifications: ${counts.notifications} · Failed gates: ${counts.failedGates} · Auto-repaired: ${counts.repairedGates} · PRs merged: ${counts.mergedPrs}`
156 );
157 lines.push("");
158
159 if (notifs.length > 0) {
160 lines.push("## Notifications");
161 for (const n of notifs.slice(0, 10)) {
162 const when = new Date(n.createdAt).toLocaleDateString();
163 lines.push(`- [${n.kind}] ${n.title || "(untitled)"} — ${when}`);
164 }
165 lines.push("");
166 }
167
168 if (failedGates.length > 0) {
169 lines.push("## Failed gates");
170 for (const g of failedGates.slice(0, 10)) {
171 lines.push(`- ${g.repoName} — ${g.gateName} (${g.sha})`);
172 }
173 lines.push("");
174 }
175
176 if (repairedGates.length > 0) {
177 lines.push("## Auto-repairs");
178 for (const g of repairedGates.slice(0, 10)) {
179 lines.push(`- ${g.repoName} — ${g.gateName} (${g.sha})`);
180 }
181 lines.push("");
182 }
183
184 if (mergedPrs.length > 0) {
185 lines.push("## Merged PRs");
186 for (const pr of mergedPrs.slice(0, 10)) {
187 lines.push(`- ${pr.repoName} — ${pr.title}`);
188 }
189 lines.push("");
190 }
191
192 lines.push("---");
193 lines.push(
194 `You're receiving this because you opted into weekly digests. Manage at ${base}/settings.`
195 );
196
197 const text = lines.join("\n");
198 const html = textToHtml(text, base);
199 return { subject, text, html, counts };
200 } catch (err) {
201 console.error("[digest] composeDigest error:", err);
202 return null;
203 }
204}
205
206function textToHtml(text: string, base: string): string {
207 const lines = text.split("\n");
208 const out: string[] = [
209 `<html><body style="font-family:system-ui,sans-serif;max-width:640px;margin:0 auto;padding:24px;color:#111">`,
210 ];
211 for (const line of lines) {
212 if (line.startsWith("## ")) {
213 out.push(
214 `<h3 style="border-bottom:1px solid #eee;padding-bottom:4px;margin-top:24px">${escapeHtml(line.slice(3))}</h3>`
215 );
216 } else if (line.startsWith("- ")) {
217 out.push(`<li>${escapeHtml(line.slice(2))}</li>`);
218 } else if (line === "---") {
219 out.push(`<hr style="border:none;border-top:1px solid #eee;margin:24px 0" />`);
220 } else if (line.trim() === "") {
221 out.push("<br>");
222 } else {
223 out.push(`<p>${escapeHtml(line)}</p>`);
224 }
225 }
226 out.push(
227 `<p style="font-size:12px;color:#777"><a href="${escapeHtml(base)}">${escapeHtml(base)}</a></p>`
228 );
229 out.push("</body></html>");
230 return out.join("\n");
231}
232
233function escapeHtml(s: string): string {
234 return s
235 .replace(/&/g, "&amp;")
236 .replace(/</g, "&lt;")
237 .replace(/>/g, "&gt;")
238 .replace(/"/g, "&quot;");
239}
240
241/** Compose + send for a single user. Records `last_digest_sent_at` on success. */
242export async function sendDigestForUser(
243 userId: string
244): Promise<EmailResult | { ok: false; provider: "none"; skipped: string }> {
245 try {
246 const [user] = await db
247 .select()
248 .from(users)
249 .where(eq(users.id, userId))
250 .limit(1);
251 if (!user) return { ok: false, provider: "none", skipped: "user not found" };
252 if (!user.notifyEmailDigestWeekly) {
253 return { ok: false, provider: "none", skipped: "opted out" };
254 }
255 const body = await composeDigest(userId);
256 if (!body) {
257 return { ok: false, provider: "none", skipped: "compose failed" };
258 }
259 const result = await sendEmail({
260 to: user.email,
261 subject: body.subject,
262 text: body.text,
263 html: body.html,
264 });
265 if (result.ok) {
266 await db
267 .update(users)
268 .set({ lastDigestSentAt: new Date() })
269 .where(eq(users.id, userId));
270 }
271 return result;
272 } catch (err) {
273 console.error("[digest] sendDigestForUser error:", err);
274 return { ok: false, provider: "none", skipped: "error" };
275 }
276}
277
278/** Iterates all opted-in users. Returns per-user results for logging. */
279export async function sendDigestsToAll(): Promise<
280 Array<{ userId: string; username: string; ok: boolean; skipped?: string }>
281> {
282 const results: Array<{
283 userId: string;
284 username: string;
285 ok: boolean;
286 skipped?: string;
287 }> = [];
288 try {
289 const opted = await db
290 .select({ id: users.id, username: users.username })
291 .from(users)
292 .where(eq(users.notifyEmailDigestWeekly, true));
293 for (const u of opted) {
294 const r = await sendDigestForUser(u.id);
295 results.push({
296 userId: u.id,
297 username: u.username,
298 ok: r.ok,
299 skipped: "skipped" in r ? r.skipped : undefined,
300 });
301 }
302 } catch (err) {
303 console.error("[digest] sendDigestsToAll error:", err);
304 }
305 return results;
306}
307
308/** Pure helper exported for tests. */
309export const __internal = { textToHtml, escapeHtml, fmtRange };
310
311// Keep sql unused-import warnings silent
312void sql;