| 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 | |
| 10 | export type Mail = { to: string; subject: string; text: string; html: string; headers?: Record<string, string> }; |
| 11 | |
| 12 | export interface Sender { |
| 13 | send(mail: Mail): Promise<void>; |
| 14 | } |
| 15 | |
| 16 | /** The `send_email` binding, as Cloudflare Email Sending gives it. */ |
| 17 | export type EmailBinding = { |
| 18 | send(message: { to: string; from: string; subject: string; text: string; html: string; headers?: Record<string, string> }): Promise<unknown>; |
| 19 | }; |
| 20 | |
| 21 | export const DEFAULT_FROM = "g1t status <noreply@g1t.sh>"; |
| 22 | |
| 23 | /** A sender over the binding, or null when there is none. */ |
| 24 | export 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. */ |
| 30 | export function memorySender(): Sender & { sent: Mail[] } { |
| 31 | const sent: Mail[] = []; |
| 32 | return { sent, send: async (mail) => void sent.push(mail) }; |
| 33 | } |
| 34 | |
| 35 | function escape(text: string): string { |
| 36 | return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); |
| 37 | } |
| 38 | |
| 39 | export 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. */ |
| 50 | export 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). */ |
| 76 | export function unsubscribeHeaders(url: string): Record<string, string> { |
| 77 | return { "List-Unsubscribe": `<${url}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click" }; |
| 78 | } |
| 79 | |
| 80 | export 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 | |
| 91 | export 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 | */ |
| 105 | export 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. */ |
| 119 | export 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 | } |