Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

144 linesCodeBlameRaw
1/**
2 * Email from the status page: subscription confirmations, incident and
3 * maintenance updates to subscribers, and the staff alert when a draft is
4 * detected. Sending goes through a `Sender`, so the page works the same
5 * with no email at all (an installation without it, or tests): the feeds
6 * still carry every update. On g1t.sh the sender is Cloudflare Email
7 * Sending, the `send_email` binding identity uses too. No Workers imports.
8 */
9
10export type Mail = { to: string; subject: string; text: string; html: string; headers?: Record<string, string> };
11
12export interface Sender {
13 send(mail: Mail): Promise<void>;
14}
15
16/** The `send_email` binding, as Cloudflare Email Sending gives it. */
17export type EmailBinding = {
18 send(message: { to: string; from: string; subject: string; text: string; html: string; headers?: Record<string, string> }): Promise<unknown>;
19};
20
21export const DEFAULT_FROM = "g1t status <noreply@g1t.sh>";
22
23/** A sender over the binding, or null when there is none. */
24export function bindingSender(binding: EmailBinding | undefined, from = DEFAULT_FROM): Sender | null {
25 if (!binding || typeof binding.send !== "function") return null;
26 return {
27 send: async ({ to, subject, text, html, headers }) => void (await binding.send({ to, from, subject, text, html, ...(headers ? { headers } : {}) })),
28 };
29}
30
31/** Collects mail instead of sending it: for tests and previews. */
32export function memorySender(): Sender & { sent: Mail[] } {
33 const sent: Mail[] = [];
34 return { sent, send: async (mail) => void sent.push(mail) };
35}
36
37function escape(text: string): string {
38 return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
39}
40
41export type Letter = {
42 /** A line over the body, such as "Identified · Pushes failing". */
43 heading: string;
44 paragraphs: string[];
45 action?: { label: string; url: string };
46 footer: string[];
47 /** Where the reader leaves, as a link in the footer. */
48 unsubscribe?: string;
49};
50
51/** The plain text and HTML of a letter; everything is escaped. */
52export function render(letter: Letter): { text: string; html: string } {
53 const text: string[] = [letter.heading, ""];
54 let html = `<div style="font-family:system-ui,sans-serif;max-width:520px;margin:0 auto;padding:32px 16px;color:#16150f">`;
55 html += `<p style="margin:0 0 6px;font-size:13px;color:#6e6a5e">g1t status</p>`;
56 html += `<h1 style="margin:0 0 16px;font-size:19px;line-height:1.35">${escape(letter.heading)}</h1>`;
57 for (const p of letter.paragraphs) {
58 text.push(p, "");
59 html += `<p style="font-size:15px;line-height:1.6;white-space:pre-line">${escape(p)}</p>`;
60 }
61 if (letter.action) {
62 text.push(`${letter.action.label}: ${letter.action.url}`, "");
63 html += `<p style="margin:24px 0"><a href="${escape(letter.action.url)}" style="background:#16150f;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:15px">${escape(letter.action.label)}</a></p>`;
64 }
65 for (const line of letter.footer) {
66 text.push(line);
67 html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0">${escape(line)}</p>`;
68 }
69 if (letter.unsubscribe) {
70 text.push(`Unsubscribe: ${letter.unsubscribe}`);
71 html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0"><a href="${escape(letter.unsubscribe)}" style="color:#6e6a5e">Unsubscribe</a></p>`;
72 }
73 html += "</div>";
74 return { text: `${text.join("\n").trim()}\n`, html };
75}
76
77/** The headers that let mail apps offer one-click unsubscribing (RFC 8058). */
78export function unsubscribeHeaders(url: string): Record<string, string> {
79 return { "List-Unsubscribe": `<${url}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" };
80}
81
82export function confirmLetter(link: string, parts: string[] | null): Letter {
83 return {
84 heading: "Confirm your subscription to g1t status",
85 paragraphs: [
86 `Someone, hopefully you, asked to get an email when g1t posts an incident or planned maintenance${parts ? ` affecting ${parts.join(", ")}` : ""}. Confirm to start.`,
87 ],
88 action: { label: "Confirm subscription", url: link },
89 footer: ["The link works for 24 hours. If you did not ask for this, ignore this email and nothing happens."],
90 };
91}
92
93export function updateLetter(input: { heading: string; text: string; url: string; affects: string[]; unsubscribe: string }): Letter {
94 return {
95 heading: input.heading,
96 paragraphs: [input.text, ...(input.affects.length ? [`Affects: ${input.affects.join(", ")}.`] : [])],
97 action: { label: "See it on the status page", url: input.url },
98 footer: ["You get these because you subscribed at status.g1t.sh."],
99 unsubscribe: input.unsubscribe,
100 };
101}
102
103/**
104 * The staff alert for a new detected draft. `lines` say what each part
105 * did, slow and not answering told apart (detect.ts `troubleSentence`).
106 */
107export function alertLetter(input: { title: string; lines: string[]; link: string; note?: string }): Letter {
108 return {
109 heading: input.title,
110 paragraphs: [
111 input.lines.join("\n"),
112 ...(input.note ? [input.note] : []),
113 "A draft incident is waiting in sudo. It is not on the status page until someone publishes it.",
114 ],
115 action: { label: "Open it in sudo", url: input.link },
116 footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
117 };
118}
119
120/**
121 * The alert again, for a detected draft nobody has acknowledged: after 45
122 * minutes, then every 6 hours while it waits (detect.ts `staleDrafts`).
123 */
124export function staleLetter(input: { title: string; waiting: string; link: string }): Letter {
125 return {
126 heading: `Still waiting: ${input.title.replace(/^Detected: /, "")}`,
127 paragraphs: [
128 `A detected draft incident has been waiting ${input.waiting} for someone to pick it up, and its parts have not stayed healthy long enough for it to be dismissed on its own.`,
129 "Acknowledge it in sudo: publish it, add a note, or dismiss it if it is not an incident. Until then this goes out again every 6 hours.",
130 ],
131 action: { label: "Open it in sudo", url: input.link },
132 footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
133 };
134}
135
136/** The follow-up when a detected draft recovered and was dismissed on its own. */
137export function recoveredLetter(input: { title: string; text: string; link: string }): Letter {
138 return {
139 heading: `Recovered: ${input.title.replace(/^Detected: /, "")} — dismissed automatically`,
140 paragraphs: [input.text, "Open it in sudo to read its timeline, or to declare an incident anyway."],
141 action: { label: "Open it in sudo", url: input.link },
142 footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
143 };
144}