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.

985 lines · 62.6 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 early_access: "You are receiving this because someone asked for early access to Diffinery with this email address. If it wasn't you, you can ignore this email.",
170 digest: "You are receiving this because you turned on the morning digest in your notification settings.",
171};
172
173const LOGO = (url: string) => `${url}/icon-512.png`;
174
175/** Full HTML document. */
176export function toHtml(e: Email, ctx: RenderCtx): string {
177 const tone = e.eyebrow ? C.tones[e.eyebrow.tone]! : null;
178 const footerLinks = [
179 ...(e.url ? [{ label: "View it on Diffinery", url: e.url }] : []),
180 ...(ctx.unsubscribe ?? []),
181 ...(ctx.settingsUrl ? [{ label: "Notification settings", url: ctx.settingsUrl }] : []),
182 ];
183 return `<!doctype html>
184<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>
185<style>
186body{margin:0;padding:0;-webkit-text-size-adjust:100%}
187.md p{margin:0 0 10px}.md p:last-child{margin-bottom:0}
188.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}
189.md code{background:${C.codeBg};border-radius:4px;padding:1px 4px;font-family:${MONO};font-size:12.5px}
190.md pre code{padding:0;background:none}
191.md blockquote{margin:0 0 10px;padding:0 12px;border-left:3px solid ${C.border};color:${C.fg3}}
192.md table{border-collapse:collapse;margin:0 0 10px}.md td,.md th{border:1px solid ${C.border};padding:4px 8px}
193.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}
194.md ul,.md ol{padding-left:22px;margin:0 0 10px}
195@media (prefers-color-scheme:dark){
196.bg{background:#0a0a0a !important}.card{background:#171717 !important;border-color:#2e2e2e !important}
197.fg,.fg a,h1.fg{color:#f4f4f5 !important}.muted{color:#a3a3a3 !important}.box{border-color:#2e2e2e !important}
198.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}
199.btn2{background:#262626 !important;color:#f4f4f5 !important;border-color:#3f3f46 !important}}
200@media (max-width:620px){.card{border-radius:0 !important;padding:22px 18px !important}.outer{padding:0 !important}}
201</style></head>
202<body class="bg" style="margin:0;padding:0;background:${C.canvas}">
203<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${esc(e.preheader)}${"&#8199;&#65279;&#847; ".repeat(30)}</div>
204<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="bg" style="background:${C.canvas};font-family:${FONT}">
205<tr><td align="center" class="outer" style="padding:28px 12px">
206<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px">
207<tr><td style="padding:0 4px 14px">
208<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
209<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>
210${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>` : ""}
211</tr></table>
212</td></tr>
213<tr><td class="card" style="background:${C.card};border:1px solid ${C.border};border-radius:12px;padding:28px 32px">
214${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>` : ""}
215<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>
216${e.blocks.map((b) => blockHtml(b, ctx)).join("\n")}
217</td></tr>
218<tr><td class="muted" style="padding:18px 8px 0;font-size:12px;line-height:1.6;color:${C.fg3}">
219<p style="margin:0 0 6px">${esc(REASON_TEXT[ctx.reason])}</p>
220${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>` : ""}
221<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>
222</td></tr>
223</table>
224</td></tr></table>
225</body></html>`;
226}
227
228export function toText(e: Email, ctx: RenderCtx): string {
229 const lines = [inlineText(e.title), "", ...e.blocks.map(blockText).flatMap((t) => [t, ""])];
230 lines.push("--", REASON_TEXT[ctx.reason]);
231 if (e.url) lines.push(`View it on Diffinery: ${e.url}`);
232 for (const u of ctx.unsubscribe ?? []) lines.push(`${u.label}: ${u.url}`);
233 if (ctx.settingsUrl) lines.push(`Notification settings: ${ctx.settingsUrl}`);
234 return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
235}
236
237// ---------------------------------------------------------------- the emails
238
239const at = (iso: string) => new Date(iso).toUTCString().replace("GMT", "UTC");
240const day = (iso: string) => new Date(iso).toLocaleDateString("en-US", { dateStyle: "long", timeZone: "UTC" });
241const user = (login: string) => `@${login}`;
242const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
243const ROLE_TEXT: Record<string, string> = { read: "read", triage: "triage", write: "write", maintain: "maintain", admin: "admin", member: "member", owner: "owner" };
244const roleText = (r: string) => ROLE_TEXT[r] ?? r;
245const mib = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
246const article = (w: string) => (/^[aeiou]/i.test(w) ? "an" : "a");
247
248/** The domain email is sent from: MAIL_FROM's, else PUBLIC_URL's host. */
249function mailDomain(pub: string, mailFrom?: string): string {
250 const at = mailFrom?.lastIndexOf("@") ?? -1;
251 if (mailFrom && at >= 0) return mailFrom.slice(at + 1).replace(/>.*$/, "").trim();
252 try {
253 return new URL(pub).hostname;
254 } catch {
255 return pub;
256 }
257}
258
259function repoContext(pub: string, repo: string) {
260 return { label: repo, url: `${pub}/${repo}` };
261}
262
263function threadEmail(d: ThreadData, pub: string, login?: string): Email {
264 const isPull = d.kind === "pull";
265 const noun = isPull ? "pull request" : "issue";
266 const tag = `${isPull ? "PR" : "Issue"} #${d.number}`;
267 const threadUrl = `${pub}/${d.repo}/${isPull ? "pull" : "issues"}/${d.number}`;
268 const [owner, name] = d.repo.split("/") as [string, string];
269 const mdBase = { owner, repo: name, ref: d.base ?? "HEAD" };
270 const subject = `${d.event === "opened" ? "" : "Re: "}[${d.repo}] ${d.title} (${tag})`;
271 const me = (l: string) => !!login && l.toLowerCase() === login.toLowerCase();
272 let title: Inline[];
273 let eyebrow: Email["eyebrow"] = { text: isPull ? "Pull request" : "Issue", tone: isPull ? "purple" : "green" };
274 const blocks: Block[] = [];
275 const ref: Inline = { a: `${d.repo}#${d.number}`, href: threadUrl };
276 switch (d.event) {
277 case "opened":
278 title = isPull ? [user(d.actor), " wants to merge ", { code: d.head ?? "" }, " into ", { code: d.base ?? "" }] : [user(d.actor), " opened ", ref];
279 blocks.push({ p: [{ b: d.title }] });
280 if (isPull && d.intent) blocks.push({ callout: [{ b: "Intent: " }, d.intent], tone: "info" });
281 blocks.push({ md: d.body ?? "", base: mdBase });
282 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)] })) });
283 if (d.reviewers?.length) blocks.push({ p: ["Review requested from ", d.reviewers.map(user).join(", "), "."], muted: true });
284 if (d.assignees?.length) blocks.push({ p: ["Assigned to ", d.assignees.map(user).join(", "), "."], muted: true });
285 blocks.push({ button: { label: `View ${noun}`, url: threadUrl }, ...(isPull ? { secondary: { label: "Files changed", url: `${threadUrl}/files` } } : {}) });
286 break;
287 case "comment":
288 title = [user(d.actor), " commented on ", ref];
289 blocks.push({ md: d.body ?? "", base: mdBase }, { button: { label: "Reply on Diffinery", url: d.url } });
290 break;
291 case "review": {
292 const s = d.state ?? "COMMENTED";
293 title = s === "APPROVED" ? [user(d.actor), " approved ", ref] : s === "CHANGES_REQUESTED" ? [user(d.actor), " requested changes on ", ref] : [user(d.actor), " reviewed ", ref];
294 eyebrow = s === "APPROVED" ? { text: "Approved", tone: "green" } : s === "CHANGES_REQUESTED" ? { text: "Changes requested", tone: "red" } : { text: "Review", tone: "purple" };
295 if (d.body?.trim()) blocks.push({ md: d.body, base: mdBase });
296 blocks.push({ button: { label: "View review", url: d.url }, secondary: { label: "Files changed", url: `${threadUrl}/files` } });
297 break;
298 }
299 case "closed":
300 title = [user(d.actor), " closed ", ref, d.stateReason === "not_planned" ? " as not planned" : isPull ? "" : " as completed"];
301 eyebrow = { text: "Closed", tone: isPull ? "red" : "purple" };
302 blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
303 break;
304 case "merged":
305 title = [user(d.actor), " merged ", ref, " into ", { code: d.base ?? "" }];
306 eyebrow = { text: "Merged", tone: "purple" };
307 blocks.push({ p: [{ b: d.title }] }, { button: { label: "View pull request", url: threadUrl } });
308 break;
309 case "reopened":
310 title = [user(d.actor), " reopened ", ref];
311 eyebrow = { text: "Reopened", tone: "green" };
312 blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
313 break;
314 case "assigned": {
315 const who = d.assignees ?? [];
316 title = who.some(me) ? [user(d.actor), " assigned you to ", ref] : [user(d.actor), " assigned ", who.map(user).join(", "), " to ", ref];
317 blocks.push({ p: [{ b: d.title }] }, { button: { label: `View ${noun}`, url: threadUrl } });
318 break;
319 }
320 case "review_requested": {
321 const who = d.reviewers ?? [];
322 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];
323 eyebrow = { text: "Review requested", tone: "amber" };
324 blocks.push({ p: [{ b: d.title }] });
325 if (d.intent) blocks.push({ callout: [{ b: "Intent: " }, d.intent], tone: "info" });
326 blocks.push({ button: { label: "Review changes", url: `${threadUrl}/files` }, secondary: { label: "View pull request", url: threadUrl } });
327 break;
328 }
329 case "pushed": {
330 const n = d.commits?.length ?? 0;
331 title = [user(d.actor), ` pushed ${n ? plural(n, "commit") : "new commits"} to `, { code: d.head ?? "" }];
332 blocks.push({ p: [ref, " ", { b: d.title }] });
333 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}` })) });
334 blocks.push({ button: { label: "View changes", url: `${threadUrl}/files` } });
335 break;
336 }
337 }
338 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 };
339}
340
341function runEmail(d: RunData, pub: string): Email {
342 const failed = d.conclusion === "failure" || d.conclusion === "timed_out";
343 const sha = d.sha.slice(0, 7);
344 const word = failed ? "failed" : d.conclusion === "success" ? "succeeded" : d.conclusion;
345 return {
346 subject: `[${d.repo}] Run ${word}: ${d.workflow} - ${d.branch} (${sha})`,
347 preheader: `${d.workflow} #${d.runNumber} ${word} on ${d.branch}: ${d.title}`,
348 eyebrow: failed ? { text: "Workflow run failed", tone: "red" } : { text: `Workflow run ${word}`, tone: d.conclusion === "success" ? "green" : "neutral" },
349 title: [{ b: d.workflow }, ` #${d.runNumber} ${word} on `, { code: d.branch }],
350 blocks: [
351 {
352 kv: [
353 ["Repository", [{ a: d.repo, href: `${pub}/${d.repo}` }]],
354 ["Commit", [{ a: sha, href: `${pub}/${d.repo}/commit/${d.sha}` }, " ", d.title]],
355 ["Triggered by", `${d.event} by @${d.actor}`],
356 ],
357 },
358 { 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 })) },
359 { button: { label: "View workflow run", url: d.url } },
360 ],
361 context: repoContext(pub, d.repo),
362 url: d.url,
363 };
364}
365
366/** Build the email for a job. `login` is the recipient's login when they have an account. */
367/** `mailFrom`: the sender (MAIL_FROM), named in the test email; without it, PUBLIC_URL's host. */
368export function compose(job: MailJob, pub: string, login?: string, mailFrom?: string): Email {
369 const settings = `${pub}/settings/security`;
370 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" };
371 switch (job.kind) {
372 case "thread":
373 return threadEmail(job.data, pub, login);
374 case "run":
375 return runEmail(job.data, pub);
376 case "signin_new": {
377 const d = job.data;
378 const how = d.method === "access" ? "email recovery" : d.method === "link" ? "a one-time sign-in link" : `the passkey “${d.passkey ?? "passkey"}”`;
379 return {
380 subject: "[Diffinery] New sign-in to your account",
381 preheader: `Signed in with ${how} on ${d.device}.`,
382 eyebrow: { text: "Security", tone: "red" },
383 title: ["New sign-in to ", { b: user(login ?? "your account") }],
384 blocks: [
385 { p: [`Your Diffinery account was just signed in to from a browser or device it hasn't seen before, using ${how}.`] },
386 { 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]>) : [])] },
387 securityTip,
388 { button: { label: "Review passkeys and sessions", url: settings } },
389 ],
390 url: settings,
391 };
392 }
393 case "passkey_added":
394 return {
395 subject: "[Diffinery] A passkey was added to your account",
396 preheader: `The passkey “${job.data.name}” can now sign in to your account.`,
397 eyebrow: { text: "Security", tone: "red" },
398 title: ["A passkey was added to your account"],
399 blocks: [
400 { 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."] },
401 { kv: [["When", at(job.at)], ["Passkey", job.data.name]] },
402 securityTip,
403 { button: { label: "Review passkeys", url: settings } },
404 ],
405 url: settings,
406 };
407 case "passkey_removed":
408 return {
409 subject: "[Diffinery] A passkey was removed from your account",
410 preheader: `The passkey “${job.data.name}” can no longer sign in.`,
411 eyebrow: { text: "Security", tone: "red" },
412 title: ["A passkey was removed from your account"],
413 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 } }],
414 url: settings,
415 };
416 case "token_created": {
417 const d = job.data;
418 const page = d.agent ? `${pub}/settings/agents` : `${pub}/settings/tokens`;
419 return {
420 subject: d.agent ? `[Diffinery] A token was created for the agent ${d.agent}` : "[Diffinery] A personal access token was added to your account",
421 preheader: `“${d.name}” with ${d.scopes.join(", ")} access.`,
422 eyebrow: { text: "Security", tone: "red" },
423 title: d.agent ? ["A token was created for ", { b: user(d.agent) }] : ["A personal access token was added to your account"],
424 blocks: [
425 { 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."] },
426 { kv: [["Name", d.name], ["Scopes", d.scopes.join(", ")], ["When", at(job.at)]] },
427 { callout: ["If you didn't create this token, revoke it now."], tone: "warn" },
428 { button: { label: d.agent ? "Review agents" : "Review tokens", url: page } },
429 ],
430 url: page,
431 };
432 }
433 case "token_expiring": {
434 const d = job.data;
435 const page = d.agent ? `${pub}/settings/agents` : `${pub}/settings/tokens`;
436 return {
437 subject: `[Diffinery] Your token “${d.name}” expires on ${day(d.expiresAt)}`,
438 preheader: "Create a new token before then so Git, CI and agents keep working.",
439 eyebrow: { text: "Token expiring", tone: "amber" },
440 title: ["Your token ", { b: `“${d.name}”` }, ` expires on ${day(d.expiresAt)}`],
441 blocks: [
442 { 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)."] },
443 { kv: [["Name", d.name], ["Starts with", [{ code: `${d.prefix}…` }]], ["Expires", at(d.expiresAt)]] },
444 { button: { label: "Create a new token", url: page } },
445 ],
446 url: page,
447 };
448 }
449 case "agent_created":
450 return {
451 subject: `[Diffinery] A new agent was added: ${job.data.agent}`,
452 preheader: `The agent @${job.data.agent} can act on your behalf once it has a token.`,
453 eyebrow: { text: "Security", tone: "red" },
454 title: ["A new agent was added: ", { b: user(job.data.agent) }],
455 blocks: [
456 { 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."] },
457 { kv: [["Agent", user(job.data.agent)], ["When", at(job.at)]] },
458 { callout: ["If you didn't add this agent, disable it now."], tone: "warn" },
459 { button: { label: "Review agents", url: `${pub}/settings/agents` } },
460 ],
461 url: `${pub}/settings/agents`,
462 };
463 case "model_key_added": {
464 const d = job.data;
465 if (d.org) {
466 const page = `${pub}/${d.org}?tab=settings#model-keys`;
467 return {
468 subject: `[Diffinery] An ${d.provider} API key was added to ${d.org}`,
469 preheader: `Agents in ${d.org}'s repositories can now run on the organization's ${d.provider} key.`,
470 eyebrow: { text: "Security", tone: "red" },
471 title: [`An ${d.provider} API key was added to `, { b: d.org }],
472 blocks: [
473 { 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}.`] },
474 { 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)]] },
475 { callout: ["If nobody in the organization added this key, delete it here and revoke it with the provider."], tone: "warn" },
476 { button: { label: "Review the organization's model keys", url: page } },
477 ],
478 url: page,
479 };
480 }
481 const page = `${pub}/settings/model-access`;
482 return {
483 subject: `[Diffinery] Your ${d.provider} API key was added to Diffinery`,
484 preheader: `Agent tasks you start can now use your ${d.provider} key.`,
485 eyebrow: { text: "Security", tone: "red" },
486 title: [`Your ${d.provider} API key was added to Diffinery`],
487 blocks: [
488 { p: ["Agent tasks you start will use this key, and the provider bills your account for them. Diffinery never shows the key again."] },
489 { kv: [["Provider", d.provider], ["Ends with", [{ code: `…${d.hint}` }]], ["When", at(job.at)]] },
490 { callout: ["If you didn't add this key, delete it here and revoke it with the provider."], tone: "warn" },
491 { button: { label: "Review model access", url: page } },
492 ],
493 url: page,
494 };
495 }
496 case "review_limit_reached": {
497 const d = job.data;
498 const page = `${pub}/${d.repo}/settings/agent-review`;
499 return {
500 subject: `[${d.repo}] Agent review reached its daily limit`,
501 preheader: `${d.limit} reviews ran with your model key in the last 24 hours, so new ones wait.`,
502 eyebrow: { text: "Agent review", tone: "amber" },
503 title: ["Agent review on ", { a: d.repo, href: `${pub}/${d.repo}` }, " reached its daily limit"],
504 blocks: [
505 { 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.`] },
506 { 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)]] },
507 { 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" },
508 { button: { label: "Agent review settings", url: page } },
509 ],
510 context: repoContext(pub, d.repo),
511 url: page,
512 };
513 }
514 case "deployment_review_requested": {
515 const d = job.data;
516 return {
517 subject: `[${d.repo}] Deployment to ${d.environment} is waiting for your review`,
518 preheader: `${d.requestor}'s ${d.workflow} run #${d.runNumber} on ${d.branch} waits for a required reviewer before it deploys to ${d.environment}.`,
519 eyebrow: { text: "Deployment review", tone: "amber" },
520 title: ["Deploy to ", { b: d.environment }, " in ", { a: d.repo, href: `${pub}/${d.repo}` }, "?"],
521 blocks: [
522 { 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.`] },
523 { 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)]] },
524 { button: { label: "Review deployment", url: d.url } },
525 ],
526 context: repoContext(pub, d.repo),
527 url: d.url,
528 };
529 }
530 case "push_bypassed": {
531 const d = job.data;
532 return {
533 subject: `[${d.repo}] Push protection was bypassed`,
534 preheader: `@${d.pusher} pushed ${d.kinds.join(", ")} past push protection: ${d.reason}`,
535 eyebrow: { text: "Push protection bypassed", tone: "amber" },
536 title: [user(d.pusher), " pushed possible secrets to ", { a: d.repo, href: `${pub}/${d.repo}` }],
537 blocks: [
538 { p: ["Push protection found possible secrets, and a repository admin pushed anyway with a reason. The bypass is in the audit log."] },
539 { 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)]] },
540 { callout: ["If any of these are real credentials, rotate them now: removing them from Git history doesn't make them safe again."], tone: "danger" },
541 { button: { label: "View repository", url: `${pub}/${d.repo}` } },
542 ],
543 context: repoContext(pub, d.repo),
544 url: `${pub}/${d.repo}`,
545 };
546 }
547 case "push_blocked": {
548 const d = job.data;
549 // A commit made without git (web editor, API or MCP) carries the files it was refused for.
550 if (d.paths)
551 return {
552 subject: `[${d.repo}] Your commit was blocked: possible secrets`,
553 preheader: `Found ${d.kinds.join(", ")}. Nothing was committed.`,
554 eyebrow: { text: "Push protection", tone: "red" },
555 title: ["Your commit to ", { a: d.repo, href: `${pub}/${d.repo}` }, " was blocked"],
556 blocks: [
557 { p: ["Push protection found what look like secrets in the files you tried to commit, so nothing was committed."] },
558 { kv: [["Found", d.kinds.join(", ")], ...(d.paths.length ? ([["Files", d.paths.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
559 { 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."] },
560 ],
561 context: repoContext(pub, d.repo),
562 url: `${pub}/${d.repo}`,
563 };
564 return {
565 subject: `[${d.repo}] Your push was blocked: possible secrets`,
566 preheader: `Found ${d.kinds.join(", ")}. Nothing from the push was kept.`,
567 eyebrow: { text: "Push protection", tone: "red" },
568 title: ["Your push to ", { a: d.repo, href: `${pub}/${d.repo}` }, " was blocked"],
569 blocks: [
570 { p: ["Push protection found what look like secrets in your commits, so nothing from this push was kept."] },
571 { kv: [["Found", d.kinds.join(", ")], ...(d.refs?.length ? ([["Refs", d.refs.join(", ")]] as Array<[string, string]>) : []), ["When", at(job.at)]] },
572 { 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."] },
573 ],
574 context: repoContext(pub, d.repo),
575 url: `${pub}/${d.repo}`,
576 };
577 }
578 case "account_status":
579 if (job.data.status === "suspended")
580 return {
581 subject: "[Diffinery] Your account was suspended",
582 preheader: "You can't sign in, and your tokens, agents and runners don't work while it lasts.",
583 eyebrow: { text: "Account", tone: "red" },
584 title: ["Your Diffinery account was suspended"],
585 blocks: [
586 { 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."] },
587 ...(job.data.reason ? [{ kv: [["Reason", job.data.reason]] as Array<[string, string]> }] : []),
588 { p: ["If you think this is a mistake, contact an administrator of this Diffinery instance."], muted: true },
589 ],
590 };
591 if (job.data.status === "unsuspended")
592 return {
593 subject: "[Diffinery] Your account is no longer suspended",
594 preheader: "You can sign in again; your tokens work again.",
595 eyebrow: { text: "Account", tone: "green" },
596 title: ["Your Diffinery account is no longer suspended"],
597 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` } }],
598 url: `${pub}/login`,
599 };
600 return job.data.status === "disabled"
601 ? {
602 subject: "[Diffinery] Your account was disabled",
603 preheader: "You can't sign in, and your tokens were revoked.",
604 eyebrow: { text: "Account", tone: "red" },
605 title: ["Your Diffinery account was disabled"],
606 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 }],
607 }
608 : {
609 subject: "[Diffinery] Your account was enabled",
610 preheader: "You can sign in with your passkey again.",
611 eyebrow: { text: "Account", tone: "green" },
612 title: ["Your Diffinery account was enabled"],
613 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` } }],
614 url: `${pub}/login`,
615 };
616 case "account_deleted":
617 return {
618 subject: "[Diffinery] Your account was deleted",
619 preheader: `@${job.data.login} and its repositories are gone.`,
620 eyebrow: { text: "Account", tone: "red" },
621 title: ["Your Diffinery account ", { b: user(job.data.login) }, " was deleted"],
622 blocks: [
623 { 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" }, "."] },
624 { 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 },
625 ],
626 };
627 case "site_admin":
628 return job.data.granted
629 ? {
630 subject: "[Diffinery] You are now a site administrator",
631 preheader: `${user(job.data.by)} made you a site administrator.`,
632 eyebrow: { text: "Security", tone: "amber" },
633 title: ["You are now a site administrator"],
634 blocks: [
635 { 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."] },
636 { button: { label: "Open Site administration", url: `${pub}/settings/admin` } },
637 { callout: ["If you didn't expect this, tell another site administrator."], tone: "warn" },
638 ],
639 url: `${pub}/settings/admin`,
640 }
641 : {
642 subject: "[Diffinery] You are no longer a site administrator",
643 preheader: `${user(job.data.by)} removed your site administration.`,
644 eyebrow: { text: "Security", tone: "amber" },
645 title: ["You are no longer a site administrator"],
646 blocks: [{ p: [user(job.data.by), " removed you from the site administrators of this Diffinery instance. Your own repositories and organizations are unchanged."] }],
647 };
648 case "export_ready":
649 return {
650 subject: "[Diffinery] Your account export is ready",
651 preheader: `Download it before ${day(job.data.expiresAt)}.`,
652 eyebrow: { text: "Account", tone: "green" },
653 title: ["Your account export is ready"],
654 blocks: [
655 { 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), "."] },
656 { button: { label: "Download your export", url: job.data.url } },
657 { 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 },
658 ],
659 url: job.data.url,
660 };
661 case "invite": {
662 const d = job.data;
663 const what = d.org ? `join the ${d.org} organization` : d.repo ? `collaborate on ${d.repo}` : "join Diffinery";
664 return {
665 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`,
666 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.`,
667 eyebrow: { text: "Invitation", tone: "blue" },
668 title: [{ b: user(d.inviter) }, ` invited you to ${what}`],
669 blocks: [
670 { 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."] },
671 { 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)]] },
672 { button: { label: d.existing ? "View invitation" : "Accept invitation", url: d.url } },
673 d.existing
674 ? { 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 }
675 : { 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 },
676 ],
677 url: d.url,
678 };
679 }
680 case "invite_accepted": {
681 const d = job.data;
682 const where = d.org ? `the ${d.org} organization` : d.repo ? d.repo : "Diffinery";
683 return {
684 subject: `[Diffinery] @${d.login} accepted your invitation`,
685 preheader: `${d.email || `@${d.login}`} joined ${where}.`,
686 eyebrow: { text: "Invitation accepted", tone: "green" },
687 title: [{ b: user(d.login) }, ` accepted your invitation to ${where}`],
688 blocks: [{ kv: [["Username", user(d.login)], ...(d.email ? ([["Email", d.email]] as Array<[string, string]>) : [])] }, { button: { label: "View profile", url: `${pub}/${d.login}` } }],
689 url: `${pub}/${d.login}`,
690 };
691 }
692 case "welcome":
693 return {
694 subject: `Welcome to Diffinery, @${job.data.login}`,
695 preheader: "Your account is ready. Here's how to get going.",
696 eyebrow: { text: "Welcome", tone: "blue" },
697 title: ["Welcome to Diffinery, ", { b: user(job.data.login) }],
698 blocks: [
699 { p: ["Your account is ready, and you sign in with the passkey you just created. A few things worth doing first:"] },
700 {
701 items: [
702 { mark: "dot", text: [{ b: "Add a second passkey" }, " on another device, or keep it in a password manager that syncs."], url: `${pub}/settings/security` },
703 { mark: "dot", text: [{ b: "Create an access token" }, " for Git over HTTPS, the CLI and MCP clients."], url: `${pub}/settings/tokens` },
704 { 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` },
705 { mark: "dot", text: [{ b: "Choose your notifications" }, ": which emails you get, and which repositories you watch."], url: `${pub}/settings/notifications` },
706 ],
707 },
708 { button: { label: "Go to your dashboard", url: pub } },
709 ],
710 url: pub,
711 };
712 case "signin_link":
713 return {
714 subject: "[Diffinery] Your one-time sign-in link",
715 preheader: `Add a new passkey before ${day(job.data.expiresAt)}.`,
716 eyebrow: { text: "Account recovery", tone: "amber" },
717 title: ["Add a new passkey to your account"],
718 blocks: [
719 { 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), "."] },
720 { button: { label: "Add a passkey", url: job.data.url } },
721 { 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" },
722 ],
723 url: job.data.url,
724 };
725 case "signup_code":
726 return {
727 // The code stays out of the subject, which the email log keeps.
728 subject: "[Diffinery] Your sign-up code",
729 preheader: `Your code is ${job.data.code}. It expires in 10 minutes.`,
730 eyebrow: { text: "Sign-up", tone: "blue" },
731 title: ["Confirm your email address"],
732 blocks: [
733 { p: ["Enter this code on the Diffinery sign-up page to confirm your email address. Then you pick a username and create a passkey."] },
734 { code: job.data.code },
735 { 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 },
736 ],
737 };
738 case "email_verify":
739 return {
740 subject: "[Diffinery] Confirm your email address to get email again",
741 preheader: `Confirm before ${day(job.data.expiresAt)} and Diffinery emails you again.`,
742 eyebrow: { text: "Account", tone: "blue" },
743 title: ["Confirm your email address"],
744 blocks: [
745 { 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."] },
746 { button: { label: "Confirm and get email again", url: job.data.url } },
747 { 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 },
748 ],
749 url: job.data.url,
750 };
751 case "signup_attempt":
752 return {
753 subject: "[Diffinery] Someone tried to sign up with your email address",
754 preheader: "No new account was created. Sign in with your passkey instead.",
755 eyebrow: { text: "Account", tone: "amber" },
756 title: ["Someone tried to sign up with your email address"],
757 blocks: [
758 { 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."] },
759 { p: ["If it was you, sign in with your passkey. If you lost it, the sign-in page shows how to recover your account."] },
760 { button: { label: "Sign in", url: `${pub}/login` } },
761 { p: ["If it wasn't you, you can ignore this email: nobody can sign up with your address without reading your mail."], muted: true },
762 ],
763 url: `${pub}/login`,
764 };
765 case "early_access_confirm":
766 return {
767 subject: "[Diffinery] Confirm your early access request",
768 preheader: `Confirm before ${day(job.data.expiresAt)} to join the list. Nothing happens without it.`,
769 eyebrow: { text: "Early access", tone: "blue" },
770 title: ["Confirm your early access request"],
771 blocks: [
772 { p: ["Thanks for asking for early access to Diffinery. Confirm that this is your email address and your request joins the list the team reviews."] },
773 { button: { label: "Confirm my request", url: job.data.url } },
774 { p: ["When there's a place for you, we'll email you an invitation to pick a username and create a passkey. There are no passwords."] },
775 { p: ["The link works once and expires ", at(job.data.expiresAt), ". If you didn't ask for early access, ignore this email: nothing is kept unless the link is opened, and the unconfirmed request is deleted after a week."], muted: true },
776 ],
777 url: job.data.url,
778 };
779 case "early_access_remove":
780 return {
781 subject: "[Diffinery] Remove your early access request",
782 preheader: `Remove it before ${day(job.data.expiresAt)}. Nothing changes unless you do.`,
783 eyebrow: { text: "Early access", tone: "amber" },
784 title: ["Remove your early access request"],
785 blocks: [
786 { p: ["Someone asked to remove the early access request for this email address. Open the link and press the button to delete it and everything stored about it."] },
787 { button: { label: "Remove my request", url: job.data.url } },
788 { p: ["The link works once and expires ", at(job.data.expiresAt), ". If you didn't ask for this, ignore this email: your request stays as it is."], muted: true },
789 ],
790 url: job.data.url,
791 };
792 case "early_access_known": {
793 const d = job.data;
794 if (d.variant === "account")
795 return {
796 subject: "[Diffinery] You already have a Diffinery account",
797 preheader: "No request was needed. Sign in with your passkey instead.",
798 eyebrow: { text: "Early access", tone: "blue" },
799 title: ["You already have an account"],
800 blocks: [
801 { p: ["Someone asked for early access to Diffinery with this email address, which already belongs to your account", ...(login ? [" ", { b: user(login) }] : []), ". Nothing changed and no request was stored."] },
802 { button: { label: "Sign in", url: `${pub}/login` } },
803 { p: ["If you lost your passkey, the sign-in page shows how to recover your account. If it wasn't you, you can ignore this email."], muted: true },
804 ],
805 url: `${pub}/login`,
806 };
807 if (d.variant === "invited")
808 return {
809 subject: "[Diffinery] You're already invited",
810 preheader: `We sent your invitation on ${day(d.since)}. Look for it in this inbox.`,
811 eyebrow: { text: "Early access", tone: "green" },
812 title: ["You're already invited"],
813 blocks: [
814 { p: ["You asked for early access again, but we already sent this address an invitation on ", { b: day(d.since) }, ". Look for an email titled \"invited you to Diffinery\", and check your spam folder too."] },
815 { p: ["Opening its link lets you pick a username and create a passkey. Invitations expire after 14 days; if yours has, let the person named in it know and they can send a new one."], muted: true },
816 ],
817 };
818 return {
819 subject: "[Diffinery] We already have your early access request",
820 preheader: "There's nothing else to do. We'll email you when there's a place.",
821 eyebrow: { text: "Early access", tone: "blue" },
822 title: ["We already have your request"],
823 blocks: [
824 { p: ["You asked for early access to Diffinery again. Your request from ", { b: day(d.since) }, " is already on the list, so there's nothing else to do."] },
825 { p: ["When there's a place for you, we'll email you an invitation to pick a username and create a passkey."] },
826 { p: ["If it wasn't you who asked again, you can ignore this email."], muted: true },
827 ],
828 };
829 }
830 case "access_request":
831 return {
832 subject: `[Diffinery] ${job.data.email} asked for access`,
833 preheader: job.data.note ? job.data.note.slice(0, 140) : `@${job.data.login} is waiting for an invite.`,
834 eyebrow: { text: "Access request", tone: "blue" },
835 title: [{ b: job.data.email }, " asked for access to Diffinery"],
836 blocks: [
837 { kv: [["Username", user(job.data.login)], ["Email", job.data.email], ["When", at(job.at)]] },
838 ...(job.data.note ? [{ quote: [job.data.note] } as Block] : []),
839 { button: { label: "Review access requests", url: `${pub}/settings/admin` } },
840 ],
841 url: `${pub}/settings/admin`,
842 };
843 case "security_report": {
844 // Only the summary: the details stay in Diffinery, behind a site administrator's sign-in.
845 const url = `${pub}/settings/admin/security-reports/${job.data.id}`;
846 return {
847 subject: "[Diffinery] New security report",
848 preheader: "Someone reported a security problem. Read it in Site administration.",
849 eyebrow: { text: "Security report", tone: "red" },
850 title: ["New security report"],
851 blocks: [
852 { p: ["Someone sent a private security report through ", { a: "/security/report", href: `${pub}/security/report` }, ". Its summary, as the reporter wrote it:"] },
853 { quote: [job.data.summary] },
854 { button: { label: "Read the report", url } },
855 { 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 },
856 ],
857 url,
858 };
859 }
860 case "org_membership": {
861 const d = job.data;
862 const url = `${pub}/${d.org}`;
863 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 }];
864 return {
865 subject: `[Diffinery] ${inlineText(title)}`,
866 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.`,
867 eyebrow: { text: "Organization", tone: d.role ? "blue" : "neutral" },
868 title,
869 blocks: [
870 { 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]>) : [])] },
871 ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : []),
872 ],
873 context: { label: d.org, url },
874 url: d.role ? url : undefined,
875 };
876 }
877 case "repo_access": {
878 const d = job.data;
879 const url = `${pub}/${d.repo}`;
880 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"];
881 return {
882 subject: `[${d.repo}] ${inlineText(title)}`,
883 preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's public.",
884 eyebrow: { text: "Repository access", tone: d.role ? "blue" : "neutral" },
885 title,
886 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] : [])],
887 context: repoContext(pub, d.repo),
888 url: d.role ? url : undefined,
889 };
890 }
891 case "repo_created": {
892 const d = job.data;
893 const url = `${pub}/${d.repo}`;
894 const mine = job.reason === "your_activity";
895 return {
896 subject: `[${d.repo}] New ${d.visibility} repository`,
897 preheader: mine ? `Push your code to ${url}.git` : `@${d.creator} created ${d.repo}.`,
898 eyebrow: { text: "New repository", tone: "green" },
899 title: mine ? ["Your repository ", { b: d.repo }, " is ready"] : [user(d.creator), " created ", { b: d.repo }],
900 blocks: [
901 ...(d.description ? [{ p: [d.description] } as Block] : []),
902 { kv: [["Repository", [{ a: d.repo, href: url }]], ["Visibility", d.visibility], ["Default branch", d.defaultBranch], ["Created by", user(d.creator)]] },
903 ...(mine
904 ? [
905 { p: ["Push an existing repository from the command line:"] } as Block,
906 { code: `git remote add origin ${url}.git\ngit push -u origin ${d.defaultBranch}` } as Block,
907 { 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,
908 ]
909 : []),
910 { button: { label: "Open repository", url } },
911 ],
912 context: repoContext(pub, d.repo),
913 url,
914 };
915 }
916 case "repo_deleted":
917 return {
918 subject: `[${job.data.repo}] Repository deleted`,
919 preheader: `@${job.data.actor} deleted ${job.data.repo}.`,
920 eyebrow: { text: "Repository deleted", tone: "red" },
921 title: [user(job.data.actor), " deleted ", { b: job.data.repo }],
922 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)]] }],
923 };
924 case "repo_transferred": {
925 const d = job.data;
926 const url = `${pub}/${d.to}`;
927 return {
928 subject: `[${d.to}] Repository transferred from ${d.from}`,
929 preheader: `@${d.actor} moved ${d.from} to ${d.to}.`,
930 eyebrow: { text: "Repository transferred", tone: "blue" },
931 title: [user(d.actor), " transferred ", { b: d.from }, " to ", { b: d.to }],
932 blocks: [
933 { 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:"] },
934 { code: `git remote set-url origin ${url}.git` },
935 { kv: [["From", d.from], ["To", [{ a: d.to, href: url }]], ["Transferred by", user(d.actor)], ["When", at(job.at)]] },
936 { button: { label: "Open repository", url } },
937 ],
938 context: repoContext(pub, d.to),
939 url,
940 };
941 }
942 case "digest":
943 return digestEmail(job.data, pub);
944 case "test":
945 return {
946 subject: "[Diffinery] Test email",
947 preheader: "Email from Diffinery works.",
948 eyebrow: { text: "Test", tone: "green" },
949 title: ["Email from Diffinery works"],
950 blocks: [{ p: [user(job.data.by), " sent this test email at ", at(job.at), `. It came from Cloudflare Email Sending, signed with DKIM for ${mailDomain(pub, mailFrom)}.`] }, { button: { label: "Notification settings", url: `${pub}/settings/notifications` } }],
951 url: `${pub}/settings/notifications`,
952 };
953 }
954}
955
956// ---------------------------------------------------------------- the morning digest (phase 5c)
957
958/** Today by email: what needs you, your agents, and what happened since the last digest (agentic/digest.ts). */
959function digestEmail(d: DigestData, pub: string): Email {
960 const today = `${pub}/`;
961 const s = d.summary;
962 if (!s) {
963 // deliver() always fills the summary in; this only shows if something sends the job without it.
964 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 };
965 }
966 const c = s.counts;
967 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);
968 const headline = parts.length ? parts.join(", ") : "nothing needs you";
969 const blocks: Block[] = [];
970 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."] });
971 for (const sec of s.sections) {
972 blocks.push({ p: [{ b: sec.title }, sec.total > sec.items.length ? ` · ${sec.items.length} of ${sec.total}` : ` · ${sec.total}`] });
973 blocks.push({ items: sec.items.map((it) => ({ mark: it.mark, text: [{ b: it.title }, ` ${it.where} · ${it.note}`], url: it.url })) });
974 }
975 blocks.push({ button: { label: "Open Today", url: today } });
976 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 });
977 return {
978 subject: `[Diffinery] Today: ${headline}`,
979 preheader: s.sections.length ? s.sections.map((x) => `${x.title}: ${x.total}`).join(" · ") : "A quiet morning.",
980 eyebrow: { text: s.date, tone: c.needsYou ? "purple" : "blue" },
981 title: [c.needsYou ? `${c.needsYou} ${c.needsYou === 1 ? "thing needs" : "things need"} you today` : "Your morning digest"],
982 blocks,
983 url: today,
984 };
985}