improvement(emails): funnel every sender through the shared render and subject layer (#6482)

* improvement(emails): funnel every sender through the shared render and subject layer

* fix(emails): cover dynamic imports in the boundary guard and mock the new subject helper

* fix(emails): mock the module the limit-notification sender actually imports
This commit is contained in:
Waleed
2026-08-09 19:28:20 -07:00
committed by GitHub
parent a5544303ef
commit a72427f38d
19 changed files with 411 additions and 337 deletions
@@ -89,6 +89,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({
}))
vi.mock('@/components/emails', () => ({
getOtpSubject: (label: string) => `Verification code for ${label}`,
renderOTPEmail: mockRenderOTPEmail,
}))
@@ -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,
})
+2 -2
View File
@@ -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}`,
@@ -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 {
@@ -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) {
+2 -2
View File
@@ -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(),
@@ -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<string, string>
/**
* 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<string, string> = {
bgOuter: 'surface-1',
@@ -44,6 +38,7 @@ const COLOR_MIRROR: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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')
+14 -9
View File
@@ -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,
@@ -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<string, CSSProperties>
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<string, CSSProperties>
interface EmailMarkdownProps {
children?: string
markdownContainerStyles?: CSSProperties
markdownCustomStyles?: Record<string, CSSProperties>
}
const EmailMarkdown = Markdown as ComponentType<EmailMarkdownProps>
/**
* 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}`
)
)
}
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={false}>
@@ -0,0 +1,98 @@
/**
* 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'
import { renderInboxResponseEmail } from '@/components/emails/render'
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 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('@react-email/render')
)
expect(offenders.map(rel)).toEqual([])
})
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')
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}`)
}
}
}
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 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')
})
})
+22 -2
View File
@@ -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<string> {
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<string> {
return stripUnsafeUrls(await render(InboxErrorEmail(params)))
}
+20 -9
View File
@@ -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}`
}
@@ -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 () => {
@@ -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'
+7 -4
View File
@@ -731,21 +731,24 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
.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',
})
@@ -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,
+13 -13
View File
@@ -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,
+11 -246
View File
@@ -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<string, CSSProperties>
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<string, CSSProperties>
interface InboxResponseEmailProps {
children?: ReactNode
chatUrl: string
linkLabel: string
}
interface EmailMarkdownProps {
children?: string
markdownContainerStyles?: CSSProperties
markdownCustomStyles?: Record<string, CSSProperties>
}
const EmailMarkdown = Markdown as ComponentType<EmailMarkdownProps>
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<string> {
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<string> {
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)
}