From 7099d22d24b44e1cc8b5ec1ecc02917a5aa68a0f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 19:03:51 -0700 Subject: [PATCH 1/3] improvement(emails): funnel every sender through the shared render and subject layer --- .../app/api/chat/[identifier]/otp/route.ts | 4 +- apps/sim/app/api/contact/route.ts | 4 +- .../app/api/files/public/[token]/otp/route.ts | 4 +- apps/sim/app/api/help/route.ts | 4 +- .../emails/_styles/base.tokens.test.ts | 56 ++-- apps/sim/components/emails/_styles/base.ts | 23 +- .../emails/agent/inbox-response-email.tsx | 184 +++++++++++++ .../emails/billing/payment-failed-email.tsx | 5 +- apps/sim/components/emails/boundary.test.ts | 97 +++++++ apps/sim/components/emails/render.ts | 24 +- apps/sim/components/emails/subjects.ts | 29 +- .../lib/billing/core/limit-notifications.ts | 2 +- apps/sim/lib/billing/core/subscription.ts | 11 +- .../sim/lib/billing/webhooks/invoices.test.ts | 6 +- apps/sim/lib/billing/webhooks/invoices.ts | 26 +- apps/sim/lib/mothership/inbox/response.ts | 257 +----------------- 16 files changed, 401 insertions(+), 335 deletions(-) create mode 100644 apps/sim/components/emails/agent/inbox-response-email.tsx create mode 100644 apps/sim/components/emails/boundary.test.ts diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 9f7fffd741f..aa936877cc5 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -3,7 +3,7 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { renderOTPEmail } from '@/components/emails' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -120,7 +120,7 @@ export const POST = withRouteHandler( const emailResult = await sendEmail({ to: email, - subject: `Verification code for ${deployment.title || 'Chat'}`, + subject: getOtpSubject(deployment.title || 'Chat'), html: emailHtml, }) diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts index 2b610ec2114..5d1c0404eba 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { renderHelpConfirmationEmail } from '@/components/emails' +import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails' import { getContactTopicLabel, mapContactTopicToHelpType, @@ -168,7 +168,7 @@ ${message} await sendEmail({ to: [email], - subject: `We've received your message: ${subject}`, + subject: getRequestConfirmationSubject(subject), html: confirmationHtml, from: getFromEmailAddress(), replyTo: `help@${helpInboxDomain}`, diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 0dd240788fd..c6b556ad41d 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { renderOTPEmail } from '@/components/emails' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, @@ -104,7 +104,7 @@ export const POST = withRouteHandler( const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) const emailResult = await sendEmail({ to: email, - subject: `Verification code for ${SHARE_EMAIL_LABEL}`, + subject: getOtpSubject(SHARE_EMAIL_LABEL), html: emailHtml, }) if (!emailResult.success) { diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 3c10f68f4d4..e87f4a38578 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { renderHelpConfirmationEmail } from '@/components/emails' +import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails' import { helpFormBodySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' @@ -130,7 +130,7 @@ ${message} await sendEmail({ to: [email], - subject: `Your ${type} request has been received: ${subject}`, + subject: getRequestConfirmationSubject(subject, type), html: confirmationHtml, from: getFromEmailAddress(), replyTo: getHelpEmailAddress(), diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts index c1223ebec42..0baac25f776 100644 --- a/apps/sim/components/emails/_styles/base.tokens.test.ts +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -1,9 +1,7 @@ /** * Email styles cannot use CSS variables — clients strip them — so `base.ts` - * hardcodes hex copies of the platform tokens. Nothing else detects it when - * `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies - * go stale, which is exactly how they drifted before. This suite is that - * detector. + * hardcodes hex copies of the platform tokens. This suite fails when those + * copies drift from `globals.css`, `tailwind.config.ts`, or the chip chrome. * * @vitest-environment node */ @@ -11,32 +9,28 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { baseStyles, colors, typography } from '@/components/emails/_styles' +import tailwindConfig from '@/tailwind.config' const APP_ROOT = join(__dirname, '../../..') const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8') -const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8') const chipChrome = readFileSync( join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'), 'utf8' ) +const tailwindFontSize = tailwindConfig.theme?.extend?.fontSize as Record + /** - * The light-mode `:root` block. Dark mode redefines the same names later in the - * file, and emails are light-only, so the FIRST definition is the one to read. + * Dark mode redefines the same names later in the file and emails are + * light-only, so the FIRST definition is the one to read. */ function readCssVar(name: string): string { - const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`)) + const match = globalsCss.match(new RegExp(`(?:^|[^-\\w])--${name}:\\s*([^;]+);`, 'm')) if (!match) throw new Error(`--${name} not found in globals.css`) return match[1].trim() } -function readTailwindFontSize(name: string): string { - const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`)) - if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`) - return match[1] -} - /** Every email color token and the platform variable it copies. */ const COLOR_MIRROR: Record = { bgOuter: 'surface-1', @@ -44,6 +38,7 @@ const COLOR_MIRROR: Record = { surfaceSubtle: 'surface-3', textPrimary: 'text-primary', textBody: 'text-body', + textSecondary: 'text-secondary', textMuted: 'text-muted', textInverse: 'text-inverse', border: 'border', @@ -52,10 +47,7 @@ const COLOR_MIRROR: Record = { footerBg: 'surface-1', } -/** - * Tokens with no single CSS variable behind them. Each needs a stated reason — - * an entry here is a deliberate exception, not an oversight. - */ +/** Tokens with no single CSS variable behind them, and why. */ const UNMIRRORED_COLORS: Record = { brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.', } @@ -69,30 +61,25 @@ describe('email color tokens mirror globals.css', () => { it('every color token is either mirrored or has a written exemption', () => { const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)]) - const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key)) - expect(unaccounted).toEqual([]) - }) - - it('exemptions state a reason', () => { - for (const reason of Object.values(UNMIRRORED_COLORS)) { - expect(reason.trim().length).toBeGreaterThan(0) - } + expect(Object.keys(colors).filter((key) => !accounted.has(key))).toEqual([]) }) }) describe('email type scale mirrors tailwind.config.ts', () => { - it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { - expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name)) + it.each(['caption', 'small', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { + expect(typography.fontSize[name as keyof typeof typography.fontSize]).toBe( + tailwindFontSize[name] + ) }) it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => { expect(typography.fontSize.sm).toBe('14px') + expect(tailwindFontSize.sm).toBeUndefined() expect(chipChrome).toContain('text-sm') }) - it('display is deliberately off-scale (no platform headline-numeral token)', () => { - expect(typography.fontSize.display).toBe('24px') - expect(tailwindConfig).not.toContain("'24px'") + it('display is deliberately off-scale — the platform has no headline numeral', () => { + expect(Object.values(tailwindFontSize)).not.toContain(typography.fontSize.display) }) }) @@ -106,10 +93,9 @@ describe('email geometry mirrors the platform', () => { it('the CTA transcribes chipGeometryClass', () => { const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1] expect(geometry).toBeDefined() - expect(geometry).toContain('h-[30px]') - expect(geometry).toContain('rounded-lg') - expect(geometry).toContain('px-2') - expect(geometry).toContain('text-sm') + for (const token of ['h-[30px]', 'rounded-lg', 'px-2', 'text-sm']) { + expect(geometry).toContain(token) + } expect(baseStyles.button.lineHeight).toBe('30px') expect(baseStyles.button.borderRadius).toBe('8px') diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index c9b51e90de4..6aa4c7d02d0 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -21,6 +21,8 @@ function buildColors() { textPrimary: '#1a1a1a', /** Body and value text — platform `--text-body` */ textBody: '#434343', + /** De-emphasized text inside a body block — platform `--text-secondary` */ + textSecondary: '#525252', /** Muted text (labels, footer) — platform `--text-muted` */ textMuted: '#7a7a7a', /** Accent for buttons and links — neutral by default, brand color when whitelabeled */ @@ -56,10 +58,13 @@ export const typography = { fontFamily: "'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif", /** - * Deliberately brand-free, for the plain personal emails — those read as a - * message typed by a person, so they must NOT carry the brand face. + * Deliberately brand-free, for emails that must read as typed by a person + * (the plain founder notes, the agent's thread replies). Carries the same + * non-brand fallbacks as {@link fontFamily} so Android and Linux clients land + * on Roboto rather than a generic sans. */ - systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + systemFontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', /** * `caption`/`base`/`md` are Sim's own scale from `tailwind.config.ts`. `sm` is * Tailwind's stock 14px — not a Sim token, but what `text-sm` resolves to in @@ -67,6 +72,7 @@ export const typography = { */ fontSize: { caption: '12px', + small: '13px', sm: '14px', base: '15px', /** Email body copy. Larger than the app's 15px `base` — the client default. */ @@ -104,7 +110,7 @@ export const spacing = { paragraphGap: 12, } -/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */ +/** Shared body-copy ramp. */ const bodyText = { fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, @@ -113,7 +119,7 @@ const bodyText = { fontFamily: typography.fontFamily, } -/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */ +/** Shared box geometry. */ const boxGeometry = { padding: '16px 18px', borderRadius: RADIUS, @@ -218,10 +224,9 @@ export const baseStyles = { }, /** - * The closing fine-print line inside the card (who this was sent to, when it - * fires again). Same ramp as {@link footerText}, but left-aligned — the card - * is left-aligned while the footer's cells are not. Every template spelled - * this out as a spread override; use the token. + * The closing fine-print line inside the card. Same ramp as + * {@link footerText}, but left-aligned — the card is left-aligned while the + * footer's own cells are not. */ footnote: { fontSize: typography.fontSize.caption, diff --git a/apps/sim/components/emails/agent/inbox-response-email.tsx b/apps/sim/components/emails/agent/inbox-response-email.tsx new file mode 100644 index 00000000000..05322657c83 --- /dev/null +++ b/apps/sim/components/emails/agent/inbox-response-email.tsx @@ -0,0 +1,184 @@ +import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react' +import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components' +import { colors, fontWeight, typography } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' + +const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace" + +const BODY_TEXT = { + fontSize: typography.fontSize.base, + lineHeight: '25px', + color: colors.textPrimary, + fontFamily: typography.systemFontFamily, + fontWeight: fontWeight.normal, +} + +const HEADING = { + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + margin: '24px 0 12px 0', + fontFamily: typography.systemFontFamily, +} + +const CODE_SURFACE = { + backgroundColor: colors.surfaceSubtle, + fontFamily: CODE_FONT_FAMILY, + fontSize: typography.fontSize.small, + color: colors.textPrimary, +} + +const emailStyles = { + body: BODY_TEXT, + content: { margin: 0 }, + markdownContainer: { margin: 0 }, + signature: { color: colors.textSecondary, marginTop: '32px', fontSize: typography.fontSize.sm }, + signatureText: { + color: colors.textSecondary, + margin: '0 0 16px 0', + fontSize: typography.fontSize.sm, + lineHeight: '25px', + fontFamily: typography.systemFontFamily, + }, + signatureLink: { + color: colors.textPrimary, + textDecoration: 'underline', + textDecorationStyle: 'dashed', + textUnderlineOffset: '2px', + }, +} satisfies Record + +const markdownStyles = { + p: { ...BODY_TEXT, margin: '0 0 16px 0' }, + h1: { ...HEADING, fontSize: typography.fontSize.display, lineHeight: '32px' }, + h2: { ...HEADING, fontSize: '20px', lineHeight: '28px' }, + h3: { ...HEADING, fontSize: typography.fontSize.md, lineHeight: '24px' }, + h4: { ...HEADING, fontSize: typography.fontSize.base, lineHeight: '25px' }, + /** + * `bold`, not `strong` — that is the key `@react-email/markdown` looks up. A + * `strong` key silently falls through to its default of 700, off the + * platform's 400/500/600 scale. + */ + bold: { fontWeight: fontWeight.semibold, color: colors.textPrimary }, + codeInline: { ...CODE_SURFACE, padding: '2px 6px', borderRadius: '4px' }, + codeBlock: { + ...CODE_SURFACE, + padding: '16px', + borderRadius: '8px', + border: `1px solid ${colors.border}`, + overflowX: 'auto', + margin: '24px 0', + lineHeight: '21px', + }, + table: { borderCollapse: 'collapse', margin: '16px 0' }, + th: { + border: `1px solid ${colors.border}`, + padding: '8px 12px', + textAlign: 'left', + fontSize: typography.fontSize.sm, + backgroundColor: colors.surfaceSubtle, + fontWeight: fontWeight.semibold, + }, + td: { + border: `1px solid ${colors.border}`, + padding: '8px 12px', + textAlign: 'left', + fontSize: typography.fontSize.sm, + }, + blockQuote: { + borderLeft: `4px solid ${colors.border}`, + margin: '16px 0', + padding: '4px 16px', + color: colors.textSecondary, + fontStyle: 'italic', + }, + a: { + color: colors.textPrimary, + textDecoration: 'underline', + textDecorationStyle: 'dashed', + textUnderlineOffset: '2px', + }, + ul: { margin: '16px 0', paddingLeft: '24px' }, + ol: { margin: '16px 0', paddingLeft: '24px' }, + li: { margin: '4px 0' }, + hr: { border: 'none', borderTop: `1px solid ${colors.border}`, margin: '24px 0' }, +} satisfies Record + +interface EmailMarkdownProps { + children?: string + markdownContainerStyles?: CSSProperties + markdownCustomStyles?: Record +} + +const EmailMarkdown = Markdown as ComponentType + +/** + * Shell for the agent's reply. Deliberately not {@link EmailLayout}: this lands + * inside an existing mail thread, where a logo header and unsubscribe footer + * would be wrong — the same carve-out as `plainEmailStyles`. + */ +function InboxShell({ + children, + chatUrl, + linkLabel, +}: { + children?: ReactNode + chatUrl: string + linkLabel: string +}) { + return createElement( + Html, + { lang: 'en', dir: 'ltr' }, + createElement(Head), + createElement( + Body, + { style: emailStyles.body }, + createElement(Section, { style: emailStyles.content }, children), + createElement( + Section, + { style: emailStyles.signature }, + createElement( + Text, + { style: emailStyles.signatureText }, + createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel) + ), + createElement( + Text, + { style: emailStyles.signatureText }, + 'Best,', + createElement('br'), + getBrandConfig().name + ) + ) + ) + ) +} + +/** The agent's reply carrying its markdown answer. */ +export function InboxResponseEmail({ markdown, chatUrl }: { markdown: string; chatUrl: string }) { + return createElement( + InboxShell, + { chatUrl, linkLabel: 'View full conversation' }, + createElement( + EmailMarkdown, + { + markdownContainerStyles: emailStyles.markdownContainer, + markdownCustomStyles: markdownStyles, + }, + markdown + ) + ) +} + +/** The agent's reply when it could not complete the task. */ +export function InboxErrorEmail({ error, chatUrl }: { error: string; chatUrl: string }) { + return createElement( + InboxShell, + { chatUrl, linkLabel: 'View details' }, + createElement(Text, { style: markdownStyles.p }, "I wasn't able to complete this task."), + createElement( + Text, + { style: { ...markdownStyles.p, color: colors.textSecondary } }, + `Error: ${error}` + ) + ) +} diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index ace471b9747..e531ae4c376 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,6 +1,7 @@ import { Link, Section, Text } from '@react-email/components' import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' import { EmailButton, EmailLayout } from '@/components/emails/components' +import { getEmailSubject } from '@/components/emails/subjects' import { getBrandConfig } from '@/ee/whitelabeling' interface PaymentFailedEmailProps { @@ -9,7 +10,6 @@ interface PaymentFailedEmailProps { lastFourDigits?: string billingPortalUrl: string failureReason?: string - sentDate?: Date } export function PaymentFailedEmail({ @@ -18,11 +18,10 @@ export function PaymentFailedEmail({ lastFourDigits, billingPortalUrl, failureReason, - sentDate = new Date(), }: PaymentFailedEmailProps) { const brand = getBrandConfig() - const previewText = `${brand.name}: Payment Failed - Action Required` + const previewText = getEmailSubject('payment-failed') return ( diff --git a/apps/sim/components/emails/boundary.test.ts b/apps/sim/components/emails/boundary.test.ts new file mode 100644 index 00000000000..3e419dcea7b --- /dev/null +++ b/apps/sim/components/emails/boundary.test.ts @@ -0,0 +1,97 @@ +/** + * Enforces that every email leaves the app through the shared layer: + * `render.ts` for the body, `subjects.ts` for the subject. + * + * @vitest-environment node + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const APP_ROOT = join(__dirname, '../..') +const EMAILS_DIR = join(__dirname) + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(dir, entry) + if (statSync(full).isDirectory()) walk(full, out) + else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full) + } + return out +} + +/** + * Only files that actually send mail. Scanning every module under `lib` would + * flag unrelated `subject:` keys (mail-tool params, schema fixtures). + * `app/api/tools/ses` is excluded because it sends the end user's own mail + * through their credentials — it is not a Sim-branded email. + */ +const senderFiles = ['lib', 'app/api', 'background'] + .flatMap((root) => walk(join(APP_ROOT, root))) + .filter((f) => !f.includes(join(APP_ROOT, 'app/api/tools/ses'))) + .filter((f) => readFileSync(f, 'utf8').includes('sendEmail')) + +const rel = (f: string) => f.slice(APP_ROOT.length + 1) + +/** Template components, read from `render.ts`'s imports so new ones are covered. */ +const renderSource = readFileSync(join(EMAILS_DIR, 'render.ts'), 'utf8') +const templateComponents = [ + ...new Set( + [...renderSource.slice(0, renderSource.indexOf('export ')).matchAll(/\b(\w*Email)\b/g)].map( + (m) => m[1] + ) + ), +] + +describe('every email goes through the shared layer', () => { + it('finds the senders and the templates', () => { + expect(senderFiles.length).toBeGreaterThan(5) + expect(templateComponents).toContain('WelcomeEmail') + }) + + it('no sender imports @react-email/render directly', () => { + const offenders = senderFiles.filter((f) => + readFileSync(f, 'utf8').includes("from '@react-email/render'") + ) + expect(offenders.map(rel)).toEqual([]) + }) + + it('no sender imports a template component instead of its render wrapper', () => { + const offenders: string[] = [] + for (const file of senderFiles) { + const src = readFileSync(file, 'utf8') + const emailImports = src.match(/import\s*\{[^}]+\}\s*from\s*'@\/components\/emails[^']*'/gs) + for (const block of emailImports ?? []) { + for (const component of templateComponents) { + if (new RegExp(`\\b${component}\\b`).test(block)) { + offenders.push(`${rel(file)} -> ${component}`) + } + } + } + } + expect(offenders).toEqual([]) + }) + + it('no sender builds its own subject line', () => { + const offenders: string[] = [] + for (const file of senderFiles) { + for (const match of readFileSync(file, 'utf8').matchAll(/subject:\s*(['"`])(.*?)\1/g)) { + // Bracket-tagged subjects are internal team-inbox alerts, not product email. + if (match[2].startsWith('[')) continue + offenders.push(`${rel(file)}: ${match[2]}`) + } + } + expect(offenders).toEqual([]) + }) + + it('the agent reply keeps markdown emphasis on the platform weight scale', async () => { + const { renderInboxResponseEmail } = await import('@/components/emails') + const html = await renderInboxResponseEmail({ + markdown: 'Some **emphasis** here.', + chatUrl: 'https://example.test/chat', + }) + expect(html).not.toMatch(/font-weight:(700|bold)/) + expect(html).toContain('font-weight:600') + }) +}) diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 3b8f7e694f2..83d285fe17f 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -1,4 +1,5 @@ import { render } from '@react-email/render' +import { InboxErrorEmail, InboxResponseEmail } from '@/components/emails/agent/inbox-response-email' import { ExistingAccountEmail, OnboardingFollowupEmail, @@ -30,8 +31,6 @@ import type { UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' -export { getEmailSubject, getLimitEmailSubject } from './subjects' - interface WorkspaceInvitation { workspaceId: string workspaceName: string @@ -284,3 +283,24 @@ export async function renderPaymentFailedEmail(params: { }) ) } + +/** Neutralize `javascript:`/`data:` hrefs that agent-authored markdown could emit. */ +function stripUnsafeUrls(html: string): string { + return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"') +} + +/** The agent's reply to an inbound email. */ +export async function renderInboxResponseEmail(params: { + markdown: string + chatUrl: string +}): Promise { + return stripUnsafeUrls(await render(InboxResponseEmail(params))) +} + +/** The agent's reply when the task could not be completed. */ +export async function renderInboxErrorEmail(params: { + error: string + chatUrl: string +}): Promise { + return stripUnsafeUrls(await render(InboxErrorEmail(params))) +} diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index b5a4dc7ac71..b8df1a5f27f 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -12,12 +12,10 @@ export type EmailSubjectType = | 'invitation' | 'batch-invitation' | 'workspace-added' - | 'help-confirmation' | 'enterprise-subscription' | 'usage-threshold' | 'free-tier-upgrade' - | 'plan-welcome-pro' - | 'plan-welcome-team' + | 'payment-failed' | 'credit-purchase' | 'abandoned-checkout' | 'free-tier-exhausted' @@ -52,18 +50,14 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've been invited to join a team and workspaces on ${brandName}` case 'workspace-added': return `You've been added to a workspace on ${brandName}` - case 'help-confirmation': - return 'Your request has been received' case 'enterprise-subscription': return `Your Enterprise Plan is now active on ${brandName}` case 'usage-threshold': return `You're nearing your monthly budget on ${brandName}` case 'free-tier-upgrade': return `You're at 80% of your free credits on ${brandName}` - case 'plan-welcome-pro': - return `Your Pro plan is now active on ${brandName}` - case 'plan-welcome-team': - return `Your Team plan is now active on ${brandName}` + case 'payment-failed': + return `Payment failed on ${brandName} — action required` case 'credit-purchase': return `Credits added to your ${brandName} account` case 'abandoned-checkout': @@ -92,3 +86,20 @@ export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'r const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject return `${subject} on ${brandName}` } + +/** The plan's display name is resolved at send time; it carries tier qualifiers. */ +export function getPlanWelcomeSubject(planDisplayName: string): string { + return `Your ${planDisplayName} plan is now active on ${getBrandConfig().name}` +} + +/** Echoes the sender's own subject line so the reply threads correctly. */ +export function getRequestConfirmationSubject(userSubject: string, requestType?: string): string { + return requestType + ? `Your ${requestType} request has been received: ${userSubject}` + : `We've received your message: ${userSubject}` +} + +/** Names the resource being unlocked rather than the brand — that is what the recipient opened. */ +export function getOtpSubject(resourceLabel: string): string { + return `Verification code for ${resourceLabel}` +} diff --git a/apps/sim/lib/billing/core/limit-notifications.ts b/apps/sim/lib/billing/core/limit-notifications.ts index 3b0b7f753a3..2df581f402a 100644 --- a/apps/sim/lib/billing/core/limit-notifications.ts +++ b/apps/sim/lib/billing/core/limit-notifications.ts @@ -3,7 +3,7 @@ import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, sql } from 'drizzle-orm' -import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails/render' +import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { BillingEntity } from '@/lib/billing/core/usage-log' diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index e4b9fa010d2..44345553e4d 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -731,21 +731,24 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise { .limit(1) if (users.length > 0 && users[0].email) { - const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails') + const { getPlanWelcomeSubject, renderPlanWelcomeEmail } = await import( + '@/components/emails' + ) const { sendEmail } = await import('@/lib/messaging/email/mailer') const baseUrl = getBaseUrl() const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') + const displayName = getDisplayPlanName(subPlan) + const html = await renderPlanWelcomeEmail({ - planName: getDisplayPlanName(subPlan), + planName: displayName, userName: users[0].name || undefined, loginLink: `${baseUrl}/login`, }) - const displayName = getDisplayPlanName(subPlan) await sendEmail({ to: users[0].email, - subject: `Your ${displayName} plan is now active on ${(await import('@/ee/whitelabeling')).getBrandConfig().name}`, + subject: getPlanWelcomeSubject(displayName), html, emailType: 'updates', }) diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index cdd2d96088c..61368c8050a 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -18,9 +18,9 @@ const { mockBlockOrgMembers, mockUnblockOrgMembers } = vi.hoisted(() => ({ })) vi.mock('@/components/emails', () => ({ - PaymentFailedEmail: vi.fn(), getEmailSubject: vi.fn(), renderCreditPurchaseEmail: vi.fn(), + renderPaymentFailedEmail: vi.fn(), })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -93,10 +93,6 @@ vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn(() => ({ isValid: true })), })) -vi.mock('@react-email/render', () => ({ - render: vi.fn(), -})) - import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded, diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 52e8b423a3b..a89383ccab0 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,4 +1,3 @@ -import { render } from '@react-email/render' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { @@ -12,7 +11,11 @@ import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' -import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' +import { + getEmailSubject, + renderCreditPurchaseEmail, + renderPaymentFailedEmail, +} from '@/components/emails' import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { @@ -353,22 +356,19 @@ async function sendPaymentFailureEmails( // Send emails to all affected users for (const userToNotify of usersToNotify) { try { - const emailHtml = await render( - PaymentFailedEmail({ - userName: userToNotify.name || undefined, - amountDue, - lastFourDigits, - billingPortalUrl, - failureReason, - sentDate: new Date(), - }) - ) + const emailHtml = await renderPaymentFailedEmail({ + userName: userToNotify.name || undefined, + amountDue, + lastFourDigits, + billingPortalUrl, + failureReason, + }) const { from } = getPersonalEmailFrom() const replyTo = getHelpEmailAddress() await sendEmail({ to: userToNotify.email, - subject: 'Payment Failed - Action Required', + subject: getEmailSubject('payment-failed'), html: emailHtml, from, replyTo, diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index 9f76951cf8c..e4c0561c8ea 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -1,12 +1,11 @@ -import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react' -import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components' -import { render } from '@react-email/render' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { renderInboxErrorEmail, renderInboxResponseEmail } from '@/components/emails' import { getBaseUrl } from '@/lib/core/utils/urls' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { replaceUntilStable } from '@/lib/mothership/inbox/format' import type { InboxTask } from '@/lib/mothership/inbox/types' +import { getBrandConfig } from '@/ee/whitelabeling' const logger = createLogger('InboxResponse') @@ -35,13 +34,18 @@ export async function sendInboxResponse( ? `${getBaseUrl()}/workspace/${ctx.workspaceId}/chat/${inboxTask.chatId}` : `${getBaseUrl()}/workspace/${ctx.workspaceId}/home` + const brandName = getBrandConfig().name + const text = result.success - ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\nMothership` - : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\nMothership` + ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\n${brandName}` + : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\n${brandName}` const html = result.success - ? await renderEmailHtml(result.content, chatUrl) - : await renderErrorHtml(result.error || 'Unknown error', chatUrl) + ? await renderInboxResponseEmail({ + markdown: preserveSoftBreaks(stripRawHtml(result.content)), + chatUrl, + }) + : await renderInboxErrorEmail({ error: result.error || 'Unknown error', chatUrl }) try { const response = await agentmail.replyToMessage( @@ -61,200 +65,6 @@ export async function sendInboxResponse( } } -const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif" -const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace" - -const emailStyles = { - body: { - fontFamily: FONT_FAMILY, - fontSize: '15px', - lineHeight: '25px', - color: '#1a1a1a', - fontWeight: 400, - }, - content: { - margin: 0, - }, - markdownContainer: { - margin: 0, - }, - signature: { - color: '#525252', - marginTop: '32px', - fontSize: '14px', - }, - signatureText: { - color: '#525252', - margin: '0 0 16px 0', - fontSize: '14px', - lineHeight: '25px', - fontFamily: FONT_FAMILY, - }, - signatureLink: { - color: '#1a1a1a', - textDecoration: 'underline', - textDecorationStyle: 'dashed', - textUnderlineOffset: '2px', - }, -} satisfies Record - -const markdownStyles = { - p: { - margin: '0 0 16px 0', - fontSize: '15px', - lineHeight: '25px', - color: '#1a1a1a', - fontFamily: FONT_FAMILY, - fontWeight: 400, - }, - h1: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '24px', - lineHeight: '32px', - fontFamily: FONT_FAMILY, - }, - h2: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '20px', - lineHeight: '28px', - fontFamily: FONT_FAMILY, - }, - h3: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '16px', - lineHeight: '24px', - fontFamily: FONT_FAMILY, - }, - h4: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '15px', - lineHeight: '25px', - fontFamily: FONT_FAMILY, - }, - strong: { - fontWeight: 600, - color: '#1a1a1a', - }, - codeInline: { - backgroundColor: '#f3f3f3', - padding: '2px 6px', - borderRadius: '4px', - fontFamily: CODE_FONT_FAMILY, - fontSize: '13px', - color: '#1a1a1a', - }, - codeBlock: { - backgroundColor: '#f3f3f3', - padding: '16px', - borderRadius: '8px', - border: '1px solid #ededed', - overflowX: 'auto', - margin: '24px 0', - fontFamily: CODE_FONT_FAMILY, - fontSize: '13px', - lineHeight: '21px', - color: '#1a1a1a', - }, - table: { - borderCollapse: 'collapse', - margin: '16px 0', - }, - th: { - border: '1px solid #ededed', - padding: '8px 12px', - textAlign: 'left', - fontSize: '14px', - backgroundColor: '#f5f5f5', - fontWeight: 600, - }, - td: { - border: '1px solid #ededed', - padding: '8px 12px', - textAlign: 'left', - fontSize: '14px', - }, - blockQuote: { - borderLeft: '4px solid #e0e0e0', - margin: '16px 0', - padding: '4px 16px', - color: '#525252', - fontStyle: 'italic', - }, - a: { - color: '#2563eb', - textDecoration: 'underline', - textDecorationStyle: 'dashed', - textUnderlineOffset: '2px', - }, - ul: { - margin: '16px 0', - paddingLeft: '24px', - }, - ol: { - margin: '16px 0', - paddingLeft: '24px', - }, - li: { - margin: '4px 0', - }, - hr: { - border: 'none', - borderTop: '1px solid #ededed', - margin: '24px 0', - }, -} satisfies Record - -interface InboxResponseEmailProps { - children?: ReactNode - chatUrl: string - linkLabel: string -} - -interface EmailMarkdownProps { - children?: string - markdownContainerStyles?: CSSProperties - markdownCustomStyles?: Record -} - -const EmailMarkdown = Markdown as ComponentType - -function InboxResponseEmail({ children, chatUrl, linkLabel }: InboxResponseEmailProps) { - return createElement( - Html, - { lang: 'en', dir: 'ltr' }, - createElement(Head), - createElement( - Body, - { style: emailStyles.body }, - createElement(Section, { style: emailStyles.content }, children), - createElement( - Section, - { style: emailStyles.signature }, - createElement( - Text, - { style: emailStyles.signatureText }, - createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel) - ), - createElement( - Text, - { style: emailStyles.signatureText }, - 'Best,', - createElement('br'), - 'Sim' - ) - ) - ) - ) -} - function stripRawHtml(text: string): string { return text .split(/(```[\s\S]*?```)/g) @@ -270,48 +80,3 @@ function preserveSoftBreaks(text: string): string { .map((segment, i) => (i % 2 === 0 ? segment.replace(/([^\n])\n(?=[^\n])/g, '$1 \n') : segment)) .join('') } - -function stripUnsafeUrls(html: string): string { - return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"') -} - -async function renderEmailHtml(markdown: string, chatUrl: string): Promise { - const safeMarkdown = preserveSoftBreaks(stripRawHtml(markdown)) - const html = await render( - createElement( - InboxResponseEmail, - { chatUrl, linkLabel: 'View full conversation' }, - createElement( - EmailMarkdown, - { - markdownContainerStyles: emailStyles.markdownContainer, - markdownCustomStyles: markdownStyles, - }, - safeMarkdown - ) - ) - ) - - return stripUnsafeUrls(html) -} - -async function renderErrorHtml(error: string, chatUrl: string): Promise { - const html = await render( - createElement( - InboxResponseEmail, - { chatUrl, linkLabel: 'View details' }, - createElement( - Text, - { key: 'message', style: markdownStyles.p }, - "I wasn't able to complete this task." - ), - createElement( - Text, - { key: 'error', style: { ...markdownStyles.p, color: '#6b7280' } }, - `Error: ${error}` - ) - ) - ) - - return stripUnsafeUrls(html) -} From 5d9388e38a1f1cbcd0e34c989dbcb19f5157a90e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 19:13:53 -0700 Subject: [PATCH 2/3] fix(emails): cover dynamic imports in the boundary guard and mock the new subject helper --- .../api/chat/[identifier]/otp/route.test.ts | 1 + .../files/public/[token]/otp/route.test.ts | 5 ++++- apps/sim/components/emails/boundary.test.ts | 21 ++++++++++--------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 1c60db08fa3..e49b485cca3 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -89,6 +89,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ })) vi.mock('@/components/emails', () => ({ + getOtpSubject: (label: string) => `Verification code for ${label}`, renderOTPEmail: mockRenderOTPEmail, })) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts index eb363eb7d5f..bce515c9237 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.test.ts @@ -50,7 +50,10 @@ vi.mock('@/lib/core/security/otp', () => ({ OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, })) -vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail })) +vi.mock('@/components/emails', () => ({ + getOtpSubject: (label: string) => `Verification code for ${label}`, + renderOTPEmail: mockRenderOTPEmail, +})) vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail })) vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { diff --git a/apps/sim/components/emails/boundary.test.ts b/apps/sim/components/emails/boundary.test.ts index 3e419dcea7b..cfe6956c8d6 100644 --- a/apps/sim/components/emails/boundary.test.ts +++ b/apps/sim/components/emails/boundary.test.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { renderInboxResponseEmail } from '@/components/emails/render' const APP_ROOT = join(__dirname, '../..') const EMAILS_DIR = join(__dirname) @@ -50,23 +51,24 @@ describe('every email goes through the shared layer', () => { expect(templateComponents).toContain('WelcomeEmail') }) - it('no sender imports @react-email/render directly', () => { + it('no sender reaches for @react-email/render', () => { + // Substring, not an import-statement match — senders here use `await import()` too. const offenders = senderFiles.filter((f) => - readFileSync(f, 'utf8').includes("from '@react-email/render'") + readFileSync(f, 'utf8').includes('@react-email/render') ) expect(offenders.map(rel)).toEqual([]) }) - it('no sender imports a template component instead of its render wrapper', () => { + it('no sender names a template component instead of its render wrapper', () => { const offenders: string[] = [] for (const file of senderFiles) { const src = readFileSync(file, 'utf8') - const emailImports = src.match(/import\s*\{[^}]+\}\s*from\s*'@\/components\/emails[^']*'/gs) - for (const block of emailImports ?? []) { - for (const component of templateComponents) { - if (new RegExp(`\\b${component}\\b`).test(block)) { - offenders.push(`${rel(file)} -> ${component}`) - } + for (const component of templateComponents) { + // Whole-file scan so static and dynamic imports are both covered. The + // word boundary keeps `PaymentFailedEmail` from matching inside + // `renderPaymentFailedEmail`. + if (new RegExp(`\\b${component}\\b`).test(src)) { + offenders.push(`${rel(file)} -> ${component}`) } } } @@ -86,7 +88,6 @@ describe('every email goes through the shared layer', () => { }) it('the agent reply keeps markdown emphasis on the platform weight scale', async () => { - const { renderInboxResponseEmail } = await import('@/components/emails') const html = await renderInboxResponseEmail({ markdown: 'Some **emphasis** here.', chatUrl: 'https://example.test/chat', From 42d1ba7bcaca612308e569a50b5b872b80324639 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 19:23:20 -0700 Subject: [PATCH 3/3] fix(emails): mock the module the limit-notification sender actually imports --- apps/sim/lib/billing/core/limit-notifications.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/billing/core/limit-notifications.test.ts b/apps/sim/lib/billing/core/limit-notifications.test.ts index 1d00608d855..dca72db39b7 100644 --- a/apps/sim/lib/billing/core/limit-notifications.test.ts +++ b/apps/sim/lib/billing/core/limit-notifications.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ getEmailPreferences: getEmailPreferencesMock, })) -vi.mock('@/components/emails/render', () => ({ +vi.mock('@/components/emails', () => ({ renderLimitThresholdEmail: renderMock, getLimitEmailSubject: subjectMock, })) @@ -72,6 +72,9 @@ describe('maybeSendLimitThresholdEmail', () => { expect(sendEmailSpy).toHaveBeenCalledTimes(1) expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })) expect(subjectMock).toHaveBeenCalledWith('storage', 'warning') + // Pins the subject to the shared helper's return, so a sender that builds + // its own string — or a mock aimed at the wrong module path — fails here. + expect(sendEmailSpy).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Subject' })) }) it('sends a reached email at/over 100%', async () => {