| 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 | */ |
| 7 | import { escapeHtml, renderMarkdown, stripHtmlComments } from "../web/markdown.js"; |
| 8 | import type { DigestData, MailJob, Reason, RunData, ThreadData } from "./types.js"; |
| 9 | |
| 10 | export type Inline = string | { b: string } | { a: string; href: string } | { code: string }; |
| 11 | |
| 12 | export 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 | |
| 22 | export 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 | |
| 40 | export 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 | |
| 52 | const 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 | }; |
| 71 | const FONT = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif`; |
| 72 | const MONO = `ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono',monospace`; |
| 73 | |
| 74 | // ---------------------------------------------------------------- rendering |
| 75 | |
| 76 | const esc = escapeHtml; |
| 77 | |
| 78 | function 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 | |
| 90 | function 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. */ |
| 96 | function 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 | |
| 108 | function 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 ? ` ${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 | |
| 141 | function 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 | |
| 153 | export 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 | |
| 173 | const LOGO = (url: string) => `${url}/icon-512.png`; |
| 174 | |
| 175 | /** Full HTML document. */ |
| 176 | export 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> |
| 186 | body{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)}${" ͏ ".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(" · ")}</p>` : ""} |
| 221 | <p style="margin:0">Diffinery · Code hosting and CI on Cloudflare · <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 | |
| 228 | export 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 | |
| 239 | const at = (iso: string) => new Date(iso).toUTCString().replace("GMT", "UTC"); |
| 240 | const day = (iso: string) => new Date(iso).toLocaleDateString("en-US", { dateStyle: "long", timeZone: "UTC" }); |
| 241 | const user = (login: string) => `@${login}`; |
| 242 | const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; |
| 243 | const ROLE_TEXT: Record<string, string> = { read: "read", triage: "triage", write: "write", maintain: "maintain", admin: "admin", member: "member", owner: "owner" }; |
| 244 | const roleText = (r: string) => ROLE_TEXT[r] ?? r; |
| 245 | const mib = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`); |
| 246 | const 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. */ |
| 249 | function 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 | |
| 259 | function repoContext(pub: string, repo: string) { |
| 260 | return { label: repo, url: `${pub}/${repo}` }; |
| 261 | } |
| 262 | |
| 263 | function 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 | |
| 341 | function 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. */ |
| 368 | export 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). */ |
| 959 | function 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 | } |