flagon-io/g1t

public

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

g1t/apps/status/src/email.ts

126 lines5,927 bytesCodeBlame
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 { send: async ({ to, subject, text, html, headers }) => void (await binding.send({ to, from, subject, text, html, ...(headers ? { headers } : {}) })) };
27}
28
29/** Collects mail instead of sending it: for tests and previews. */
30export function memorySender(): Sender & { sent: Mail[] } {
31 const sent: Mail[] = [];
32 return { sent, send: async (mail) => void sent.push(mail) };
33}
34
35function escape(text: string): string {
36 return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
37}
38
39export type Letter = {
40 /** A line over the body, such as "Identified · Pushes failing". */
41 heading: string;
42 paragraphs: string[];
43 action?: { label: string; url: string };
44 footer: string[];
45 /** Where the reader leaves, as a link in the footer. */
46 unsubscribe?: string;
47};
48
49/** The plain text and HTML of a letter; everything is escaped. */
50export function render(letter: Letter): { text: string; html: string } {
51 const text: string[] = [letter.heading, ""];
52 let html = `<div style="font-family:system-ui,sans-serif;max-width:520px;margin:0 auto;padding:32px 16px;color:#16150f">`;
53 html += `<p style="margin:0 0 6px;font-size:13px;color:#6e6a5e">g1t status</p>`;
54 html += `<h1 style="margin:0 0 16px;font-size:19px;line-height:1.35">${escape(letter.heading)}</h1>`;
55 for (const p of letter.paragraphs) {
56 text.push(p, "");
57 html += `<p style="font-size:15px;line-height:1.6;white-space:pre-line">${escape(p)}</p>`;
58 }
59 if (letter.action) {
60 text.push(`${letter.action.label}: ${letter.action.url}`, "");
61 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>`;
62 }
63 for (const line of letter.footer) {
64 text.push(line);
65 html += `<p style="font-size:13px;line-height:1.6;color:#6e6a5e;margin:4px 0">${escape(line)}</p>`;
66 }
67 if (letter.unsubscribe) {
68 text.push(`Unsubscribe: ${letter.unsubscribe}`);
69 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>`;
70 }
71 html += "</div>";
72 return { text: `${text.join("\n").trim()}\n`, html };
73}
74
75/** The headers that let mail apps offer one-click unsubscribing (RFC 8058). */
76export function unsubscribeHeaders(url: string): Record<string, string> {
77 return { "List-Unsubscribe": `<${url}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" };
78}
79
80export function confirmLetter(link: string, parts: string[] | null): Letter {
81 return {
82 heading: "Confirm your subscription to g1t status",
83 paragraphs: [
84 `Someone, hopefully you, asked to get an email when g1t posts an incident or planned maintenance${parts ? ` affecting ${parts.join(", ")}` : ""}. Confirm to start.`,
85 ],
86 action: { label: "Confirm subscription", url: link },
87 footer: ["The link works for 24 hours. If you did not ask for this, ignore this email and nothing happens."],
88 };
89}
90
91export function updateLetter(input: { heading: string; text: string; url: string; affects: string[]; unsubscribe: string }): Letter {
92 return {
93 heading: input.heading,
94 paragraphs: [input.text, ...(input.affects.length ? [`Affects: ${input.affects.join(", ")}.`] : [])],
95 action: { label: "See it on the status page", url: input.url },
96 footer: ["You get these because you subscribed at status.g1t.sh."],
97 unsubscribe: input.unsubscribe,
98 };
99}
100
101/**
102 * The staff alert for a new detected draft. `lines` say what each part
103 * did, slow and not answering told apart (detect.ts `troubleSentence`).
104 */
105export function alertLetter(input: { title: string; lines: string[]; link: string; note?: string }): Letter {
106 return {
107 heading: input.title,
108 paragraphs: [
109 input.lines.join("\n"),
110 ...(input.note ? [input.note] : []),
111 "A draft incident is waiting in sudo. It is not on the status page until someone publishes it.",
112 ],
113 action: { label: "Open it in sudo", url: input.link },
114 footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
115 };
116}
117
118/** The follow-up when a detected draft recovered and was dismissed on its own. */
119export function recoveredLetter(input: { title: string; text: string; link: string }): Letter {
120 return {
121 heading: `Recovered: ${input.title.replace(/^Detected: /, "")} — dismissed automatically`,
122 paragraphs: [input.text, "Open it in sudo to read its timeline, or to declare an incident anyway."],
123 action: { label: "Open it in sudo", url: input.link },
124 footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
125 };
126}