mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user