Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

907 lines · 57.9 KBRaw History
1/**
2 * Email templates. Each email is a list of blocks rendered twice: as HTML
3 * (table layout and inline styles that hold up in Gmail, Outlook and Apple
4 * Mail, light and dark) and as plain text. The look follows the site: Kumo's
5 * neutral palette, blue for actions and the orange mark.
6 */
7import { escapeHtml, renderMarkdown, stripHtmlComments } from "../web/markdown.js";
8import type { DigestData, MailJob, Reason, RunData, ThreadData } from "./types.js";
9
10export type Inline = string | { b: string } | { a: string; href: string } | { code: string };
11
12export type Block =
13 | { p: Inline[]; muted?: boolean }
14 | { md: string; base?: { owner: string; repo: string; ref: string } }
15 | { kv: Array<[string, Inline[] | string]> }
16 | { button: { label: string; url: string }; secondary?: { label: string; url: string } }
17 | { callout: Inline[]; tone: "info" | "warn" | "danger" }
18 | { code: string }
19 | { items: Array<{ mark: "ok" | "fail" | "skip" | "dot"; text: Inline[]; url?: string }> }
20 | { quote: Inline[] };
21
22export interface Email {
23 subject: string;
24 /** Hidden preview line shown by mail clients next to the subject. */
25 preheader: string;
26 /** Small label above the title (e.g. "Security", "Pull request"). */
27 eyebrow?: { text: string; tone: "neutral" | "blue" | "green" | "red" | "amber" | "purple" };
28 title: Inline[];
29 blocks: Block[];
30 /** Display name on the From address, e.g. the person who commented. */
31 fromName?: string;
32 /** Repository the email is about, shown next to the logo and used for List-Id. */
33 context?: { label: string; url: string };
34 /** Thread id for In-Reply-To / References, e.g. "owner/repo/issues/12". */
35 thread?: string;
36 /** Main link (also "View it on Diffinery" in the footer). */
37 url?: string;
38}
39
40export interface RenderCtx {
41 publicUrl: string;
42 /** The recipient's login, when they have an account. */
43 login?: string;
44 reason: Reason;
45 /** Footer links: unsubscribe (thread, watch or settings group) and settings. */
46 unsubscribe?: Array<{ label: string; url: string }>;
47 settingsUrl?: string;
48}
49
50// ---------------------------------------------------------------- palette (Kumo, as hex for mail clients)
51
52const C = {
53 canvas: "#f4f4f5",
54 card: "#ffffff",
55 border: "#e4e4e7",
56 fg: "#18181b",
57 fg2: "#52525b",
58 fg3: "#71717a",
59 accent: "#1447e6",
60 brand: "#f6821f",
61 codeBg: "#f4f4f5",
62 tones: {
63 neutral: ["#f4f4f5", "#3f3f46"],
64 blue: ["#e6edfd", "#1447e6"],
65 green: ["#e3f6ec", "#007a55"],
66 red: ["#fdecec", "#c10007"],
67 amber: ["#fdf3dc", "#a65f00"],
68 purple: ["#f3e8ff", "#8200db"],
69 } as Record<string, [string, string]>,
70};
71const FONT = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif`;
72const MONO = `ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono',monospace`;
73
74// ---------------------------------------------------------------- rendering
75
76const esc = escapeHtml;
77
78function inlineHtml(parts: Inline[] | string): string {
79 const list = typeof parts === "string" ? [parts] : parts;
80 return list
81 .map((x) => {
82 if (typeof x === "string") return esc(x);
83 if ("b" in x) return `<strong style="font-weight:600">${esc(x.b)}</strong>`;
84 if ("code" in x) return `<code class="code" style="font-family:${MONO};font-size:12.5px;background:${C.codeBg};border-radius:4px;padding:1px 5px">${esc(x.code)}</code>`;
85 return `<a href="${esc(x.href)}" style="color:${C.accent};text-decoration:none">${esc(x.a)}</a>`;
86 })
87 .join("");
88}
89
90function inlineText(parts: Inline[] | string): string {
91 const list = typeof parts === "string" ? [parts] : parts;
92 return list.map((x) => (typeof x === "string" ? x : "b" in x ? x.b : "code" in x ? `\`${x.code}\`` : `${x.a} (${x.href})`)).join("");
93}
94
95/** Markdown from issues and comments, with relative links made absolute. */
96function markdownHtml(md: string, publicUrl: string, base?: { owner: string; repo: string; ref: string }): string {
97 const html = renderMarkdown(md.length > 20_000 ? `${md.slice(0, 20_000)}\n\n…` : md, base ? { base: { ...base, dir: "" } } : {});
98 const abs = html.replace(/(href|src)="\/(?!\/)/g, `$1="${publicUrl}/`).replace(/href="#/g, `href="${publicUrl}/#`);
99 // Images from other hosts become links: an email that loads them would tell whoever wrote the comment when and where it was read.
100 return abs.replace(/<img\b[^>]*>/gi, (tag) => {
101 const src = /\ssrc="([^"]*)"/i.exec(tag)?.[1] ?? "";
102 if (src.startsWith(`${publicUrl}/`)) return tag;
103 const alt = /\salt="([^"]*)"/i.exec(tag)?.[1] || "image";
104 return src ? `<a href="${src}">[${alt}]</a>` : "";
105 });
106}
107
108function blockHtml(b: Block, ctx: RenderCtx): string {
109 const P = `margin:0 0 14px;font-size:15px;line-height:1.6;color:${C.fg}`;
110 if ("p" in b) return `<p class="${b.muted ? "muted" : "fg"}" style="${P}${b.muted ? `;color:${C.fg3};font-size:13px` : ""}">${inlineHtml(b.p)}</p>`;
111 if ("md" in b)
112 return `<div class="md box" style="margin:0 0 16px;padding:14px 16px;border:1px solid ${C.border};border-radius:8px;font-size:14px;line-height:1.6;color:${C.fg};overflow-wrap:anywhere">${markdownHtml(b.md, ctx.publicUrl, b.base) || `<span style="color:${C.fg3}">No description provided.</span>`}</div>`;
113 if ("kv" in b)
114 return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.kv
115 .map(
116 ([k, v], i) =>
117 `<tr><td class="muted" style="padding:9px 14px;${i ? `border-top:1px solid ${C.border};` : ""}font-size:13px;color:${C.fg3};width:130px;vertical-align:top">${esc(k)}</td><td class="fg" style="padding:9px 14px;${i ? `border-top:1px solid ${C.border};` : ""}font-size:13px;color:${C.fg};overflow-wrap:anywhere">${inlineHtml(v)}</td></tr>`,
118 )
119 .join("")}</table>`;
120 if ("button" in b) {
121 const btn = (l: { label: string; url: string }, primary: boolean) =>
122 `<a href="${esc(l.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;${primary ? `background:${C.accent};color:#ffffff;border:1px solid ${C.accent}` : `background:${C.card};color:${C.fg};border:1px solid ${C.border}`}" class="${primary ? "" : "btn2"}">${esc(l.label)}</a>`;
123 return `<p style="margin:6px 0 18px">${btn(b.button, true)}${b.secondary ? `&nbsp;&nbsp;${btn(b.secondary, false)}` : ""}</p>`;
124 }
125 if ("callout" in b) {
126 const [bg, fg] = C.tones[b.tone === "info" ? "blue" : b.tone === "warn" ? "amber" : "red"]!;
127 return `<div style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${bg};color:${fg};font-size:13.5px;line-height:1.55">${inlineHtml(b.callout)}</div>`;
128 }
129 if ("code" in b)
130 return `<pre class="md" style="margin:0 0 16px;padding:12px 14px;border-radius:8px;background:${C.codeBg};font-family:${MONO};font-size:12.5px;line-height:1.55;color:${C.fg};white-space:pre-wrap;overflow-wrap:anywhere">${esc(b.code)}</pre>`;
131 if ("quote" in b) return `<div class="muted" style="margin:0 0 16px;padding:2px 0 2px 14px;border-left:3px solid ${C.border};color:${C.fg2};font-size:14px;line-height:1.6">${inlineHtml(b.quote)}</div>`;
132 const marks = { ok: ["✓", C.tones.green![1]], fail: ["✕", C.tones.red![1]], skip: ["–", C.fg3], dot: ["•", C.fg3] } as const;
133 return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="box" style="margin:0 0 16px;border:1px solid ${C.border};border-radius:8px;border-collapse:separate">${b.items
134 .map(
135 (it, i) =>
136 `<tr><td style="padding:8px 0 8px 14px;${i ? `border-top:1px solid ${C.border};` : ""}width:18px;font-size:14px;font-weight:700;color:${marks[it.mark][1]};vertical-align:top">${marks[it.mark][0]}</td><td class="fg" style="padding:8px 14px 8px 8px;${i ? `border-top:1px solid ${C.border};` : ""}font-size:13.5px;color:${C.fg};overflow-wrap:anywhere">${it.url ? `<a href="${esc(it.url)}" style="color:${C.fg};text-decoration:none">${inlineHtml(it.text)}</a>` : inlineHtml(it.text)}</td></tr>`,
137 )
138 .join("")}</table>`;
139}
140
141function blockText(b: Block): string {
142 if ("p" in b) return inlineText(b.p);
143 if ("md" in b) return (stripHtmlComments(b.md).trim() || "No description provided.").split("\n").map((l) => `> ${l}`.trimEnd()).join("\n");
144 if ("kv" in b) return b.kv.map(([k, v]) => `${k}: ${inlineText(v)}`).join("\n");
145 if ("button" in b) return `${b.button.label}: ${b.button.url}${b.secondary ? `\n${b.secondary.label}: ${b.secondary.url}` : ""}`;
146 if ("callout" in b) return `! ${inlineText(b.callout)}`;
147 if ("code" in b) return b.code.split("\n").map((l) => ` ${l}`).join("\n");
148 if ("quote" in b) return `> ${inlineText(b.quote)}`;
149 const marks = { ok: "[ok]", fail: "[failed]", skip: "[skipped]", dot: "-" };
150 return b.items.map((it) => `${marks[it.mark]} ${inlineText(it.text)}${it.url ? ` <${it.url}>` : ""}`).join("\n");
151}
152
153export const REASON_TEXT: Record<Reason, string> = {
154 author: "You are receiving this because you opened this thread.",
155 comment: "You are receiving this because you commented.",
156 mention: "You are receiving this because you were mentioned.",
157 assign: "You are receiving this because you were assigned.",
158 review_requested: "You are receiving this because your review was requested.",
159 manual: "You are receiving this because you subscribed to this thread.",
160 state_change: "You are receiving this because you changed this thread's state.",
161 subscribed: "You are receiving this because you are watching this repository.",
162 ci_activity: "You are receiving this because this workflow run was triggered by you, or you watch this repository.",
163 security_alert: "This is a security notice about your Diffinery account. These can't be turned off.",
164 your_activity: "You are receiving this because of something you did on Diffinery.",
165 invitation: "You are receiving this because someone invited this email address to Diffinery. If you weren't expecting it, you can ignore this email.",
166 admin: "You are receiving this because you are an administrator.",
167 account: "You are receiving this because you have a Diffinery account.",
168 signup: "You are receiving this because someone asked to create a Diffinery account with this email address. If it wasn't you, you can ignore this email.",
169 digest: "You are receiving this because you turned on the morning digest in your notification settings.",
170};
171
172const LOGO = (url: string) => `${url}/icon-512.png`;
173
174/** Full HTML document. */
175export function toHtml(e: Email, ctx: RenderCtx): string {
176 const tone = e.eyebrow ? C.tones[e.eyebrow.tone]! : null;
177 const footerLinks = [
178 ...(e.url ? [{ label: "View it on Diffinery", url: e.url }] : []),
179 ...(ctx.unsubscribe ?? []),
180 ...(ctx.settingsUrl ? [{ label: "Notification settings", url: ctx.settingsUrl }] : []),
181 ];
182 return `<!doctype html>
183<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark"><title>${esc(e.subject)}</title>
184<style>
185body{margin:0;padding:0;-webkit-text-size-adjust:100%}
186.md p{margin:0 0 10px}.md p:last-child{margin-bottom:0}
187.md pre{background:${C.codeBg};padding:10px 12px;border-radius:6px;overflow:auto;font-family:${MONO};font-size:12.5px;line-height:1.5;white-space:pre-wrap}
188.md code{background:${C.codeBg};border-radius:4px;padding:1px 4px;font-family:${MONO};font-size:12.5px}
189.md pre code{padding:0;background:none}
190.md blockquote{margin:0 0 10px;padding:0 12px;border-left:3px solid ${C.border};color:${C.fg3}}
191.md table{border-collapse:collapse;margin:0 0 10px}.md td,.md th{border:1px solid ${C.border};padding:4px 8px}
192.md img{max-width:100%;height:auto}.md a{color:${C.accent}}.md h1,.md h2,.md h3{font-size:16px;margin:12px 0 8px}
193.md ul,.md ol{padding-left:22px;margin:0 0 10px}
194@media (prefers-color-scheme:dark){
195.bg{background:#0a0a0a !important}.card{background:#171717 !important;border-color:#2e2e2e !important}
196.fg,.fg a,h1.fg{color:#f4f4f5 !important}.muted{color:#a3a3a3 !important}.box{border-color:#2e2e2e !important}
197.box td{border-color:#2e2e2e !important}.md,.md td,.md th{color:#e4e4e7 !important}.md pre,.md code,pre.md,code.code{background:#262626 !important;color:#f4f4f5 !important}
198.btn2{background:#262626 !important;color:#f4f4f5 !important;border-color:#3f3f46 !important}}
199@media (max-width:620px){.card{border-radius:0 !important;padding:22px 18px !important}.outer{padding:0 !important}}
200</style></head>
201<body class="bg" style="margin:0;padding:0;background:${C.canvas}">
202<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${esc(e.preheader)}${"&#8199;&#65279;&#847; ".repeat(30)}</div>
203<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="bg" style="background:${C.canvas};font-family:${FONT}">
204<tr><td align="center" class="outer" style="padding:28px 12px">
205<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
206<tr><td style="padding:0 4px 14px">
207<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
208<td style="vertical-align:middle"><a href="${esc(ctx.publicUrl)}" style="text-decoration:none"><img src="${esc(LOGO(ctx.publicUrl))}" width="26" height="26" alt="" style="vertical-align:middle;border:0;border-radius:6px"> <span class="fg" style="vertical-align:middle;font-size:16px;font-weight:650;color:${C.fg};letter-spacing:-.01em">Diffinery</span></a></td>
209${e.context ? `<td align="right" style="vertical-align:middle;font-size:13px"><a href="${esc(e.context.url)}" class="muted" style="color:${C.fg3};text-decoration:none">${esc(e.context.label)}</a></td>` : ""}
210</tr></table>
211</td></tr>
212<tr><td class="card" style="background:${C.card};border:1px solid ${C.border};border-radius:12px;padding:28px 32px">
213${e.eyebrow && tone ? `<div style="margin:0 0 12px"><span style="display:inline-block;padding:3px 9px;border-radius:999px;background:${tone[0]};color:${tone[1]};font-size:12px;font-weight:600">${esc(e.eyebrow.text)}</span></div>` : ""}
214<h1 class="fg" style="margin:0 0 16px;font-size:20px;line-height:1.35;font-weight:650;color:${C.fg};letter-spacing:-.01em">${inlineHtml(e.title)}</h1>
215${e.blocks.map((b) => blockHtml(b, ctx)).join("\n")}
216</td></tr>
217<tr><td class="muted" style="padding:18px 8px 0;font-size:12px;line-height:1.6;color:${C.fg3}">
218<p style="margin:0 0 6px">${esc(REASON_TEXT[ctx.reason])}</p>
219${footerLinks.length ? `<p style="margin:0 0 6px">${footerLinks.map((l) => `<a href="${esc(l.url)}" style="color:${C.fg3};text-decoration:underline">${esc(l.label)}</a>`).join(" &middot; ")}</p>` : ""}
220<p style="margin:0">Diffinery &middot; Code hosting and CI on Cloudflare &middot; <a href="${esc(ctx.publicUrl)}" style="color:${C.fg3}">${esc(new URL(ctx.publicUrl).host)}</a></p>
221</td></tr>
222</table>
223</td></tr></table>
224</body></html>`;
225}
226
227export function toText(e: Email, ctx: RenderCtx): string {
228 const lines = [inlineText(e.title), "", ...e.blocks.map(blockText).flatMap((t) => [t, ""])];
229 lines.push("--", REASON_TEXT[ctx.reason]);
230 if (e.url) lines.push(`View it on Diffinery: ${e.url}`);
231 for (const u of ctx.unsubscribe ?? []) lines.push(`${u.label}: ${u.url}`);
232 if (ctx.settingsUrl) lines.push(`Notification settings: ${ctx.settingsUrl}`);
233 return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
234}
235
236// ---------------------------------------------------------------- the emails
237
238const at = (iso: string) => new Date(iso).toUTCString().replace("GMT", "UTC");
239const day = (iso: string) => new Date(iso).toLocaleDateString("en-US", { dateStyle: "long", timeZone: "UTC" });
240const user = (login: string) => `@${login}`;
241const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
242const ROLE_TEXT: Record<string, string> = { read: "read", triage: "triage", write: "write", maintain: "maintain", admin: "admin", member: "member", owner: "owner" };
243const roleText = (r: string) => ROLE_TEXT[r] ?? r;
244const mib = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
245const article = (w: string) => (/^[aeiou]/i.test(w) ? "an" : "a");
246
247function repoContext(pub: string, repo: string) {
248 return { label: repo, url: `${pub}/${repo}` };
249}
250
251function threadEmail(d: ThreadData, pub: string, login?: string): Email {
252 const isPull = d.kind === "pull";
253 const noun = isPull ? "pull request" : "issue";
254 const tag = `${isPull ? "PR" : "Issue"} #${d.number}`;
255 const threadUrl = `${pub}/${d.repo}/${isPull ? "pull" : "issues"}/${d.number}`;
256 const [owner, name] = d.repo.split("/") as [string, string];
257 const mdBase = { owner, repo: name, ref: d.base ?? "HEAD" };
258 const subject = `${d.event === "opened" ? "" : "Re: "}[${d.repo}] ${d.title} (${tag})`;
259 const me = (l: string) => !!login && l.toLowerCase() === login.toLowerCase();
260 let title: Inline[];
261 let eyebrow: Email["eyebrow"] = { text: isPull ? "Pull request" : "Issue", tone: isPull ? "purple" : "green" };
262 const blocks: Block[] = [];
263 const ref: Inline = { a: `${d.repo}#${d.number}`, href: threadUrl };
264 switch (d.event) {
265 case "opened":
266 title = isPull ? [user(d.actor), " wants to merge ", { code: d.head ?? "" }, " into ", { code: d.base ?? "" }] : [user(d.actor), " opened ", ref];
267 blocks.push({ p: [{ b: d.title }] });
268 if (isPull && d.intent) blocks.push({ callout: [{ b: "Intent: " }, d.intent], tone: "info" });
269 blocks.push({ md: d.body ?? "", base: mdBase });
270 if (d.commits?.length) blocks.push({ items: d.commits.slice(0, 10).map((c) => ({ mark: "dot" as const, text: [{ code: c.sha.slice(0, 7) }, " ", c.message.split("\n")[0]!.slice(0, 120)] })) });
271 if (d.reviewers?.length) blocks.push({ p: ["Review requested from ", d.reviewers.map(user).join(", "), "."], muted: true });
272 if (d.assignees?.length) blocks.push({ p: ["Assigned to ", d.assignees.map(user).join(", "), "."], muted: true });
273 blocks.push({ button: { label: `View ${noun}`, url: threadUrl }, ...(isPull ? { secondary: { label: "Files changed", url: `${threadUrl}/files` } } : {}) });
274 break;
275 case "comment":
276 title = [user(d.actor), " commented on ", ref];
277 blocks.push({ md: d.body ?? "", base: mdBase }, { button: { label: "Reply on Diffinery", url: d.url } });
278 break;
279 case "review": {
280 const s = d.state ?? "COMMENTED";
281 title = s === "APPROVED" ? [user(d.actor), " approved ", ref] : s === "CHANGES_REQUESTED" ? [user(d.actor), " requested changes on ", ref] : [user(d.actor), " reviewed ", ref];
282 eyebrow = s === "APPROVED" ? { text: "Approved", tone: "green" } : s === "CHANGES_REQUESTED" ? { text: "Changes requested", tone: "red" } : { text: "Review", tone: "purple" };
283 if (d.body?.trim()) blocks.push({ md: d.body, base: mdBase });
284 blocks.push({ button: { label: "View review", url: d.url }, secondary: { label: "Files changed", url: `${threadUrl}/files` } });
285 break;
286 }
287 case "closed":
288 title = [user(d.actor), " closed ", ref, d.stateReason === "not_planned" ? " as not planned" : isPull ? "" : " as completed"];
289 eyebrow = { text: "Closed", tone: isPull ? "red" : "purple" };
290 blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
291 break;
292 case "merged":
293 title = [user(d.actor), " merged ", ref, " into ", { code: d.base ?? "" }];
294 eyebrow = { text: "Merged", tone: "purple" };
295 blocks.push({ p: [{ b: d.title }] }, { button: { label: "View pull request", url: threadUrl } });
296 break;
297 case "reopened":
298 title = [user(d.actor), " reopened ", ref];
299 eyebrow = { text: "Reopened", tone: "green" };
300 blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
301 break;
302 case "assigned": {
303 const who = d.assignees ?? [];
304 title = who.some(me) ? [user(d.actor), " assigned you to ", ref] : [user(d.actor), " assigned ", who.map(user).join(", "), " to ", ref];
305 blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
306 break;
307 }
308 case "review_requested": {
309 const who = d.reviewers ?? [];
310 title = who.some(me) ? [user(d.actor), " requested your review on ", ref] : [user(d.actor), " requested a review from ", who.map(user).join(", "), " on ", ref];
311 eyebrow = { text: "Review requested", tone: "amber" };
312 blocks.push({ p: [{ b: d.title }] });
313 if (d.intent) blocks.push({ callout: [{ b: "Intent: " }, d.intent], tone: "info" });
314 blocks.push({ button: { label: "Review changes", url: `${threadUrl}/files` }, secondary: { label: "View pull request", url: threadUrl } });
315 break;
316 }
317 case "pushed": {
318 const n = d.commits?.length ?? 0;
319 title = [user(d.actor), ` pushed ${n ? plural(n, "commit") : "new commits"} to `, { code: d.head ?? "" }];
320 blocks.push({ p: [ref, " ", { b: d.title }] });
321 if (n) blocks.push({ items: d.commits!.slice(-10).map((c) => ({ mark: "dot" as const, text: [{ code: c.sha.slice(0, 7) }, " ", c.message.split("\n")[0]!.slice(0, 120)], url: `${pub}/${d.repo}/commit/${c.sha}` })) });
322 blocks.push({ button: { label: "View changes", url: `${threadUrl}/files` } });
323 break;
324 }
325 }
326 return { subject, preheader: stripHtmlComments(d.body ?? "").trim().slice(0, 140) || `${inlineText(title)}`, eyebrow, title, blocks, fromName: d.actor, context: repoContext(pub, d.repo), thread: `${d.repo}/${isPull ? "pull" : "issues"}/${d.number}`, url: d.url };
327}
328
329function runEmail(d: RunData, pub: string): Email {
330 const failed = d.conclusion === "failure" || d.conclusion === "timed_out";
331 const sha = d.sha.slice(0, 7);
332 const word = failed ? "failed" : d.conclusion === "success" ? "succeeded" : d.conclusion;
333 return {
334 subject: `[${d.repo}] Run ${word}: ${d.workflow} - ${d.branch} (${sha})`,
335 preheader: `${d.workflow} #${d.runNumber} ${word} on ${d.branch}: ${d.title}`,
336 eyebrow: failed ? { text: "Workflow run failed", tone: "red" } : { text: `Workflow run ${word}`, tone: d.conclusion === "success" ? "green" : "neutral" },
337 title: [{ b: d.workflow }, ` #${d.runNumber} ${word} on `, { code: d.branch }],
338 blocks: [
339 {
340 kv: [
341 ["Repository", [{ a: d.repo, href: `${pub}/${d.repo}` }]],
342 ["Commit", [{ a: sha, href: `${pub}/${d.repo}/commit/${d.sha}` }, " ", d.title]],
343 ["Triggered by", `${d.event} by @${d.actor}`],
344 ],
345 },
346 { items: d.jobs.slice(0, 20).map((j) => ({ mark: j.conclusion === "success" ? ("ok" as const) : j.conclusion === "failure" || j.conclusion === "timed_out" ? ("fail" as const) : ("skip" as const), text: [j.name, j.failure ? ` — ${j.failure.slice(0, 160)}` : j.conclusion && !["success", "failure", "skipped"].includes(j.conclusion) ? ` (${j.conclusion.replace(/_/g, " ")})` : ""], url: j.url })) },
347 { button: { label: "View workflow run", url: d.url } },
348 ],
349 context: repoContext(pub, d.repo),
350 url: d.url,
351 };
352}
353
354/** Build the email for a job. `login` is the recipient's login when they have an account. */
355export function compose(job: MailJob, pub: string, login?: string): Email {
356 const settings = `${pub}/settings/security`;
357 const securityTip: Block = { callout: ["If this wasn't you, sign out of every other browser and remove any passkey or token you don't recognize under ", { a: "Passkeys and sessions", href: settings }, ", then tell a site administrator."], tone: "warn" };
358 switch (job.kind) {
359 case "thread":
360 return threadEmail(job.data, pub, login);
361 case "run":
362 return runEmail(job.data, pub);
363 case "signin_new": {
364 const d = job.data;
365 const how = d.method === "access" ? "email recovery" : d.method === "link" ? "a one-time sign-in link" : `the passkey “${d.passkey ?? "passkey"}”`;
366 return {
367 subject: "[Diffinery] New sign-in to your account",
368 preheader: `Signed in with ${how} on ${d.device}.`,
369 eyebrow: { text: "Security", tone: "red" },
370 title: ["New sign-in to ", { b: user(login ?? "your account") }],
371 blocks: [
372 { p: [`Your Diffinery account was just signed in to from a browser or device it hasn't seen before, using ${how}.`] },
373 { kv: [["When", at(job.at)], ["Device", d.device], ...(d.location ? ([["Location", d.location]] as Array<[string, string]>) : []), ...(d.ip ? ([["IP address", d.ip]] as Array<[string, string]>) : [])] },
374 securityTip,
375 { button: { label: "Review passkeys and sessions", url: settings } },
376 ],
377 url: settings,
378 };
379 }
380 case "passkey_added":
381 return {
382 subject: "[Diffinery] A passkey was added to your account",
383 preheader: `The passkey “${job.data.name}” can now sign in to your account.`,
384 eyebrow: { text: "Security", tone: "red" },
385 title: ["A passkey was added to your account"],
386 blocks: [
387 { p: ["The passkey ", { b: `“${job.data.name}”` }, job.data.via === "signin-link" ? " was added with a one-time sign-in link from an administrator." : " was added to your account.", " It can now sign you in."] },
388 { kv: [["When", at(job.at)], ["Passkey", job.data.name]] },
389 securityTip,
390 { button: { label: "Review passkeys", url: settings } },
391 ],
392 url: settings,
393 };
394 case "passkey_removed":
395 return {
396 subject: "[Diffinery] A passkey was removed from your account",
397 preheader: `The passkey “${job.data.name}” can no longer sign in.`,
398 eyebrow: { text: "Security", tone: "red" },
399 title: ["A passkey was removed from your account"],
400 blocks: [{ p: ["The passkey ", { b: `“${job.data.name}”` }, " was removed and can no longer sign in. Browsers it signed in were signed out."] }, { kv: [["When", at(job.at)]] }, securityTip, { button: { label: "Review passkeys", url: settings } }],
401 url: settings,
402 };
403 case "token_created": {
404 const d = job.data;
405 const page = d.agent ? `${pub}/settings/agents` : `${pub}/settings/tokens`;
406 return {
407 subject: d.agent ? `[Diffinery] A token was created for the agent ${d.agent}` : "[Diffinery] A personal access token was added to your account",
408 preheader: `“${d.name}” with ${d.scopes.join(", ")} access.`,
409 eyebrow: { text: "Security", tone: "red" },
410 title: d.agent ? ["A token was created for ", { b: user(d.agent) }] : ["A personal access token was added to your account"],
411 blocks: [
412 { p: [d.agent ? "Anything holding this token can act as the agent, within its policy." : "Anything holding this token can use Git, the API, the CLI and MCP as you, within its scopes."] },
413 { kv: [["Name", d.name], ["Scopes", d.scopes.join(", ")], ["When", at(job.at)]] },
414 { callout: ["If you didn't create this token, revoke it now."], tone: "warn" },
415 { button: { label: d.agent ? "Review agents" : "Review tokens", url: page } },
416 ],
417 url: page,
418 };
419 }
420 case "token_expiring": {
421 const d = job.data;
422 const page = d.agent ? `${pub}/settings/agents` : `${pub}/settings/tokens`;
423 return {
424 subject: `[Diffinery] Your token “${d.name}” expires on ${day(d.expiresAt)}`,
425 preheader: "Create a new token before then so Git, CI and agents keep working.",
426 eyebrow: { text: "Token expiring", tone: "amber" },
427 title: ["Your token ", { b: `“${d.name}”` }, ` expires on ${day(d.expiresAt)}`],
428 blocks: [
429 { p: [d.agent ? `The token for the agent @${d.agent}` : "This personal access token", " stops working then. Create a new one and swap it in wherever the old one is used (Git credentials, CI secrets, MCP clients)."] },
430 { kv: [["Name", d.name], ["Starts with", [{ code: `${d.prefix}…` }]], ["Expires", at(d.expiresAt)]] },
431 { button: { label: "Create a new token", url: page } },
432 ],
433 url: page,
434 };
435 }
436 case "agent_created":
437 return {
438 subject: `[Diffinery] A new agent was added: ${job.data.agent}`,
439 preheader: `The agent @${job.data.agent} can act on your behalf once it has a token.`,
440 eyebrow: { text: "Security", tone: "red" },
441 title: ["A new agent was added: ", { b: user(job.data.agent) }],
442 blocks: [
443 { p: ["Agents act on behalf of their owner, capped by their policy. They can't push to protected branches, and their pull requests need a human approval."] },
444 { kv: [["Agent", user(job.data.agent)], ["When", at(job.at)]] },
445 { callout: ["If you didn't add this agent, disable it now."], tone: "warn" },
446 { button: { label: "Review agents", url: `${pub}/settings/agents` } },
447 ],
448 url: `${pub}/settings/agents`,
449 };
450 case "model_key_added": {
451 const d = job.data;
452 if (d.org) {
453 const page = `${pub}/${d.org}?tab=settings#model-keys`;
454 return {
455 subject: `[Diffinery] An ${d.provider} API key was added to ${d.org}`,
456 preheader: `Agents in ${d.org}'s repositories can now run on the organization's ${d.provider} key.`,
457 eyebrow: { text: "Security", tone: "red" },
458 title: [`An ${d.provider} API key was added to `, { b: d.org }],
459 blocks: [
460 { p: [`Agent runs that ${d.org}'s policy lets the organization pay for will use this key, and the provider bills the organization's account. Diffinery never shows the key again. You get this email because you're an owner of ${d.org}.`] },
461 { kv: [["Organization", d.org], ["Provider", d.provider], ["Ends with", [{ code: `…${d.hint}` }]], ...(d.by ? ([["Added by", user(d.by)]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
462 { callout: ["If nobody in the organization added this key, delete it here and revoke it with the provider."], tone: "warn" },
463 { button: { label: "Review the organization's model keys", url: page } },
464 ],
465 url: page,
466 };
467 }
468 const page = `${pub}/settings/model-access`;
469 return {
470 subject: `[Diffinery] Your ${d.provider} API key was added to Diffinery`,
471 preheader: `Agent tasks you start can now use your ${d.provider} key.`,
472 eyebrow: { text: "Security", tone: "red" },
473 title: [`Your ${d.provider} API key was added to Diffinery`],
474 blocks: [
475 { p: ["Agent tasks you start will use this key, and the provider bills your account for them. Diffinery never shows the key again."] },
476 { kv: [["Provider", d.provider], ["Ends with", [{ code: `…${d.hint}` }]], ["When", at(job.at)]] },
477 { callout: ["If you didn't add this key, delete it here and revoke it with the provider."], tone: "warn" },
478 { button: { label: "Review model access", url: page } },
479 ],
480 url: page,
481 };
482 }
483 case "review_limit_reached": {
484 const d = job.data;
485 const page = `${pub}/${d.repo}/settings/agent-review`;
486 return {
487 subject: `[${d.repo}] Agent review reached its daily limit`,
488 preheader: `${d.limit} reviews ran with your model key in the last 24 hours, so new ones wait.`,
489 eyebrow: { text: "Agent review", tone: "amber" },
490 title: ["Agent review on ", { a: d.repo, href: `${pub}/${d.repo}` }, " reached its daily limit"],
491 blocks: [
492 { p: [`Agent reviews on this repository use your model key. ${d.limit} ran in the last 24 hours, the daily limit you set, so pull requests get an action-required check instead of a review until older reviews fall out of the 24 hours. Then reviews run again when someone asks or pushes.`] },
493 { kv: [["Repository", d.repo], ["Daily limit", String(d.limit)], ["Last pull request", [{ a: `#${d.pr}`, href: `${pub}/${d.repo}/pull/${d.pr}` }]], ["When", at(job.at)]] },
494 { callout: ["If this is more reviews than you expected, look at which pull requests ran them on the Tasks tab. Something may be pushing often, or asking for reviews."], tone: "warn" },
495 { button: { label: "Agent review settings", url: page } },
496 ],
497 context: repoContext(pub, d.repo),
498 url: page,
499 };
500 }
501 case "deployment_review_requested": {
502 const d = job.data;
503 return {
504 subject: `[${d.repo}] Deployment to ${d.environment} is waiting for your review`,
505 preheader: `${d.requestor}'s ${d.workflow} run #${d.runNumber} on ${d.branch} waits for a required reviewer before it deploys to ${d.environment}.`,
506 eyebrow: { text: "Deployment review", tone: "amber" },
507 title: ["Deploy to ", { b: d.environment }, " in ", { a: d.repo, href: `${pub}/${d.repo}` }, "?"],
508 blocks: [
509 { p: [`You're a required reviewer for the ${d.environment} environment. Its jobs in this run wait, with no runner started and no secret handed out, until a reviewer approves or rejects them. One approval is enough.`] },
510 { kv: [["Run", [{ a: `${d.workflow} #${d.runNumber}`, href: d.url }]], ["Title", d.title || d.workflow], ["Branch", [{ code: d.branch }]], ["Commit", [{ code: d.sha.slice(0, 12) }]], ["Started by", d.requestor], ["When", at(job.at)]] },
511 { button: { label: "Review deployment", url: d.url } },
512 ],
513 context: repoContext(pub, d.repo),
514 url: d.url,
515 };
516 }
517 case "push_bypassed": {
518 const d = job.data;
519 return {
520 subject: `[${d.repo}] Push protection was bypassed`,
521 preheader: `@${d.pusher} pushed ${d.kinds.join(", ")} past push protection: ${d.reason}`,
522 eyebrow: { text: "Push protection bypassed", tone: "amber" },
523 title: [user(d.pusher), " pushed possible secrets to ", { a: d.repo, href: `${pub}/${d.repo}` }],
524 blocks: [
525 { p: ["Push protection found possible secrets, and a repository admin pushed anyway with a reason. The bypass is in the audit log."] },
526 { kv: [["Pushed by", user(d.pusher)], ["Secrets", d.kinds.join(", ")], ["Reason", d.reason], ...(d.refs?.length ? ([["Refs", d.refs.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
527 { callout: ["If any of these are real credentials, rotate them now: removing them from Git history doesn't make them safe again."], tone: "danger" },
528 { button: { label: "View repository", url: `${pub}/${d.repo}` } },
529 ],
530 context: repoContext(pub, d.repo),
531 url: `${pub}/${d.repo}`,
532 };
533 }
534 case "push_blocked": {
535 const d = job.data;
536 // A commit made without git (web editor, API or MCP) carries the files it was refused for.
537 if (d.paths)
538 return {
539 subject: `[${d.repo}] Your commit was blocked: possible secrets`,
540 preheader: `Found ${d.kinds.join(", ")}. Nothing was committed.`,
541 eyebrow: { text: "Push protection", tone: "red" },
542 title: ["Your commit to ", { a: d.repo, href: `${pub}/${d.repo}` }, " was blocked"],
543 blocks: [
544 { p: ["Push protection found what look like secrets in the files you tried to commit, so nothing was committed."] },
545 { kv: [["Found", d.kinds.join(", ")], ...(d.paths.length ? ([["Files", d.paths.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
546 { p: ["Remove the secret and commit again. If it was a real credential, rotate it. For a test fixture, add a ", { code: "diffinery:allow-secret" }, " comment to the file."] },
547 ],
548 context: repoContext(pub, d.repo),
549 url: `${pub}/${d.repo}`,
550 };
551 return {
552 subject: `[${d.repo}] Your push was blocked: possible secrets`,
553 preheader: `Found ${d.kinds.join(", ")}. Nothing from the push was kept.`,
554 eyebrow: { text: "Push protection", tone: "red" },
555 title: ["Your push to ", { a: d.repo, href: `${pub}/${d.repo}` }, " was blocked"],
556 blocks: [
557 { p: ["Push protection found what look like secrets in your commits, so nothing from this push was kept."] },
558 { kv: [["Found", d.kinds.join(", ")], ...(d.refs?.length ? ([["Refs", d.refs.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
559 { p: ["Remove the secret from your commits (for example with ", { code: "git commit --amend" }, " or an interactive rebase) and push again. If it was a real credential, rotate it. For a test fixture, add a ", { code: "diffinery:allow-secret" }, " comment to the file. A repository admin can push past the check with ", { code: "git push -o allow-secrets=<reason>" }, ", which is audited."] },
560 ],
561 context: repoContext(pub, d.repo),
562 url: `${pub}/${d.repo}`,
563 };
564 }
565 case "account_status":
566 if (job.data.status === "suspended")
567 return {
568 subject: "[Diffinery] Your account was suspended",
569 preheader: "You can't sign in, and your tokens, agents and runners don't work while it lasts.",
570 eyebrow: { text: "Account", tone: "red" },
571 title: ["Your Diffinery account was suspended"],
572 blocks: [
573 { p: ["A site administrator suspended your account. While it is suspended you can't sign in, your access tokens, agents and runners don't work, and your repositories, profile and comments are hidden from other people."] },
574 ...(job.data.reason ? [{ kv: [["Reason", job.data.reason]] as Array<[string, string]> }] : []),
575 { p: ["If you think this is a mistake, contact an administrator of this Diffinery instance."], muted: true },
576 ],
577 };
578 if (job.data.status === "unsuspended")
579 return {
580 subject: "[Diffinery] Your account is no longer suspended",
581 preheader: "You can sign in again; your tokens work again.",
582 eyebrow: { text: "Account", tone: "green" },
583 title: ["Your Diffinery account is no longer suspended"],
584 blocks: [{ p: ["A site administrator lifted the suspension of your account. Sign in with your passkey; your access tokens, agents and runners work again, and your repositories and comments are visible again."] }, { button: { label: "Sign in", url: `${pub}/login` } }],
585 url: `${pub}/login`,
586 };
587 return job.data.status === "disabled"
588 ? {
589 subject: "[Diffinery] Your account was disabled",
590 preheader: "You can't sign in, and your tokens were revoked.",
591 eyebrow: { text: "Account", tone: "red" },
592 title: ["Your Diffinery account was disabled"],
593 blocks: [{ p: ["A site administrator disabled your account. You can't sign in, and every access token you had was revoked."] }, { p: ["If you think this is a mistake, contact an administrator of this Diffinery instance."], muted: true }],
594 }
595 : {
596 subject: "[Diffinery] Your account was enabled",
597 preheader: "You can sign in with your passkey again.",
598 eyebrow: { text: "Account", tone: "green" },
599 title: ["Your Diffinery account was enabled"],
600 blocks: [{ p: ["A site administrator enabled your account again. Sign in with your passkey, and create new access tokens: the old ones were revoked when the account was disabled."] }, { button: { label: "Sign in", url: `${pub}/login` } }],
601 url: `${pub}/login`,
602 };
603 case "account_deleted":
604 return {
605 subject: "[Diffinery] Your account was deleted",
606 preheader: `@${job.data.login} and its repositories are gone.`,
607 eyebrow: { text: "Account", tone: "red" },
608 title: ["Your Diffinery account ", { b: user(job.data.login) }, " was deleted"],
609 blocks: [
610 { p: [job.data.by ? `A site administrator, ${user(job.data.by)}, deleted your account.` : "You deleted your account.", " Your repositories, container images, access tokens, passkeys and settings are gone, and every browser was signed out. Issues, pull requests and comments you wrote in other people's repositories stay, shown as written by ", { b: "ghost" }, "."] },
611 { p: [`Your username can be used again, by anyone, ${job.data.cooldownDays} days from now. This is the last email you get from this Diffinery instance.`], muted: true },
612 ],
613 };
614 case "site_admin":
615 return job.data.granted
616 ? {
617 subject: "[Diffinery] You are now a site administrator",
618 preheader: `${user(job.data.by)} made you a site administrator.`,
619 eyebrow: { text: "Security", tone: "amber" },
620 title: ["You are now a site administrator"],
621 blocks: [
622 { p: [user(job.data.by), " made you a site administrator of this Diffinery instance. You have admin on every repository and organization, and the Site administration pages in Settings."] },
623 { button: { label: "Open Site administration", url: `${pub}/settings/admin` } },
624 { callout: ["If you didn't expect this, tell another site administrator."], tone: "warn" },
625 ],
626 url: `${pub}/settings/admin`,
627 }
628 : {
629 subject: "[Diffinery] You are no longer a site administrator",
630 preheader: `${user(job.data.by)} removed your site administration.`,
631 eyebrow: { text: "Security", tone: "amber" },
632 title: ["You are no longer a site administrator"],
633 blocks: [{ p: [user(job.data.by), " removed you from the site administrators of this Diffinery instance. Your own repositories and organizations are unchanged."] }],
634 };
635 case "export_ready":
636 return {
637 subject: "[Diffinery] Your account export is ready",
638 preheader: `Download it before ${day(job.data.expiresAt)}.`,
639 eyebrow: { text: "Account", tone: "green" },
640 title: ["Your account export is ready"],
641 blocks: [
642 { p: [`The archive of your account (${mib(job.data.size)}) is ready: your profile and settings, your repositories as Git bundles, and the issues, pull requests and comments you wrote. Download it from Settings → Account before `, at(job.data.expiresAt), "."] },
643 { button: { label: "Download your export", url: job.data.url } },
644 { p: ["You'll be asked to sign in again before the download starts. If you didn't ask for an export, sign out your other browsers and tell a site administrator."], muted: true },
645 ],
646 url: job.data.url,
647 };
648 case "invite": {
649 const d = job.data;
650 const what = d.org ? `join the ${d.org} organization` : d.repo ? `collaborate on ${d.repo}` : "join Diffinery";
651 return {
652 subject: d.org ? `@${d.inviter} invited you to join the ${d.org} organization on Diffinery` : d.repo ? `@${d.inviter} invited you to collaborate on ${d.repo}` : `@${d.inviter} invited you to Diffinery`,
653 preheader: d.existing ? `Accept or decline it in Diffinery by ${day(d.expiresAt)}.` : `Accept by ${day(d.expiresAt)}. You'll sign in with a passkey, no password needed.`,
654 eyebrow: { text: "Invitation", tone: "blue" },
655 title: [{ b: user(d.inviter) }, ` invited you to ${what}`],
656 blocks: [
657 { p: ["Diffinery is where the team keeps its code: Git repositories, pull requests, issues and GitHub Actions–compatible CI, with AI agents as first-class collaborators."] },
658 { kv: [["Invited by", user(d.inviter)], ...(d.org ? ([["Organization", d.org]] as Array<[string, string]>) : []), ...(d.repo ? ([["Repository", d.repo]] as Array<[string, string]>) : []), ...(d.org || d.repo ? ([["Role", roleText(d.role)]] as Array<[string, string]>) : []), ["Expires", day(d.expiresAt)]] },
659 { button: { label: d.existing ? "View invitation" : "Accept invitation", url: d.url } },
660 d.existing
661 ? { p: ["You'll have access once you accept. Sign in to accept or decline it; if you don't recognize this, decline it or let it expire."], muted: true }
662 : { p: ["You'll pick a username and create a passkey with your fingerprint, face or screen lock. This invitation only works for this email address."], muted: true },
663 ],
664 url: d.url,
665 };
666 }
667 case "invite_accepted": {
668 const d = job.data;
669 const where = d.org ? `the ${d.org} organization` : d.repo ? d.repo : "Diffinery";
670 return {
671 subject: `[Diffinery] @${d.login} accepted your invitation`,
672 preheader: `${d.email || `@${d.login}`} joined ${where}.`,
673 eyebrow: { text: "Invitation accepted", tone: "green" },
674 title: [{ b: user(d.login) }, ` accepted your invitation to ${where}`],
675 blocks: [{ kv: [["Username", user(d.login)], ...(d.email ? ([["Email", d.email]] as Array<[string, string]>) : [])] }, { button: { label: "View profile", url: `${pub}/${d.login}` } }],
676 url: `${pub}/${d.login}`,
677 };
678 }
679 case "welcome":
680 return {
681 subject: `Welcome to Diffinery, @${job.data.login}`,
682 preheader: "Your account is ready. Here's how to get going.",
683 eyebrow: { text: "Welcome", tone: "blue" },
684 title: ["Welcome to Diffinery, ", { b: user(job.data.login) }],
685 blocks: [
686 { p: ["Your account is ready, and you sign in with the passkey you just created. A few things worth doing first:"] },
687 {
688 items: [
689 { mark: "dot", text: [{ b: "Add a second passkey" }, " on another device, or keep it in a password manager that syncs."], url: `${pub}/settings/security` },
690 { mark: "dot", text: [{ b: "Create an access token" }, " for Git over HTTPS, the CLI and MCP clients."], url: `${pub}/settings/tokens` },
691 { mark: "dot", text: [{ b: "Create a repository" }, " or push an existing one. Workflows in .github/workflows run as they do on GitHub."], url: `${pub}/new` },
692 { mark: "dot", text: [{ b: "Choose your notifications" }, ": which emails you get, and which repositories you watch."], url: `${pub}/settings/notifications` },
693 ],
694 },
695 { button: { label: "Go to your dashboard", url: pub } },
696 ],
697 url: pub,
698 };
699 case "signin_link":
700 return {
701 subject: "[Diffinery] Your one-time sign-in link",
702 preheader: `Add a new passkey before ${day(job.data.expiresAt)}.`,
703 eyebrow: { text: "Account recovery", tone: "amber" },
704 title: ["Add a new passkey to your account"],
705 blocks: [
706 { p: [user(job.data.admin), " created a one-time link so you can add a new passkey and sign in again. It works once and expires ", at(job.data.expiresAt), "."] },
707 { button: { label: "Add a passkey", url: job.data.url } },
708 { callout: ["Anyone with this link can add a passkey to your account. Don't forward this email. If you didn't ask for it, tell a site administrator."], tone: "warn" },
709 ],
710 url: job.data.url,
711 };
712 case "signup_code":
713 return {
714 // The code stays out of the subject, which the email log keeps.
715 subject: "[Diffinery] Your sign-up code",
716 preheader: `Your code is ${job.data.code}. It expires in 10 minutes.`,
717 eyebrow: { text: "Sign-up", tone: "blue" },
718 title: ["Confirm your email address"],
719 blocks: [
720 { p: ["Enter this code on the Diffinery sign-up page to confirm your email address. Then you pick a username and create a passkey."] },
721 { code: job.data.code },
722 { p: ["It works once and expires ", at(job.data.expiresAt), ". If you didn't ask to sign up, ignore this email: nothing happens without the code."], muted: true },
723 ],
724 };
725 case "email_verify":
726 return {
727 subject: "[Diffinery] Confirm your email address to get email again",
728 preheader: `Confirm before ${day(job.data.expiresAt)} and Diffinery emails you again.`,
729 eyebrow: { text: "Account", tone: "blue" },
730 title: ["Confirm your email address"],
731 blocks: [
732 { p: ["Diffinery stopped emailing ", { b: job.data.email }, " after a bounce or a spam report. You asked to get email here again: confirm that this inbox is yours and works."] },
733 { button: { label: "Confirm and get email again", url: job.data.url } },
734 { p: ["The link works while you're signed in to Diffinery as ", ...(login ? [{ b: user(login) }] : ["yourself"]), " and expires ", at(job.data.expiresAt), ". If you didn't ask for it, ignore this email: nothing changes."], muted: true },
735 ],
736 url: job.data.url,
737 };
738 case "signup_attempt":
739 return {
740 subject: "[Diffinery] Someone tried to sign up with your email address",
741 preheader: "No new account was created. Sign in with your passkey instead.",
742 eyebrow: { text: "Account", tone: "amber" },
743 title: ["Someone tried to sign up with your email address"],
744 blocks: [
745 { p: ["Someone asked to create a new Diffinery account for ", { b: job.data.email }, ", which already belongs to your account", ...(login ? [" ", { b: user(login) }] : []), ". No account was created and no code was sent."] },
746 { p: ["If it was you, sign in with your passkey. If you lost it, the sign-in page shows how to recover your account."] },
747 { button: { label: "Sign in", url: `${pub}/login` } },
748 { p: ["If it wasn't you, you can ignore this email: nobody can sign up with your address without reading your mail."], muted: true },
749 ],
750 url: `${pub}/login`,
751 };
752 case "access_request":
753 return {
754 subject: `[Diffinery] ${job.data.email} asked for access`,
755 preheader: job.data.note ? job.data.note.slice(0, 140) : `@${job.data.login} is waiting for an invite.`,
756 eyebrow: { text: "Access request", tone: "blue" },
757 title: [{ b: job.data.email }, " asked for access to Diffinery"],
758 blocks: [
759 { kv: [["Username", user(job.data.login)], ["Email", job.data.email], ["When", at(job.at)]] },
760 ...(job.data.note ? [{ quote: [job.data.note] } as Block] : []),
761 { button: { label: "Review access requests", url: `${pub}/settings/admin` } },
762 ],
763 url: `${pub}/settings/admin`,
764 };
765 case "security_report": {
766 // Only the summary: the details stay in Diffinery, behind a site administrator's sign-in.
767 const url = `${pub}/settings/admin/security-reports/${job.data.id}`;
768 return {
769 subject: "[Diffinery] New security report",
770 preheader: "Someone reported a security problem. Read it in Site administration.",
771 eyebrow: { text: "Security report", tone: "red" },
772 title: ["New security report"],
773 blocks: [
774 { p: ["Someone sent a private security report through ", { a: "/security/report", href: `${pub}/security/report` }, ". Its summary, as the reporter wrote it:"] },
775 { quote: [job.data.summary] },
776 { button: { label: "Read the report", url } },
777 { p: ["The details are only in Site administration → Security reports. Reply to the reporter from your own mailbox, and don't forward this email beyond the people who handle security."], muted: true },
778 ],
779 url,
780 };
781 }
782 case "org_membership": {
783 const d = job.data;
784 const url = `${pub}/${d.org}`;
785 const title: Inline[] = !d.role ? ["You were removed from the ", { b: d.org }, " organization"] : d.previous ? ["Your role in ", { b: d.org }, ` changed to ${roleText(d.role)}`] : ["You're now ", article(roleText(d.role)), " ", roleText(d.role), " of ", { b: d.org }];
786 return {
787 subject: `[Diffinery] ${inlineText(title)}`,
788 preheader: d.role ? `You can see ${d.org}'s repositories according to your role.` : `You no longer have access to ${d.org}'s private repositories.`,
789 eyebrow: { text: "Organization", tone: d.role ? "blue" : "neutral" },
790 title,
791 blocks: [
792 { kv: [["Organization", d.org], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : []), ...(d.by ? ([[d.role && !d.previous ? "Added by" : "Changed by", d.by]] as Array<[string, string]>) : [])] },
793 ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : []),
794 ],
795 context: { label: d.org, url },
796 url: d.role ? url : undefined,
797 };
798 }
799 case "repo_access": {
800 const d = job.data;
801 const url = `${pub}/${d.repo}`;
802 const title: Inline[] = d.role ? (d.previous ? ["Your access to ", { b: d.repo }, ` changed to ${roleText(d.role)}`] : ["You now have ", roleText(d.role), " access to ", { b: d.repo }]) : ["Your access to ", { b: d.repo }, " was removed"];
803 return {
804 subject: `[${d.repo}] ${inlineText(title)}`,
805 preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's public.",
806 eyebrow: { text: "Repository access", tone: d.role ? "blue" : "neutral" },
807 title,
808 blocks: [{ kv: [["Repository", d.repo], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: "Open repository", url } } as Block] : [])],
809 context: repoContext(pub, d.repo),
810 url: d.role ? url : undefined,
811 };
812 }
813 case "repo_created": {
814 const d = job.data;
815 const url = `${pub}/${d.repo}`;
816 const mine = job.reason === "your_activity";
817 return {
818 subject: `[${d.repo}] New ${d.visibility} repository`,
819 preheader: mine ? `Push your code to ${url}.git` : `@${d.creator} created ${d.repo}.`,
820 eyebrow: { text: "New repository", tone: "green" },
821 title: mine ? ["Your repository ", { b: d.repo }, " is ready"] : [user(d.creator), " created ", { b: d.repo }],
822 blocks: [
823 ...(d.description ? [{ p: [d.description] } as Block] : []),
824 { kv: [["Repository", [{ a: d.repo, href: url }]], ["Visibility", d.visibility], ["Default branch", d.defaultBranch], ["Created by", user(d.creator)]] },
825 ...(mine
826 ? [
827 { p: ["Push an existing repository from the command line:"] } as Block,
828 { code: `git remote add origin ${url}.git\ngit push -u origin ${d.defaultBranch}` } as Block,
829 { p: ["Use an ", { a: "access token", href: `${pub}/settings/tokens` }, " as the password. Workflows in .github/workflows start running on the first push."], muted: true } as Block,
830 ]
831 : []),
832 { button: { label: "Open repository", url } },
833 ],
834 context: repoContext(pub, d.repo),
835 url,
836 };
837 }
838 case "repo_deleted":
839 return {
840 subject: `[${job.data.repo}] Repository deleted`,
841 preheader: `@${job.data.actor} deleted ${job.data.repo}.`,
842 eyebrow: { text: "Repository deleted", tone: "red" },
843 title: [user(job.data.actor), " deleted ", { b: job.data.repo }],
844 blocks: [{ p: ["The repository, its issues, pull requests, workflow runs and settings are gone."] }, { kv: [["Repository", job.data.repo], ["Deleted by", user(job.data.actor)], ["When", at(job.at)]] }],
845 };
846 case "repo_transferred": {
847 const d = job.data;
848 const url = `${pub}/${d.to}`;
849 return {
850 subject: `[${d.to}] Repository transferred from ${d.from}`,
851 preheader: `@${d.actor} moved ${d.from} to ${d.to}.`,
852 eyebrow: { text: "Repository transferred", tone: "blue" },
853 title: [user(d.actor), " transferred ", { b: d.from }, " to ", { b: d.to }],
854 blocks: [
855 { p: ["Its history, issues, pull requests, workflow runs, secrets and settings moved with it. Links, clones and API calls that use the old name keep working for people who can still read it; update your remotes when you can:"] },
856 { code: `git remote set-url origin ${url}.git` },
857 { kv: [["From", d.from], ["To", [{ a: d.to, href: url }]], ["Transferred by", user(d.actor)], ["When", at(job.at)]] },
858 { button: { label: "Open repository", url } },
859 ],
860 context: repoContext(pub, d.to),
861 url,
862 };
863 }
864 case "digest":
865 return digestEmail(job.data, pub);
866 case "test":
867 return {
868 subject: "[Diffinery] Test email",
869 preheader: "Email from Diffinery works.",
870 eyebrow: { text: "Test", tone: "green" },
871 title: ["Email from Diffinery works"],
872 blocks: [{ p: [user(job.data.by), " sent this test email at ", at(job.at), ". It came from Cloudflare Email Sending, signed with DKIM for diffinery.com."] }, { button: { label: "Notification settings", url: `${pub}/settings/notifications` } }],
873 url: `${pub}/settings/notifications`,
874 };
875 }
876}
877
878// ---------------------------------------------------------------- the morning digest (phase 5c)
879
880/** Today by email: what needs you, your agents, and what happened since the last digest (agentic/digest.ts). */
881function digestEmail(d: DigestData, pub: string): Email {
882 const today = `${pub}/`;
883 const s = d.summary;
884 if (!s) {
885 // deliver() always fills the summary in; this only shows if something sends the job without it.
886 return { subject: "[Diffinery] Your morning digest", preheader: "Open Today on Diffinery.", eyebrow: { text: "Today", tone: "blue" }, title: ["Your morning digest"], blocks: [{ button: { label: "Open Today", url: today } }], url: today };
887 }
888 const c = s.counts;
889 const parts = [c.needsYou ? `${c.needsYou} need${c.needsYou === 1 ? "s" : ""} you` : "", c.agents ? plural(c.agents, "agent pull request") : "", c.merged ? `${c.merged} merged` : "", c.failed ? plural(c.failed, "failed run") : ""].filter(Boolean);
890 const headline = parts.length ? parts.join(", ") : "nothing needs you";
891 const blocks: Block[] = [];
892 if (!s.sections.length) blocks.push({ p: ["Nothing needs you, your agents have no open pull requests, and nothing merged, released or failed since the last digest."] });
893 for (const sec of s.sections) {
894 blocks.push({ p: [{ b: sec.title }, sec.total > sec.items.length ? ` · ${sec.items.length} of ${sec.total}` : ` · ${sec.total}`] });
895 blocks.push({ items: sec.items.map((it) => ({ mark: it.mark, text: [{ b: it.title }, ` ${it.where} · ${it.note}`], url: it.url })) });
896 }
897 blocks.push({ button: { label: "Open Today", url: today } });
898 blocks.push({ p: [`Since ${at(d.since)}, across ${plural(s.repositories, "active repository", "active repositories")}${s.incomplete ? " (some were left out; Today shows the most recently active)" : ""}.`], muted: true });
899 return {
900 subject: `[Diffinery] Today: ${headline}`,
901 preheader: s.sections.length ? s.sections.map((x) => `${x.title}: ${x.total}`).join(" · ") : "A quiet morning.",
902 eyebrow: { text: s.date, tone: c.needsYou ? "purple" : "blue" },
903 title: [c.needsYou ? `${c.needsYou} ${c.needsYou === 1 ? "thing needs" : "things need"} you today` : "Your morning digest"],
904 blocks,
905 url: today,
906 };
907}