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

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
Fast pages, required checks on the branch, self-hosted runners, honest incidents101/**
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 {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas106 return {
107 heading: input.title,
108 paragraphs: [
Fast pages, required checks on the branch, self-hosted runners, honest incidents109 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.",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas112 ],
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}
Fast pages, required checks on the branch, self-hosted runners, honest incidents117
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}