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.

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