fix(alerts): fix email alerts to use Email Routing > Destination Addresses
3 files changed,  +57, -3
M src/email.ts
+30, -1
 1@@ -1,3 +1,4 @@
 2+import { EmailMessage } from "cloudflare:email";
 3 import { REGION_NAMES, type Check } from "./monitor";
 4 
 5 export type AlertKind = "down" | "still down" | "recovered";
 6@@ -23,8 +24,12 @@ export async function sendAlert(env: Env, kind: AlertKind, check: Check, failing
 7     prettyJson(check.body) ?? "(empty)",
 8   );
 9 
10+  // Raw MIME goes out through Email Routing to the verified destination address. (The structured
11+  // builder form of send() uses Email Sending, which needs separate per-domain setup.)
12+  const raw = mimeMessage({ from: env.ALERT_FROM, to: env.ALERT_TO, subject, text: lines.join("\n") });
13   try {
14-    await env.EMAIL.send({ from: env.ALERT_FROM, to: env.ALERT_TO, subject, text: lines.join("\n") });
15+    await env.EMAIL.send(new EmailMessage(env.ALERT_FROM, env.ALERT_TO, raw));
16+    console.log("alert email sent", { endpoint: check.endpoint, kind, subject });
17     return true;
18   } catch (e) {
19     console.error("alert email failed", { endpoint: check.endpoint, kind, error: String(e) });
20@@ -41,3 +46,27 @@ export function prettyJson(body: string | null): string | null {
21     return body;
22   }
23 }
24+
25+/** Builds a minimal RFC 5322 plain-text message. Subject and body are base64 so any UTF-8 is safe. */
26+export function mimeMessage(m: { from: string; to: string; subject: string; text: string; date?: Date }): string {
27+  const domain = m.from.split("@")[1];
28+  const headers = [
29+    `From: Monitor <${m.from}>`,
30+    `To: ${m.to}`,
31+    `Subject: =?UTF-8?B?${base64(m.subject)}?=`,
32+    `Date: ${(m.date ?? new Date()).toUTCString()}`,
33+    `Message-ID: <${crypto.randomUUID()}@${domain}>`,
34+    "MIME-Version: 1.0",
35+    "Content-Type: text/plain; charset=utf-8",
36+    "Content-Transfer-Encoding: base64",
37+  ];
38+  const body = base64(m.text).replace(/.{1,76}/g, "$&\r\n");
39+  return headers.join("\r\n") + "\r\n\r\n" + body;
40+}
41+
42+function base64(s: string): string {
43+  const bytes = new TextEncoder().encode(s);
44+  let binary = "";
45+  for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
46+  return btoa(binary);
47+}
M test/index.test.ts
+22, -0
 1@@ -1,6 +1,7 @@
 2 import { exports } from "cloudflare:workers";
 3 import { beforeEach, describe, expect, it, vi } from "vitest";
 4 import { mergePage } from "../src/index";
 5+import { mimeMessage } from "../src/email";
 6 import { esc } from "../src/html";
 7 import type { Check } from "../src/monitor";
 8 
 9@@ -66,3 +67,24 @@ describe("esc", () => {
10     expect(esc(`<script>"&'`)).toBe("&lt;script&gt;&quot;&amp;&#39;");
11   });
12 });
13+
14+describe("mimeMessage", () => {
15+  it("builds a plain-text message with encoded subject and body", () => {
16+    const raw = mimeMessage({
17+      from: "[email protected]",
18+      to: "[email protected]",
19+      subject: "[monitor] DOWN: héllo",
20+      text: "line one\n" + "x".repeat(200) + " ✓",
21+      date: new Date("2026-10-10T05:50:21Z"),
22+    });
23+    const [head, body] = raw.split("\r\n\r\n");
24+    expect(head).toContain("From: Monitor <[email protected]>\r\nTo: [email protected]\r\n");
25+    expect(head).toContain("Date: Sat, 10 Oct 2026 05:50:21 GMT");
26+    expect(head).toMatch(/Message-ID: <[0-9a-f-]{36}@rtw\.run>/);
27+    const subject = head.match(/Subject: =\?UTF-8\?B\?(.+)\?=/)![1];
28+    const decode = (b64: string) => new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
29+    expect(decode(subject)).toBe("[monitor] DOWN: héllo");
30+    expect(body.split("\r\n").every((l) => l.length <= 76)).toBe(true);
31+    expect(decode(body.replace(/\r\n/g, ""))).toBe("line one\n" + "x".repeat(200) + " ✓");
32+  });
33+});
M test/monitor.test.ts
+5, -2
 1@@ -30,17 +30,20 @@ function mockEndpoints(responses: Record<string, number | Error>) {
 2 }
 3 
 4 /** Runs one alarm cycle and returns the subjects of emails sent during it. */
 5-async function runCycle(stub: DurableObjectStub<MonitorDO>, send?: (msg: unknown) => Promise<unknown>) {
 6+async function runCycle(stub: DurableObjectStub<MonitorDO>, send?: (msg: EmailMessage) => Promise<unknown>) {
 7   const subjects: string[] = [];
 8+  const log = vi.spyOn(console, "log").mockImplementation(() => {});
 9   await runInDurableObject(stub, (instance: MonitorDO) => {
10     const emailEnv = (instance as unknown as { env: Env }).env;
11     vi.spyOn(emailEnv.EMAIL, "send").mockImplementation(async (msg: any) => {
12-      subjects.push(msg.subject);
13+      expect(msg).toMatchObject({ from: "[email protected]", to: "[email protected]" });
14       if (send) await send(msg);
15       return { messageId: "x" } as any;
16     });
17   });
18   expect(await runDurableObjectAlarm(stub)).toBe(true);
19+  for (const [message, data] of log.mock.calls) if (message === "alert email sent") subjects.push(data.subject);
20+  log.mockRestore();
21   return subjects;
22 }
23