diff --git a/apps/server/src/routes/v1/statusReports/update/post.ts b/apps/server/src/routes/v1/statusReports/update/post.ts index 0cf28d27f..a4f9b9065 100644 --- a/apps/server/src/routes/v1/statusReports/update/post.ts +++ b/apps/server/src/routes/v1/statusReports/update/post.ts @@ -128,7 +128,7 @@ export function registerStatusReportUpdateRoutes(api: typeof statusReportsApi) { s.unsubscribedAt === null, ); if (_statusReportWithRelations?.page && validSubscribers.length > 0) { - await emailClient.sendStatusReportUpdate({ + await emailClient.sendPageUpdate({ subscribers: validSubscribers.map((subscriber) => ({ email: subscriber.email, token: subscriber.token, diff --git a/packages/emails/emails/_components/format.ts b/packages/emails/emails/_components/format.ts index bdec6ce3d..fe743fad3 100644 --- a/packages/emails/emails/_components/format.ts +++ b/packages/emails/emails/_components/format.ts @@ -36,6 +36,29 @@ export function formatDateTime(input: Date | string | number): string { return `${p("day")} ${p("month")}, ${p("hour")}:${p("minute")} UTC`; } +/** "12 Oct, 22:00 - 13 Oct, 01:00 UTC"; same-day windows show time only. */ +export function formatDateTimeRange( + from: Date | string | number, + to: Date | string | number, +): string { + const start = new Date(from); + const end = new Date(to); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return `${String(from)} - ${String(to)}`; + } + const s = parts(time, start); + const e = parts(time, end); + const sameDay = + start.getUTCFullYear() === end.getUTCFullYear() && + s("day") === e("day") && + s("month") === e("month"); + const startLabel = `${s("day")} ${s("month")}, ${s("hour")}:${s("minute")}`; + const endLabel = sameDay + ? `${e("hour")}:${e("minute")}` + : `${e("day")} ${e("month")}, ${e("hour")}:${e("minute")}`; + return `${startLabel} - ${endLabel} UTC`; +} + /** "Fri 25 Sep 2026" — day-only dates carry no timezone suffix. */ export function formatDay(input: Date | string | number): string { const date = new Date(input); diff --git a/packages/emails/emails/status-report.tsx b/packages/emails/emails/page-update.tsx similarity index 82% rename from packages/emails/emails/status-report.tsx rename to packages/emails/emails/page-update.tsx index d106c32dd..61aeca58f 100644 --- a/packages/emails/emails/status-report.tsx +++ b/packages/emails/emails/page-update.tsx @@ -5,14 +5,18 @@ import { z } from "zod"; import { Actions } from "./_components/actions"; import { Eyebrow } from "./_components/eyebrow"; import { Footer } from "./_components/footer"; -import { formatDateTime, formatElapsed } from "./_components/format"; +import { + formatDateTime, + formatDateTimeRange, + formatElapsed, +} from "./_components/format"; import { Heading } from "./_components/heading"; import { KeyValue } from "./_components/key-value"; import { Layout, statusPageBrand } from "./_components/layout"; import { Markdown } from "./_components/markdown"; import type { Tone } from "./_components/styles"; -export const StatusReportSchema = z.object({ +export const PageUpdateSchema = z.object({ pageTitle: z.string(), // statusReportStatus from db status: z.enum([ @@ -46,9 +50,12 @@ export const StatusReportSchema = z.object({ /** 1-based position of this update within the report. */ updateIndex: z.number().optional(), reportStartedAt: z.string().optional(), + /** Maintenance window; `date` stays the timestamp of the update itself. */ + startsAt: z.string().optional(), + endsAt: z.string().optional(), }); -export type StatusReportProps = z.infer; +export type PageUpdateProps = z.infer; const statusTone = { investigating: "danger", @@ -56,10 +63,10 @@ const statusTone = { monitoring: "info", resolved: "success", maintenance: "info", -} satisfies Record; +} satisfies Record; type Impact = NonNullable< - StatusReportProps["componentImpacts"] + PageUpdateProps["componentImpacts"] >[number]["impact"]; const impactRow = { @@ -75,14 +82,14 @@ const componentLabel = { monitoring: "Monitoring", resolved: "Resolved", maintenance: "Maintenance", -} satisfies Record; +} satisfies Record; function isDate(value: string) { return !Number.isNaN(new Date(value).getTime()); } -export function statusReportPreheader( - props: Pick, +export function pageUpdatePreheader( + props: Pick, ): string { const components = props.pageComponents.length > 0 @@ -93,7 +100,7 @@ export function statusReportPreheader( return `${componentLabel[props.status]}: ${components}.`; } -function StatusReportEmail({ +function PageUpdateEmail({ status, date, message, @@ -106,9 +113,19 @@ function StatusReportEmail({ statusPageUrl, updateIndex, reportStartedAt, -}: StatusReportProps) { + startsAt, + endsAt, +}: PageUpdateProps) { const tone = statusTone[status]; const dated = isDate(date); + // a maintenance shows its window where a report shows the update time; + // legacy callers pass a pre-formatted window as `date` + const when = + startsAt && endsAt + ? formatDateTimeRange(startsAt, endsAt) + : dated + ? formatDateTime(date) + : date; const elapsed = dated && reportStartedAt && isDate(reportStartedAt) ? formatElapsed(reportStartedAt, date) @@ -121,7 +138,7 @@ function StatusReportEmail({ return ( - {!dated ? : null} {pageComponents.length > 0 ? ( { @@ -162,7 +178,7 @@ function StatusReportEmail({ ); } -StatusReportEmail.PreviewProps = { +PageUpdateEmail.PreviewProps = { pageTitle: "openstatus", reportTitle: "API unavailable — service partially restored", status: "monitoring", @@ -191,6 +207,6 @@ Nothing. Pin the previous action version if your pipeline is blocked — next up "https://status.openstatus.dev/unsubscribe/550e8400-e29b-41d4-a716-446655440000", manageUrl: "https://status.openstatus.dev/manage/550e8400-e29b-41d4-a716-446655440000", -} satisfies StatusReportProps; +} satisfies PageUpdateProps; -export default StatusReportEmail; +export default PageUpdateEmail; diff --git a/packages/emails/src/client.integration.test.tsx b/packages/emails/src/client.integration.test.tsx index 47b3ec682..f594f6e47 100644 --- a/packages/emails/src/client.integration.test.tsx +++ b/packages/emails/src/client.integration.test.tsx @@ -5,8 +5,8 @@ import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; import { render } from "react-email"; -import StatusReportEmail from "../emails/status-report"; -import { statusReportSubject } from "./client"; +import PageUpdateEmail from "../emails/page-update"; +import { pageUpdateSubject } from "./client"; describe("Status Report Email - Unsubscribe Link in Body", () => { const unsubscribeUrl = @@ -15,7 +15,7 @@ describe("Status Report Email - Unsubscribe Link in Body", () => { test("should include unsubscribe link in email body when URL is provided", async () => { const html = await render( - { test("should not include unsubscribe section when URL is not provided", async () => { const html = await render( - { test("should render unsubscribe link as clickable", async () => { const html = await render( - { test("should render the unsubscribe link in the footer, outside the card", async () => { const html = await render( - { const reportTitle = "API Outage"; test('prepends "RESOLVED:" only when status is "resolved"', () => { - expect(statusReportSubject({ status: "resolved", reportTitle })).toBe( + expect(pageUpdateSubject({ status: "resolved", reportTitle })).toBe( `RESOLVED: ${reportTitle}`, ); }); @@ -101,14 +101,14 @@ describe("Status Report Email - Subject Line", () => { const ongoing = ["investigating", "identified", "monitoring"] as const; for (const status of ongoing) { - const subject = statusReportSubject({ status, reportTitle }); + const subject = pageUpdateSubject({ status, reportTitle }); expect(subject).toBe(reportTitle); expect(subject).not.toContain("RESOLVED:"); } }); test('prepends "Planned Maintenance:" when status is "maintenance"', () => { - expect(statusReportSubject({ status: "maintenance", reportTitle })).toBe( + expect(pageUpdateSubject({ status: "maintenance", reportTitle })).toBe( `Planned Maintenance: ${reportTitle}`, ); }); @@ -127,7 +127,7 @@ describe("Status Report Email - Email Content Validation", () => { manageUrl: "https://openstatus.openstatus.dev/manage/test", }; - const html = await render(); + const html = await render(); expect(html).toContain(props.pageTitle); expect(html).toContain(props.reportTitle); @@ -148,7 +148,7 @@ describe("Status Report Email - Email Content Validation", () => { for (const status of statuses) { const html = await render( - [0]> = {}, + overrides: Partial[0]> = {}, ) { return { subscribers: makeSubscribers(1), @@ -43,7 +43,7 @@ const idempotencyConflict = { error: { name: "invalid_idempotent_request" }, } as any; -describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { +describe("EmailClient.sendPageUpdate - idempotency & chunking", () => { let client: EmailClient; // biome-ignore lint/suspicious/noExplicitAny: stub over the Resend batch method let batchSend: Stub; @@ -62,7 +62,7 @@ describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { }); test("passes the base idempotency key suffixed with the batch index", async () => { - await client.sendStatusReportUpdate( + await client.sendPageUpdate( baseReq({ idempotencyKey: "status-report-update:5" }), ); @@ -72,7 +72,7 @@ describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { }); test("gives each 100-recipient chunk a distinct key and its own slice", async () => { - await client.sendStatusReportUpdate( + await client.sendPageUpdate( baseReq({ subscribers: makeSubscribers(250), idempotencyKey: "status-report-update:9", @@ -91,7 +91,7 @@ describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { }); test("omits the option entirely when no base key is provided", async () => { - await client.sendStatusReportUpdate(baseReq()); + await client.sendPageUpdate(baseReq()); const options = batchSend.calls[0].args[1]; expect(options).toBeUndefined(); @@ -105,7 +105,7 @@ describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { returnsNext([Promise.resolve(fail), Promise.resolve(ok)]), ); - await client.sendStatusReportUpdate( + await client.sendPageUpdate( baseReq({ idempotencyKey: "status-report-update:7" }), ); @@ -125,7 +125,7 @@ describe("EmailClient.sendStatusReportUpdate - idempotency & chunking", () => { Promise.resolve(idempotencyConflict), ); - await client.sendStatusReportUpdate( + await client.sendPageUpdate( baseReq({ idempotencyKey: "status-report-update:7" }), ); diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index f267bf344..c915a29b8 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -14,6 +14,8 @@ import MonitorAlertEmail, { import type { MonitorAlertProps } from "../emails/monitor-alert"; import PageSubscriptionEmail from "../emails/page-subscription"; import type { PageSubscriptionProps } from "../emails/page-subscription"; +import PageUpdateEmail from "../emails/page-update"; +import type { PageUpdateProps } from "../emails/page-update"; import PrivateLocationAlertEmail, { privateLocationAlertSubject, } from "../emails/private-location-alert"; @@ -21,14 +23,12 @@ import type { PrivateLocationAlertProps } from "../emails/private-location-alert import SlackFeedbackEmail from "../emails/slack-feedback"; import StatusPageMagicLinkEmail from "../emails/status-page-magic-link"; import type { StatusPageMagicLinkProps } from "../emails/status-page-magic-link"; -import StatusReportEmail from "../emails/status-report"; -import type { StatusReportProps } from "../emails/status-report"; import TeamInvitationEmail from "../emails/team-invitation"; import type { TeamInvitationProps } from "../emails/team-invitation"; import { env } from "./env"; -export function statusReportSubject(req: { - status: StatusReportProps["status"]; +export function pageUpdateSubject(req: { + status: PageUpdateProps["status"]; reportTitle: string; }): string { if (req.status === "resolved") return `RESOLVED: ${req.reportTitle}`; @@ -187,9 +187,9 @@ export class EmailClient { console.log(`Sent slack feedback emails to ${req.to}`); } - public async sendStatusReportUpdate( + public async sendPageUpdate( req: Omit< - StatusReportProps, + PageUpdateProps, "unsubscribeUrl" | "manageUrl" | "statusPageUrl" > & { subscribers: Array<{ email: string; token: string }>; @@ -207,7 +207,7 @@ export class EmailClient { if (env.NODE_ENV === "development") { console.log( - `Sending status report update emails to ${req.subscribers + `Sending page update emails to ${req.subscribers .map((s) => s.email) .join(", ")}`, ); @@ -230,10 +230,10 @@ export class EmailClient { const manageUrl = `${statusPageBaseUrl}/manage/${subscriber.token}`; return { from: `${req.pageTitle} `, - subject: statusReportSubject(req), + subject: pageUpdateSubject(req), to: subscriber.email, react: ( - new Error( - `Error sending status report update batch to ${recipients.map( + `Error sending page update batch to ${recipients.map( (r) => r.email, )}`, ), @@ -264,7 +264,7 @@ export class EmailClient { } console.log( - `Sent status report update email to ${req.subscribers.length} subscribers`, + `Sent page update email to ${req.subscribers.length} subscribers`, ); } @@ -415,88 +415,6 @@ export class EmailClient { console.log(`Sent dashboard magic link email to ${req.to}`); } - public async sendMaintenanceNotification(req: { - subscribers: Array<{ email: string; token: string }>; - pageTitle: string; - pageSlug: string; - customDomain?: string | null; - maintenanceTitle: string; - message: string; - from: string; - to: string; - pageComponents: string[]; - idempotencyKey?: string; - }) { - const statusPageBaseUrl = req.customDomain - ? `https://${req.customDomain}` - : `https://${req.pageSlug}.openstatus.dev`; - - if (env.NODE_ENV === "development") { - console.log( - `Sending maintenance notification emails to ${req.subscribers - .map((s) => s.email) - .join(", ")}`, - ); - return; - } - - const chunks = chunk(req.subscribers, 100); - for (let i = 0; i < chunks.length; i++) { - const recipients = chunks[i]; - const batchKey = req.idempotencyKey - ? `${req.idempotencyKey}:${i}` - : undefined; - const sendEmail = Effect.tryPromise({ - try: () => - this.client.batch.send( - recipients.map((subscriber) => { - const unsubscribeUrl = `${statusPageBaseUrl}/unsubscribe/${subscriber.token}`; - const manageUrl = `${statusPageBaseUrl}/manage/${subscriber.token}`; - return { - from: `${req.pageTitle} `, - subject: `Scheduled Maintenance: ${req.maintenanceTitle}`, - to: subscriber.email, - react: ( - - ), - }; - }), - batchKey ? { idempotencyKey: batchKey } : undefined, - ), - catch: (_unknown) => - new Error( - `Error sending maintenance notification batch to ${recipients.map( - (r) => r.email, - )}`, - ), - }).pipe( - Effect.andThen((result) => - result.error ? Effect.fail(result.error) : Effect.succeed(result), - ), - Effect.retry({ - times: 3, - schedule: Schedule.exponential(this.retryBackoff), - while: isRetryableSendError, - }), - ); - await Effect.runPromise(sendEmail).catch(console.error); - } - - console.log( - `Sent maintenance notification email to ${req.subscribers.length} subscribers`, - ); - } - public async sendPrivateLocationAlert( req: Omit & { to: string[]; diff --git a/packages/emails/src/format.test.ts b/packages/emails/src/format.test.ts index 534797338..678822f0a 100644 --- a/packages/emails/src/format.test.ts +++ b/packages/emails/src/format.test.ts @@ -3,6 +3,7 @@ import { describe, test } from "@std/testing/bdd"; import { formatDateTime, + formatDateTimeRange, formatDay, formatElapsed, formatLongDay, @@ -34,6 +35,20 @@ describe("email date formatting", () => { test("unparseable input is returned as-is", () => { expect(formatDateTime("Mon - Tue")).toBe("Mon - Tue"); expect(formatDay("soon")).toBe("soon"); + expect(formatDateTimeRange("soon", "later")).toBe("soon - later"); + }); + + test("ranges repeat the day only when it changes", () => { + expect( + formatDateTimeRange("2026-10-12T22:00:00Z", "2026-10-13T01:00:00Z"), + ).toBe("12 Oct, 22:00 - 13 Oct, 01:00 UTC"); + expect( + formatDateTimeRange("2026-10-12T22:00:00Z", "2026-10-12T23:30:00Z"), + ).toBe("12 Oct, 22:00 - 23:30 UTC"); + // same calendar day in another year must still show the end date + expect( + formatDateTimeRange("2026-12-31T22:00:00Z", "2027-12-31T01:00:00Z"), + ).toBe("31 Dec, 22:00 - 31 Dec, 01:00 UTC"); }); test("elapsed time", () => { diff --git a/packages/emails/src/templates.test.tsx b/packages/emails/src/templates.test.tsx index 5bc2d1597..41d02e2e7 100644 --- a/packages/emails/src/templates.test.tsx +++ b/packages/emails/src/templates.test.tsx @@ -27,14 +27,14 @@ import MonitorDeactivationEmail, { } from "../emails/monitor-deactivation"; import MonitorPausedEmail from "../emails/monitor-paused"; import PageSubscriptionEmail from "../emails/page-subscription"; +import PageUpdateEmail, { + type PageUpdateProps, + pageUpdatePreheader, +} from "../emails/page-update"; import PrivateLocationAlertEmail, { privateLocationAlertSubject, } from "../emails/private-location-alert"; import StatusPageMagicLinkEmail from "../emails/status-page-magic-link"; -import StatusReportEmail, { - type StatusReportProps, - statusReportPreheader, -} from "../emails/status-report"; import TeamInvitationEmail from "../emails/team-invitation"; import WelcomeEmail from "../emails/welcome"; @@ -69,7 +69,7 @@ const report = { statusPageUrl: "https://acme.openstatus.dev", unsubscribeUrl: "https://acme.openstatus.dev/unsubscribe/t", manageUrl: "https://acme.openstatus.dev/manage/t", -} satisfies StatusReportProps; +} satisfies PageUpdateProps; describe("primitives", () => { test("pill maps tone to colour and uppercases its label", async () => { @@ -393,9 +393,9 @@ describe("private location alert", () => { }); }); -describe("status report", () => { +describe("page update", () => { test("eyebrow, components, markdown, links", async () => { - const html = await render(); + const html = await render(); expect(html).toContain("Acme Status"); expect(html).toContain("MONITORING"); expect(html).toContain("Update 3 · 18 Sep, 12:37 UTC · 2h 14m in"); @@ -421,9 +421,9 @@ describe("status report", () => { }; for (const [status, tone] of Object.entries(expected)) { const html = await render( - , ); expect(html).toContain(status.toUpperCase()); @@ -433,7 +433,7 @@ describe("status report", () => { test("unknown impact drops the value cell", async () => { const html = await render( - , + , ); expect(html).toContain("Runners"); expect(html).toContain('colSpan="2"'); @@ -443,7 +443,7 @@ describe("status report", () => { test("optional blocks disappear", async () => { const html = await render( - { expect(html).not.toContain("●"); }); - test("maintenance renders its window instead of a timestamp", async () => { + test("maintenance shows its window in the eyebrow instead of the update time", async () => { const html = await render( - , ); expect(html).toContain("MAINTENANCE"); - expect(html).toContain("Window"); + expect(html).toContain("Update 3 · 21 Sep, 10:00 - 12:00 UTC"); + expect(html).not.toContain("Window"); + expect(html).not.toContain("18 Sep, 12:37 UTC"); + expect(html).not.toContain("2026-09-21T10:00:00.000Z"); + }); + + test("a pre-formatted window string still renders verbatim", async () => { + const html = await render( + , + ); + expect(html).not.toContain("Window"); expect(html).toContain("Mon 21 Sep, 10:00 - 12:00"); }); test("markdown headings and lists are styled, raw HTML is escaped", async () => { const html = await render( - alert(1)" @@ -488,7 +504,7 @@ describe("status report", () => { test("a crafted autolink cannot inject an attribute into the email", async () => { const html = await render( - [x](javascript:alert(1))' @@ -504,7 +520,7 @@ describe("status report", () => { test("autolinks and entities in the message survive escaping", async () => { const html = await render( - , @@ -514,12 +530,12 @@ describe("status report", () => { expect(html).not.toContain("&amp;"); }); - test("maintenance without eyebrow items renders no empty eyebrow", async () => { + test("no eyebrow items renders no empty eyebrow", async () => { const html = await render( - , @@ -528,12 +544,12 @@ describe("status report", () => { }); test("preheader never repeats the subject", () => { - expect(statusReportPreheader(report)).toBe("Monitoring: API, Runners."); - expect(statusReportPreheader({ ...report, status: "resolved" })).toBe( + expect(pageUpdatePreheader(report)).toBe("Monitoring: API, Runners."); + expect(pageUpdatePreheader({ ...report, status: "resolved" })).toBe( "Resolved for API, Runners.", ); expect( - statusReportPreheader({ ...report, pageComponents: [] }), + pageUpdatePreheader({ ...report, pageComponents: [] }), ).not.toContain(report.reportTitle); }); }); @@ -686,7 +702,7 @@ describe("every transactional template", () => { lastSeenAt="2026-07-23T10:00:00Z" /> ), - statusReport: , + pageUpdate: , deactivation: ( ), diff --git a/packages/subscriptions/src/channels/email.test.ts b/packages/subscriptions/src/channels/email.test.ts index 19ec30dde..eda3ace15 100644 --- a/packages/subscriptions/src/channels/email.test.ts +++ b/packages/subscriptions/src/channels/email.test.ts @@ -15,7 +15,7 @@ import { // loads successfully and EmailClient prototype methods can be spied on. let sendPageSubscriptionMock: Stub; -let sendStatusReportUpdateMock: Stub; +let sendPageUpdateMock: Stub; function makeSub(overrides: Partial = {}): Subscription { return { @@ -51,16 +51,14 @@ beforeEach(() => { "sendPageSubscription", () => Promise.resolve(undefined), ); - sendStatusReportUpdateMock = stub( - EmailClient.prototype, - "sendStatusReportUpdate", - () => Promise.resolve(undefined), + sendPageUpdateMock = stub(EmailClient.prototype, "sendPageUpdate", () => + Promise.resolve(undefined), ); }); afterEach(() => { sendPageSubscriptionMock.restore(); - sendStatusReportUpdateMock.restore(); + sendPageUpdateMock.restore(); }); // ─── validateEmailConfig ────────────────────────────────────────────────────── @@ -115,24 +113,24 @@ describe("sendEmailVerification", () => { describe("sendEmailNotifications", () => { test("does nothing for an empty subscriptions array", async () => { await sendEmailNotifications([], makeUpdate()); - assertSpyCalls(sendStatusReportUpdateMock, 0); + assertSpyCalls(sendPageUpdateMock, 0); }); test("filters out subscriptions without an email address", async () => { const sub = makeSub({ email: undefined }); await sendEmailNotifications([sub], makeUpdate()); - assertSpyCalls(sendStatusReportUpdateMock, 0); + assertSpyCalls(sendPageUpdateMock, 0); }); - test("calls sendStatusReportUpdate once with all valid subscribers", async () => { + test("calls sendPageUpdate once with all valid subscribers", async () => { const sub1 = makeSub({ email: "a@example.com", token: "token-a" }); const sub2 = makeSub({ email: "b@example.com", token: "token-b" }); const update = makeUpdate({ title: "Outage", status: "resolved" }); await sendEmailNotifications([sub1, sub2], update); - assertSpyCalls(sendStatusReportUpdateMock, 1); - const [args] = sendStatusReportUpdateMock.calls[0].args; + assertSpyCalls(sendPageUpdateMock, 1); + const [args] = sendPageUpdateMock.calls[0].args; expect(args.subscribers).toHaveLength(2); expect(args.subscribers[0].email).toBe("a@example.com"); expect(args.subscribers[1].email).toBe("b@example.com"); @@ -140,13 +138,13 @@ describe("sendEmailNotifications", () => { expect(args.status).toBe("resolved"); }); - test("passes page components to sendStatusReportUpdate", async () => { + test("passes page components to sendPageUpdate", async () => { const sub = makeSub({ email: "user@example.com" }); const update = makeUpdate({ pageComponents: ["API", "Database"] }); await sendEmailNotifications([sub], update); - const [args] = sendStatusReportUpdateMock.calls[0].args; + const [args] = sendPageUpdateMock.calls[0].args; expect(args.pageComponents).toEqual(["API", "Database"]); }); @@ -154,7 +152,7 @@ describe("sendEmailNotifications", () => { const sub = makeSub(); await sendEmailNotifications([sub], makeUpdate({ updateId: 77 })); - const [args] = sendStatusReportUpdateMock.calls[0].args; + const [args] = sendPageUpdateMock.calls[0].args; expect(args.idempotencyKey).toMatch(/^status-report-update:77:[0-9a-z]+$/); }); @@ -165,7 +163,7 @@ describe("sendEmailNotifications", () => { makeUpdate({ id: 17, updateId: undefined, status: "maintenance" }), ); - const [args] = sendStatusReportUpdateMock.calls[0].args; + const [args] = sendPageUpdateMock.calls[0].args; expect(args.idempotencyKey).toMatch( /^page-update:17:maintenance:[0-9a-z]+$/, ); @@ -176,9 +174,7 @@ describe("sendEmailNotifications", () => { await sendEmailNotifications([makeSub()], update); await sendEmailNotifications([makeSub()], update); - const keys = sendStatusReportUpdateMock.calls.map( - (c) => c.args[0].idempotencyKey, - ); + const keys = sendPageUpdateMock.calls.map((c) => c.args[0].idempotencyKey); expect(keys[0]).toBe(keys[1]); }); @@ -190,12 +186,28 @@ describe("sendEmailNotifications", () => { update, ); - const keys = sendStatusReportUpdateMock.calls.map( - (c) => c.args[0].idempotencyKey, - ); + const keys = sendPageUpdateMock.calls.map((c) => c.args[0].idempotencyKey); expect(keys[0]).not.toBe(keys[1]); }); + test("passes the maintenance window alongside the update date", async () => { + await sendEmailNotifications( + [makeSub()], + makeUpdate({ + updateId: 77, + status: "maintenance", + date: "2026-07-16T11:00:00Z", + startsAt: "2026-07-20T10:00:00Z", + endsAt: "2026-07-20T11:00:00Z", + }), + ); + + const [args] = sendPageUpdateMock.calls[0].args; + expect(args.date).toBe("2026-07-16T11:00:00Z"); + expect(args.startsAt).toBe("2026-07-20T10:00:00Z"); + expect(args.endsAt).toBe("2026-07-20T11:00:00Z"); + }); + test("changes the key when the maintenance window changes", async () => { const base = { updateId: 77, @@ -212,9 +224,7 @@ describe("sendEmailNotifications", () => { makeUpdate({ ...base, endsAt: "2026-07-20T12:00:00Z" }), ); - const keys = sendStatusReportUpdateMock.calls.map( - (c) => c.args[0].idempotencyKey, - ); + const keys = sendPageUpdateMock.calls.map((c) => c.args[0].idempotencyKey); expect(keys[0]).not.toBe(keys[1]); }); @@ -229,9 +239,7 @@ describe("sendEmailNotifications", () => { makeUpdate({ updateId: 77, date, message: "Root cause identified." }), ); - const keys = sendStatusReportUpdateMock.calls.map( - (c) => c.args[0].idempotencyKey, - ); + const keys = sendPageUpdateMock.calls.map((c) => c.args[0].idempotencyKey); expect(keys[0]).not.toBe(keys[1]); }); }); diff --git a/packages/subscriptions/src/channels/email.ts b/packages/subscriptions/src/channels/email.ts index afccdccef..a50afa5c1 100644 --- a/packages/subscriptions/src/channels/email.ts +++ b/packages/subscriptions/src/channels/email.ts @@ -104,7 +104,7 @@ export async function sendEmailNotifications( ); const client = getEmailClient(); - await client.sendStatusReportUpdate({ + await client.sendPageUpdate({ subscribers: validSubscriptions.map((sub) => ({ email: sub.email, token: sub.token, @@ -115,13 +115,10 @@ export async function sendEmailNotifications( reportTitle: pageUpdate.title, status: pageUpdate.status, message: pageUpdate.message, - // the template prints non-date strings verbatim, so the window reads "from - to" - date: - pageUpdate.status === "maintenance" && - pageUpdate.startsAt && - pageUpdate.endsAt - ? `${pageUpdate.startsAt} - ${pageUpdate.endsAt}` - : pageUpdate.date, + date: pageUpdate.date, + // the template formats the maintenance window as its own row + startsAt: pageUpdate.startsAt, + endsAt: pageUpdate.endsAt, pageComponents: pageUpdate.pageComponents, componentImpacts: pageUpdate.componentsWithImpact, idempotencyKey: `${idempotencyKeyFor(pageUpdate)}:${payloadHash}`, diff --git a/packages/subscriptions/src/dispatcher.test.ts b/packages/subscriptions/src/dispatcher.test.ts index f46a5dd87..8bf065463 100644 --- a/packages/subscriptions/src/dispatcher.test.ts +++ b/packages/subscriptions/src/dispatcher.test.ts @@ -38,7 +38,7 @@ import type { PageUpdate } from "./types"; // RESEND_API_KEY is set in test-preload.ts (see bunfig.toml) so @openstatus/emails // loads successfully and EmailClient prototype methods can be spied on. -let sendStatusReportUpdateMock: Stub; +let sendPageUpdateMock: Stub; let rejectNextSend: Error | null = null; // Built in `beforeAll` — a private page keeps the subscriber set this suite @@ -133,22 +133,18 @@ afterAll(cleanAll); beforeEach(() => { rejectNextSend = null; - sendStatusReportUpdateMock = stub( - EmailClient.prototype, - "sendStatusReportUpdate", - () => { - if (rejectNextSend) { - const error = rejectNextSend; - rejectNextSend = null; - return Promise.reject(error); - } - return Promise.resolve(undefined); - }, - ); + sendPageUpdateMock = stub(EmailClient.prototype, "sendPageUpdate", () => { + if (rejectNextSend) { + const error = rejectNextSend; + rejectNextSend = null; + return Promise.reject(error); + } + return Promise.resolve(undefined); + }); }); afterEach(() => { - sendStatusReportUpdateMock.restore(); + sendPageUpdateMock.restore(); }); // ─── dispatchPageUpdate - component filtering ───────────────────────────────── @@ -159,8 +155,8 @@ describe("dispatchPageUpdate - component filtering", () => { makePageUpdate({ pageComponentIds: [COMPONENT_1] }), ); - assertSpyCalls(sendStatusReportUpdateMock, 1); - const { subscribers } = sendStatusReportUpdateMock.calls[0].args[0]; + assertSpyCalls(sendPageUpdateMock, 1); + const { subscribers } = sendPageUpdateMock.calls[0].args[0]; const emails = subscribers.map((s: { email: string }) => s.email); expect(emails).toContain(EMAILS.entirePage); @@ -173,7 +169,7 @@ describe("dispatchPageUpdate - component filtering", () => { makePageUpdate({ pageComponentIds: [COMPONENT_2] }), ); - const { subscribers } = sendStatusReportUpdateMock.calls[0].args[0]; + const { subscribers } = sendPageUpdateMock.calls[0].args[0]; const emails = subscribers.map((s: { email: string }) => s.email); expect(emails).toContain(EMAILS.entirePage); @@ -186,7 +182,7 @@ describe("dispatchPageUpdate - component filtering", () => { makePageUpdate({ pageComponentIds: [COMPONENT_1, COMPONENT_2] }), ); - const { subscribers } = sendStatusReportUpdateMock.calls[0].args[0]; + const { subscribers } = sendPageUpdateMock.calls[0].args[0]; const emails = subscribers.map((s: { email: string }) => s.email); expect(emails).toContain(EMAILS.entirePage); @@ -197,8 +193,8 @@ describe("dispatchPageUpdate - component filtering", () => { test("notifies only the entire-page subscriber when update has no affected components", async () => { await dispatchPageUpdate(makePageUpdate({ pageComponentIds: [] })); - assertSpyCalls(sendStatusReportUpdateMock, 1); - const { subscribers } = sendStatusReportUpdateMock.calls[0].args[0]; + assertSpyCalls(sendPageUpdateMock, 1); + const { subscribers } = sendPageUpdateMock.calls[0].args[0]; const emails = subscribers.map((s: { email: string }) => s.email); // Assert only against this suite's own subscribers — package test suites @@ -216,7 +212,7 @@ describe("dispatchPageUpdate - component filtering", () => { makePageUpdate({ pageComponentIds: [COMPONENT_1] }), ); - const { subscribers } = sendStatusReportUpdateMock.calls[0].args[0]; + const { subscribers } = sendPageUpdateMock.calls[0].args[0]; const emails = subscribers.map((s: { email: string }) => s.email); expect(emails).not.toContain(EMAILS.component2); @@ -231,10 +227,10 @@ describe("dispatchPageUpdate - edge cases", () => { makePageUpdate({ pageId: 99999, pageComponentIds: [COMPONENT_1] }), ); - assertSpyCalls(sendStatusReportUpdateMock, 0); + assertSpyCalls(sendPageUpdateMock, 0); }); - test("does not propagate channel failure — resolves even when sendStatusReportUpdate throws", async () => { + test("does not propagate channel failure — resolves even when sendPageUpdate throws", async () => { rejectNextSend = new Error("SMTP failure"); await expect( @@ -281,11 +277,11 @@ describe("dispatchMaintenanceUpdate", () => { await dispatchMaintenanceUpdate(update.id); - const args = sendStatusReportUpdateMock.calls[0].args[0]; + const args = sendPageUpdateMock.calls[0].args[0]; expect(args.message).toBe("specific update message"); - expect(args.date).toBe( - `${startsAt.toISOString()} - ${endsAt.toISOString()}`, - ); + expect(args.date).toBe(occurredAt.toISOString()); + expect(args.startsAt).toBe(startsAt.toISOString()); + expect(args.endsAt).toBe(endsAt.toISOString()); expect(args.pageComponents).toContain(COMPONENT_1_NAME); expect(args.idempotencyKey).toMatch( new RegExp(`^maintenance-update:${update.id}:`), @@ -347,8 +343,8 @@ describe("dispatchStatusReportUpdate - impacts", () => { try { await dispatchStatusReportUpdate(earlier.id); - assertSpyCalls(sendStatusReportUpdateMock, 1); - const args = sendStatusReportUpdateMock.calls[0].args[0]; + assertSpyCalls(sendPageUpdateMock, 1); + const args = sendPageUpdateMock.calls[0].args[0]; expect( args.componentImpacts.map((c: { impact: string }) => c.impact), ).toEqual(["major_outage"]); diff --git a/packages/subscriptions/src/types.ts b/packages/subscriptions/src/types.ts index 0c5684ee6..43bcb21ac 100644 --- a/packages/subscriptions/src/types.ts +++ b/packages/subscriptions/src/types.ts @@ -39,7 +39,7 @@ export interface PageUpdate { message: string; pageComponentIds: number[]; pageComponents: string[]; - date: string; // can be single string or "from - to" + date: string; // ISO timestamp of the update itself // Optional fields consumed by the prepared (staged) generic webhook payload. // Populated by the matching dispatcher; ignored by email/Slack/Discord