3 files changed,
+57,
-3
+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+}
+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("<script>"&'");
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+});
+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