diff --git a/apps/server/src/routes/v1/pageSubscribers/post.test.ts b/apps/server/src/routes/v1/pageSubscribers/post.test.ts index 5a5973b28..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 2b47d53cf..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/apps/workflows/src/cron/monitor.ts b/apps/workflows/src/cron/monitor.ts index 7142b9a09..d2c52786a 100644 --- a/apps/workflows/src/cron/monitor.ts +++ b/apps/workflows/src/cron/monitor.ts @@ -29,8 +29,7 @@ import { env } from "../env"; const redis = Redis.fromEnv(); -const PAUSE_FROM = - "Thibault from openstatus "; +const PAUSE_FROM = "Thibault from openstatus "; const PAUSE_REPLY_TO = "thibault@openstatus.dev"; // Check if GCP is properly configured (not empty and not placeholder values) diff --git a/packages/db/src/schema/pages/constants.ts b/packages/db/src/schema/pages/constants.ts index c1ebdb334..9d0139caf 100644 --- a/packages/db/src/schema/pages/constants.ts +++ b/packages/db/src/schema/pages/constants.ts @@ -15,6 +15,10 @@ export const subdomainSafeList = [ "shop", "skills", "cli", + "mail", + "notifications", + "ship", + "subscribers", ]; export const pageAccessTypes = [ diff --git a/packages/emails/src/billing.test.tsx b/packages/emails/src/billing.test.tsx index d193f8aa4..bf3ee7e95 100644 --- a/packages/emails/src/billing.test.tsx +++ b/packages/emails/src/billing.test.tsx @@ -584,7 +584,7 @@ describe("send plumbing", () => { const [payload, options] = send.mock.calls[0]; expect(payload.to).toEqual(["max@acme.dev"]); expect(payload.from).toBe( - "Thibault from openstatus ", + "Thibault from openstatus ", ); expect(payload.replyTo).toBe("thibault@openstatus.dev"); expect(payload.subject).toBe( @@ -607,7 +607,7 @@ describe("send plumbing", () => { "b@acme.dev", ]); expect(emails[0].from).toBe( - "openstatus ", + "openstatus ", ); expect(emails[0].replyTo).toBe("ping@openstatus.dev"); expect(emails[0].subject).toBe( @@ -685,9 +685,7 @@ describe("send plumbing", () => { }); expect(id).toBe("email_123"); const [payload, options] = send.mock.calls[0]; - expect(payload.from).toBe( - "openstatus ", - ); + expect(payload.from).toBe("openstatus "); expect(payload.replyTo).toBe("ping@openstatus.dev"); expect(payload.scheduledAt).toBe("2026-09-22T00:00:00.000Z"); expect(options).toEqual({ @@ -720,9 +718,7 @@ describe("send plumbing", () => { loss, }); const [payload, options] = send.mock.calls[0]; - expect(payload.from).toBe( - "openstatus ", - ); + expect(payload.from).toBe("openstatus "); expect(payload.replyTo).toBe("ping@openstatus.dev"); expect(payload.subject).toBe( "Your trial ends on 25 Sep — add a payment method to keep starter", diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx index 84980e8c3..dd0c40fb5 100644 --- a/packages/emails/src/billing.tsx +++ b/packages/emails/src/billing.tsx @@ -34,11 +34,10 @@ import TrialEndingEmail, { import { sendBatchEmailHtml, sendEmail } from "./send"; const RAW_FROM = "Thibault from openstatus "; -// Signed hybrid: personal name on the system subdomain, replies to a real inbox. -const SIGNED_FROM = - "Thibault from openstatus "; +// Signed hybrid: personal name on the account subdomain, replies to a real inbox. +const SIGNED_FROM = "Thibault from openstatus "; const SIGNED_REPLY_TO = "thibault@openstatus.dev"; -const SYSTEM_FROM = "openstatus "; +const SYSTEM_FROM = "openstatus "; const SUPPORT_EMAIL = "ping@openstatus.dev"; const DAY_MS = 86_400_000; diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index c915a29b8..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"]; @@ -38,6 +39,7 @@ export function pageUpdateSubject(req: { } const SYSTEM_FROM = "openstatus "; +const ACCOUNT_FROM = "openstatus "; const SUPPORT_EMAIL = "ping@openstatus.dev"; // Deterministic Resend rejections: retrying the identical request can never @@ -229,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: ( @@ -279,13 +281,11 @@ export class EmailClient { try { const html = await render(); const result = await this.client.emails.send({ - from: `${ - req.workspaceName || "openstatus" - } `, + from: ACCOUNT_FROM, replyTo: SUPPORT_EMAIL, - subject: `You've been invited to join ${ - req.workspaceName || "openstatus" - }`, + subject: req.workspaceName + ? `${req.invitedBy} invited you to join ${req.workspaceName} on openstatus` + : `${req.invitedBy} invited you to join openstatus`, to: req.to, html, }); @@ -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, @@ -399,7 +399,7 @@ export class EmailClient { const html = await render(); const result = await this.client.emails.send({ - from: SYSTEM_FROM, + from: ACCOUNT_FROM, subject: "Sign in to openstatus", 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}>`; +}