email.ts
1import { EmailMessage } from "cloudflare:email";
2import { REGION_NAMES, type Check } from "./monitor";
3
4export type AlertKind = "down" | "still down" | "recovered";
5
6/** Sends an alert email. Returns false (and logs) on failure rather than throwing. */
7export async function sendAlert(env: Env, kind: AlertKind, check: Check, failingSince: number | null): Promise<boolean> {
8 const host = check.endpoint.replace(/^https?:\/\//, "");
9 const result = check.status ? `HTTP ${check.status}` : (check.error ?? "no response");
10 const subject = `[monitor] ${kind.toUpperCase()}: ${host} (${result}) from ${check.region}`;
11 const lines = [
12 `Endpoint: ${check.endpoint}`,
13 `Region: ${check.region} (${REGION_NAMES[check.region]})`,
14 `Result: ${result}`,
15 `Checked: ${new Date(check.ts).toISOString()}`,
16 `Latency: ${check.duration_ms} ms`,
17 ];
18 if (failingSince !== null) lines.push(`Failing since: ${new Date(failingSince).toISOString()}`);
19 if (check.error && check.status) lines.push(`Error: ${check.error}`);
20 lines.push(
21 `History: ${env.PUBLIC_URL}/endpoint?url=${encodeURIComponent(check.endpoint)}`,
22 "",
23 "Response body:",
24 prettyJson(check.body) ?? "(empty)",
25 );
26
27 // Raw MIME goes out through Email Routing to the verified destination address. (The structured
28 // builder form of send() uses Email Sending, which needs separate per-domain setup.)
29 const raw = mimeMessage({ from: env.ALERT_FROM, to: env.ALERT_TO, subject, text: lines.join("\n") });
30 try {
31 await env.EMAIL.send(new EmailMessage(env.ALERT_FROM, env.ALERT_TO, raw));
32 console.log("alert email sent", { endpoint: check.endpoint, kind, subject });
33 return true;
34 } catch (e) {
35 console.error("alert email failed", { endpoint: check.endpoint, kind, error: String(e) });
36 return false;
37 }
38}
39
40/** Pretty-prints JSON bodies; returns other bodies unchanged and null when there is no body. */
41export function prettyJson(body: string | null): string | null {
42 if (!body) return null;
43 try {
44 return JSON.stringify(JSON.parse(body), null, 2);
45 } catch {
46 return body;
47 }
48}
49
50/** Builds a minimal RFC 5322 plain-text message. Subject and body are base64 so any UTF-8 is safe. */
51export function mimeMessage(m: { from: string; to: string; subject: string; text: string; date?: Date }): string {
52 const domain = m.from.split("@")[1];
53 const headers = [
54 `From: Monitor <${m.from}>`,
55 `To: ${m.to}`,
56 `Subject: =?UTF-8?B?${base64(m.subject)}?=`,
57 `Date: ${(m.date ?? new Date()).toUTCString()}`,
58 `Message-ID: <${crypto.randomUUID()}@${domain}>`,
59 "MIME-Version: 1.0",
60 "Content-Type: text/plain; charset=utf-8",
61 "Content-Transfer-Encoding: base64",
62 ];
63 const body = base64(m.text).replace(/.{1,76}/g, "$&\r\n");
64 return headers.join("\r\n") + "\r\n\r\n" + body;
65}
66
67function base64(s: string): string {
68 const bytes = new TextEncoder().encode(s);
69 let binary = "";
70 for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
71 return btoa(binary);
72}