g1t/apps/status/src/email.ts

110 lines5,251 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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
101export function alertLetter(input: { title: string; parts: string[]; since: string; link: string }): Letter {
102 return {
103 heading: input.title,
104 paragraphs: [
105 `The checks have failed for ${input.parts.join(", ")} since ${input.since}, three times in a row. A draft incident is waiting in sudo. It is not on the status page until someone publishes it.`,
106 ],
107 action: { label: "Open it in sudo", url: input.link },
108 footer: ["Sent by status.g1t.sh to the staff alert address (STATUS_ALERT_EMAIL)."],
109 };
110}