Skip to content

Commit a72427f

Browse files
authored
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
1 parent a554430 commit a72427f

19 files changed

Lines changed: 411 additions & 337 deletions

File tree

apps/sim/app/api/chat/[identifier]/otp/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({
8989
}))
9090

9191
vi.mock('@/components/emails', () => ({
92+
getOtpSubject: (label: string) => `Verification code for ${label}`,
9293
renderOTPEmail: mockRenderOTPEmail,
9394
}))
9495

apps/sim/app/api/chat/[identifier]/otp/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { chat } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, isNull } from 'drizzle-orm'
55
import type { NextRequest } from 'next/server'
6-
import { renderOTPEmail } from '@/components/emails'
6+
import { getOtpSubject, renderOTPEmail } from '@/components/emails'
77
import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats'
88
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
99
import { RateLimiter } from '@/lib/core/rate-limiter'
@@ -120,7 +120,7 @@ export const POST = withRouteHandler(
120120

121121
const emailResult = await sendEmail({
122122
to: email,
123-
subject: `Verification code for ${deployment.title || 'Chat'}`,
123+
subject: getOtpSubject(deployment.title || 'Chat'),
124124
html: emailHtml,
125125
})
126126

apps/sim/app/api/contact/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3-
import { renderHelpConfirmationEmail } from '@/components/emails'
3+
import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails'
44
import {
55
getContactTopicLabel,
66
mapContactTopicToHelpType,
@@ -168,7 +168,7 @@ ${message}
168168

169169
await sendEmail({
170170
to: [email],
171-
subject: `We've received your message: ${subject}`,
171+
subject: getRequestConfirmationSubject(subject),
172172
html: confirmationHtml,
173173
from: getFromEmailAddress(),
174174
replyTo: `help@${helpInboxDomain}`,

apps/sim/app/api/files/public/[token]/otp/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ vi.mock('@/lib/core/security/otp', () => ({
5050
OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 },
5151
OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 },
5252
}))
53-
vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail }))
53+
vi.mock('@/components/emails', () => ({
54+
getOtpSubject: (label: string) => `Verification code for ${label}`,
55+
renderOTPEmail: mockRenderOTPEmail,
56+
}))
5457
vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail }))
5558
vi.mock('@/lib/core/rate-limiter', () => ({
5659
RateLimiter: class {

apps/sim/app/api/files/public/[token]/otp/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { normalizeEmail } from '@sim/utils/string'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
5-
import { renderOTPEmail } from '@/components/emails'
5+
import { getOtpSubject, renderOTPEmail } from '@/components/emails'
66
import {
77
requestPublicFileOtpContract,
88
verifyPublicFileOtpContract,
@@ -104,7 +104,7 @@ export const POST = withRouteHandler(
104104
const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL)
105105
const emailResult = await sendEmail({
106106
to: email,
107-
subject: `Verification code for ${SHARE_EMAIL_LABEL}`,
107+
subject: getOtpSubject(SHARE_EMAIL_LABEL),
108108
html: emailHtml,
109109
})
110110
if (!emailResult.success) {

apps/sim/app/api/help/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3-
import { renderHelpConfirmationEmail } from '@/components/emails'
3+
import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails'
44
import { helpFormBodySchema } from '@/lib/api/contracts/common'
55
import { validationErrorResponse } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
@@ -130,7 +130,7 @@ ${message}
130130

131131
await sendEmail({
132132
to: [email],
133-
subject: `Your ${type} request has been received: ${subject}`,
133+
subject: getRequestConfirmationSubject(subject, type),
134134
html: confirmationHtml,
135135
from: getFromEmailAddress(),
136136
replyTo: getHelpEmailAddress(),

apps/sim/components/emails/_styles/base.tokens.test.ts

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,44 @@
11
/**
22
* Email styles cannot use CSS variables — clients strip them — so `base.ts`
3-
* hardcodes hex copies of the platform tokens. Nothing else detects it when
4-
* `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies
5-
* go stale, which is exactly how they drifted before. This suite is that
6-
* detector.
3+
* hardcodes hex copies of the platform tokens. This suite fails when those
4+
* copies drift from `globals.css`, `tailwind.config.ts`, or the chip chrome.
75
*
86
* @vitest-environment node
97
*/
108
import { readFileSync } from 'node:fs'
119
import { join } from 'node:path'
1210
import { describe, expect, it } from 'vitest'
1311
import { baseStyles, colors, typography } from '@/components/emails/_styles'
12+
import tailwindConfig from '@/tailwind.config'
1413

1514
const APP_ROOT = join(__dirname, '../../..')
1615

1716
const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8')
18-
const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8')
1917
const chipChrome = readFileSync(
2018
join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'),
2119
'utf8'
2220
)
2321

22+
const tailwindFontSize = tailwindConfig.theme?.extend?.fontSize as Record<string, string>
23+
2424
/**
25-
* The light-mode `:root` block. Dark mode redefines the same names later in the
26-
* file, and emails are light-only, so the FIRST definition is the one to read.
25+
* Dark mode redefines the same names later in the file and emails are
26+
* light-only, so the FIRST definition is the one to read.
2727
*/
2828
function readCssVar(name: string): string {
29-
const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`))
29+
const match = globalsCss.match(new RegExp(`(?:^|[^-\\w])--${name}:\\s*([^;]+);`, 'm'))
3030
if (!match) throw new Error(`--${name} not found in globals.css`)
3131
return match[1].trim()
3232
}
3333

34-
function readTailwindFontSize(name: string): string {
35-
const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`))
36-
if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`)
37-
return match[1]
38-
}
39-
4034
/** Every email color token and the platform variable it copies. */
4135
const COLOR_MIRROR: Record<string, string> = {
4236
bgOuter: 'surface-1',
4337
bgCard: 'surface-2',
4438
surfaceSubtle: 'surface-3',
4539
textPrimary: 'text-primary',
4640
textBody: 'text-body',
41+
textSecondary: 'text-secondary',
4742
textMuted: 'text-muted',
4843
textInverse: 'text-inverse',
4944
border: 'border',
@@ -52,10 +47,7 @@ const COLOR_MIRROR: Record<string, string> = {
5247
footerBg: 'surface-1',
5348
}
5449

55-
/**
56-
* Tokens with no single CSS variable behind them. Each needs a stated reason —
57-
* an entry here is a deliberate exception, not an oversight.
58-
*/
50+
/** Tokens with no single CSS variable behind them, and why. */
5951
const UNMIRRORED_COLORS: Record<string, string> = {
6052
brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.',
6153
}
@@ -69,30 +61,25 @@ describe('email color tokens mirror globals.css', () => {
6961

7062
it('every color token is either mirrored or has a written exemption', () => {
7163
const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)])
72-
const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key))
73-
expect(unaccounted).toEqual([])
74-
})
75-
76-
it('exemptions state a reason', () => {
77-
for (const reason of Object.values(UNMIRRORED_COLORS)) {
78-
expect(reason.trim().length).toBeGreaterThan(0)
79-
}
64+
expect(Object.keys(colors).filter((key) => !accounted.has(key))).toEqual([])
8065
})
8166
})
8267

8368
describe('email type scale mirrors tailwind.config.ts', () => {
84-
it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => {
85-
expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name))
69+
it.each(['caption', 'small', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => {
70+
expect(typography.fontSize[name as keyof typeof typography.fontSize]).toBe(
71+
tailwindFontSize[name]
72+
)
8673
})
8774

8875
it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => {
8976
expect(typography.fontSize.sm).toBe('14px')
77+
expect(tailwindFontSize.sm).toBeUndefined()
9078
expect(chipChrome).toContain('text-sm')
9179
})
9280

93-
it('display is deliberately off-scale (no platform headline-numeral token)', () => {
94-
expect(typography.fontSize.display).toBe('24px')
95-
expect(tailwindConfig).not.toContain("'24px'")
81+
it('display is deliberately off-scale — the platform has no headline numeral', () => {
82+
expect(Object.values(tailwindFontSize)).not.toContain(typography.fontSize.display)
9683
})
9784
})
9885

@@ -106,10 +93,9 @@ describe('email geometry mirrors the platform', () => {
10693
it('the CTA transcribes chipGeometryClass', () => {
10794
const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1]
10895
expect(geometry).toBeDefined()
109-
expect(geometry).toContain('h-[30px]')
110-
expect(geometry).toContain('rounded-lg')
111-
expect(geometry).toContain('px-2')
112-
expect(geometry).toContain('text-sm')
96+
for (const token of ['h-[30px]', 'rounded-lg', 'px-2', 'text-sm']) {
97+
expect(geometry).toContain(token)
98+
}
11399

114100
expect(baseStyles.button.lineHeight).toBe('30px')
115101
expect(baseStyles.button.borderRadius).toBe('8px')

apps/sim/components/emails/_styles/base.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ function buildColors() {
2121
textPrimary: '#1a1a1a',
2222
/** Body and value text — platform `--text-body` */
2323
textBody: '#434343',
24+
/** De-emphasized text inside a body block — platform `--text-secondary` */
25+
textSecondary: '#525252',
2426
/** Muted text (labels, footer) — platform `--text-muted` */
2527
textMuted: '#7a7a7a',
2628
/** Accent for buttons and links — neutral by default, brand color when whitelabeled */
@@ -56,17 +58,21 @@ export const typography = {
5658
fontFamily:
5759
"'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
5860
/**
59-
* Deliberately brand-free, for the plain personal emails — those read as a
60-
* message typed by a person, so they must NOT carry the brand face.
61+
* Deliberately brand-free, for emails that must read as typed by a person
62+
* (the plain founder notes, the agent's thread replies). Carries the same
63+
* non-brand fallbacks as {@link fontFamily} so Android and Linux clients land
64+
* on Roboto rather than a generic sans.
6165
*/
62-
systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
66+
systemFontFamily:
67+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
6368
/**
6469
* `caption`/`base`/`md` are Sim's own scale from `tailwind.config.ts`. `sm` is
6570
* Tailwind's stock 14px — not a Sim token, but what `text-sm` resolves to in
6671
* `chipGeometryClass`, so the CTA has to use it.
6772
*/
6873
fontSize: {
6974
caption: '12px',
75+
small: '13px',
7076
sm: '14px',
7177
base: '15px',
7278
/** Email body copy. Larger than the app's 15px `base` — the client default. */
@@ -104,7 +110,7 @@ export const spacing = {
104110
paragraphGap: 12,
105111
}
106112

107-
/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */
113+
/** Shared body-copy ramp. */
108114
const bodyText = {
109115
fontSize: typography.fontSize.md,
110116
lineHeight: typography.lineHeight.body,
@@ -113,7 +119,7 @@ const bodyText = {
113119
fontFamily: typography.fontFamily,
114120
}
115121

116-
/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */
122+
/** Shared box geometry. */
117123
const boxGeometry = {
118124
padding: '16px 18px',
119125
borderRadius: RADIUS,
@@ -218,10 +224,9 @@ export const baseStyles = {
218224
},
219225

220226
/**
221-
* The closing fine-print line inside the card (who this was sent to, when it
222-
* fires again). Same ramp as {@link footerText}, but left-aligned — the card
223-
* is left-aligned while the footer's cells are not. Every template spelled
224-
* this out as a spread override; use the token.
227+
* The closing fine-print line inside the card. Same ramp as
228+
* {@link footerText}, but left-aligned — the card is left-aligned while the
229+
* footer's own cells are not.
225230
*/
226231
footnote: {
227232
fontSize: typography.fontSize.caption,

0 commit comments

Comments
 (0)