From 4482264cacb8901bb26ef2fffa3fc885ef9774a6 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Wed, 21 Oct 2026 16:25:42 +0200 Subject: [PATCH] email: improve deliverability --- .../routes/v1/pageSubscribers/post.test.ts | 4 +- .../src/routes/v1/pageSubscribers/post.ts | 8 +++- packages/emails/src/client.tsx | 8 ++-- packages/emails/src/index.ts | 1 + packages/emails/src/sender.test.ts | 45 +++++++++++++++++++ packages/emails/src/sender.ts | 20 +++++++++ 6 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 packages/emails/src/sender.test.ts create mode 100644 packages/emails/src/sender.ts diff --git a/apps/server/src/routes/v1/pageSubscribers/post.test.ts b/apps/server/src/routes/v1/pageSubscribers/post.test.ts index f45c8ed02..6d9f97091 100644 --- a/apps/server/src/routes/v1/pageSubscribers/post.test.ts +++ b/apps/server/src/routes/v1/pageSubscribers/post.test.ts @@ -57,8 +57,8 @@ test("sends the same redesigned verification email as the status page form", asy expect(send).toHaveBeenCalledTimes(1); const payload = send.mock.calls[0][0]; expect(payload.to).toEqual([email]); - expect(payload.from).toBe( - "Status Page ", + expect(payload.from).toMatch( + /^".+" $/, ); expect(payload.subject).toMatch(/^Confirm your subscription to /); // the route calls the template as a function, so this is its diff --git a/apps/server/src/routes/v1/pageSubscribers/post.ts b/apps/server/src/routes/v1/pageSubscribers/post.ts index f8ea2b1f1..ddcca3408 100644 --- a/apps/server/src/routes/v1/pageSubscribers/post.ts +++ b/apps/server/src/routes/v1/pageSubscribers/post.ts @@ -3,7 +3,11 @@ import { Events } from "@openstatus/analytics"; import { and, eq, isNull, sql } from "@openstatus/db"; import { db } from "@openstatus/db/src/db"; import { page, pageSubscriber } from "@openstatus/db/src/schema"; -import { PageSubscriptionEmail, sendEmail } from "@openstatus/emails"; +import { + PageSubscriptionEmail, + sendEmail, + statusPageFrom, +} from "@openstatus/emails"; import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import { trackMiddleware } from "@/libs/middlewares"; @@ -112,7 +116,7 @@ export function registerPostPageSubscriber(api: typeof pageSubscribersApi) { link, page: _page.title, }), - from: "Status Page ", + from: statusPageFrom(_page.title), to: [normalizedEmail], subject: `Confirm your subscription to ${_page.title}`, }); diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 40d51776a..21e6a50fb 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -26,6 +26,7 @@ import type { StatusPageMagicLinkProps } from "../emails/status-page-magic-link" import TeamInvitationEmail from "../emails/team-invitation"; import type { TeamInvitationProps } from "../emails/team-invitation"; import { env } from "./env"; +import { statusPageFrom } from "./sender"; export function pageUpdateSubject(req: { status: PageUpdateProps["status"]; @@ -39,7 +40,6 @@ export function pageUpdateSubject(req: { const SYSTEM_FROM = "openstatus "; const ACCOUNT_FROM = "openstatus "; -const SUBSCRIBERS_DOMAIN = "subscribers.openstatus.dev"; const SUPPORT_EMAIL = "ping@openstatus.dev"; // Deterministic Resend rejections: retrying the identical request can never @@ -231,7 +231,7 @@ export class EmailClient { const unsubscribeUrl = `${statusPageBaseUrl}/unsubscribe/${subscriber.token}`; const manageUrl = `${statusPageBaseUrl}/manage/${subscriber.token}`; return { - from: `${req.pageTitle} `, + from: statusPageFrom(req.pageTitle), subject: pageUpdateSubject(req), to: subscriber.email, react: ( @@ -341,7 +341,7 @@ export class EmailClient { try { const html = await render(); const result = await this.client.emails.send({ - from: `Status Page `, + from: statusPageFrom(req.page), subject: `Confirm your subscription to ${req.page}`, to: req.to, html, @@ -370,7 +370,7 @@ export class EmailClient { try { const html = await render(); const result = await this.client.emails.send({ - from: `Status Page `, + from: statusPageFrom(req.page), subject: `Authenticate to ${req.page}`, to: req.to, html, diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index 0cbd74281..5a8ee2295 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -12,6 +12,7 @@ export { default as StatusPageMagicLinkEmail } from "../emails/status-page-magic export { default as DashboardMagicLinkEmail } from "../emails/dashboard-magic-link"; export { monitorDeactivationEmail, monitorPausedEmail } from "./render"; +export { statusPageFrom } from "./sender"; export { cancelScheduledEmail, sendEmail, diff --git a/packages/emails/src/sender.test.ts b/packages/emails/src/sender.test.ts new file mode 100644 index 000000000..02166c626 --- /dev/null +++ b/packages/emails/src/sender.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; + +import { statusPageFrom } from "./sender"; + +describe("statusPageFrom", () => { + test("quotes the page title", () => { + expect(statusPageFrom("Acme")).toBe( + '"Acme" ', + ); + }); + + test("keeps commas inside the quoted name", () => { + expect(statusPageFrom("Acme, Inc.")).toBe( + '"Acme, Inc." ', + ); + }); + + test("strips characters that would break the header", () => { + expect(statusPageFrom('Acme "EU" \r\nBcc: x\\y')).toBe( + '"Acme EU status Bcc: x y" ', + ); + }); + + test("strips @ so the name can't pose as an address", () => { + expect(statusPageFrom("security@paypal.com")).toBe( + '"security paypal.com" ', + ); + }); + + test("caps the length without splitting characters", () => { + const from = statusPageFrom(`${"a".repeat(59)}🚀🚀`); + expect(from).toBe( + `"${"a".repeat(59)}🚀" `, + ); + }); + + test("falls back to openstatus when nothing is left", () => { + expect(statusPageFrom(' "<>" ')).toBe( + '"openstatus" ', + ); + expect(statusPageFrom(null)).toBe( + '"openstatus" ', + ); + }); +}); diff --git a/packages/emails/src/sender.ts b/packages/emails/src/sender.ts new file mode 100644 index 000000000..902d0693c --- /dev/null +++ b/packages/emails/src/sender.ts @@ -0,0 +1,20 @@ +const SUBSCRIBERS_ADDRESS = "notifications@subscribers.openstatus.dev"; +const MAX_DISPLAY_NAME_LENGTH = 60; + +/** + * `From` header for status page emails. The page title is customer input, so + * drop anything that would break the quoted display name (quotes, angle + * brackets, backslashes, control chars) or make it look like an address (`@`), + * and cap the length so it doesn't read as spam in the inbox. + */ +export function statusPageFrom(pageTitle: string | null | undefined) { + const cleaned = (pageTitle ?? "") + .replace(/["<>\\@\p{Cc}]/gu, " ") + .replace(/\s+/g, " ") + .trim(); + const name = Array.from(cleaned) + .slice(0, MAX_DISPLAY_NAME_LENGTH) + .join("") + .trim(); + return `"${name || "openstatus"}" <${SUBSCRIBERS_ADDRESS}>`; +} -- 2.54.0