From 823d281cbc259abe4ffa2ce18e6911794f808cc5 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:55:21 +0200 Subject: [PATCH] fix(emails): format the maintenance window and rename the template to page-update (#2882) * fix(emails): format the maintenance window instead of raw ISO timestamps The email channel joined the maintenance from/to ISO strings into the template's date prop, so subscribers saw a Window row like "2026-10-12T22:00:00.000Z - 2026-10-13T01:00:00.000Z". Pass the window as its own props, format it in the template, and keep date as the update's timestamp so the eyebrow shows when each update was posted. Drop the unused sendMaintenanceNotification client method. * fix: email * refactor(emails): rename sendStatusReportUpdate to sendPageUpdate The subscriber email carries maintenance updates too, so the method, subject helper, and template now use the page-update vocabulary that @openstatus/subscriptions already uses (PageUpdate, dispatchPageUpdate). - EmailClient.sendStatusReportUpdate -> sendPageUpdate - statusReportSubject -> pageUpdateSubject - emails/status-report.tsx -> emails/page-update.tsx (PageUpdateEmail, PageUpdateProps, PageUpdateSchema, pageUpdatePreheader) Idempotency key prefixes, the reportTitle prop, and the report-specific dispatchStatusReportUpdate are unchanged. * fix(emails): compare the year when collapsing a same-day window formatDateTimeRange only checked day and month, so a window ending on the same calendar date in another year rendered the end as time-only. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../routes/v1/statusReports/update/post.ts | 2 +- packages/emails/emails/_components/format.ts | 23 ++++ .../{status-report.tsx => page-update.tsx} | 48 +++++--- .../emails/src/client.integration.test.tsx | 22 ++-- packages/emails/src/client.test.ts | 14 +-- packages/emails/src/client.tsx | 104 ++---------------- packages/emails/src/format.test.ts | 15 +++ packages/emails/src/templates.test.tsx | 66 ++++++----- .../subscriptions/src/channels/email.test.ts | 62 ++++++----- packages/subscriptions/src/channels/email.ts | 13 +-- packages/subscriptions/src/dispatcher.test.ts | 54 +++++---- packages/subscriptions/src/types.ts | 2 +- 12 files changed, 207 insertions(+), 218 deletions(-) rename packages/emails/emails/{status-report.tsx => page-update.tsx} (82%) 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 -- 2.51.2