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}