diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 77ec9673d1e..63cfb4994dd 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -1,12 +1,17 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { + renderAbandonedCheckoutEmail, renderBatchInvitationEmail, renderCreditPurchaseEmail, + renderCreditsExhaustedEmail, renderEnterpriseSubscriptionEmail, + renderExistingAccountEmail, renderFreeTierUpgradeEmail, renderHelpConfirmationEmail, renderInvitationEmail, + renderLimitThresholdEmail, + renderOnboardingFollowupEmail, renderOTPEmail, renderPasswordResetEmail, renderPaymentFailedEmail, @@ -15,8 +20,10 @@ import { renderUsageLimitReachedEmail, renderUsageThresholdEmail, renderWelcomeEmail, + renderWorkspaceAddedEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' +import { colors, typography } from '@/components/emails/_styles' import { emailPreviewQuerySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -24,11 +31,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const emailTemplates = { // Auth emails otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'), + 'otp-sign-in': () => renderOTPEmail('123456', 'user@example.com', 'sign-in'), 'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'), + 'existing-account': () => renderExistingAccountEmail('John'), welcome: () => renderWelcomeEmail('John'), + 'onboarding-followup': () => renderOnboardingFollowupEmail('John'), // Invitation emails invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'), + 'workspace-added': () => + renderWorkspaceAddedEmail('Jane Doe', 'Engineering', 'https://sim.ai/workspace/ws_123'), 'batch-invitation': () => renderBatchInvitationEmail( 'Jane Doe', @@ -87,6 +99,43 @@ const emailTemplates = { amount: 50, newBalance: 75, }), + 'credits-exhausted': () => + renderCreditsExhaustedEmail({ + userName: 'John', + limit: 10, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'abandoned-checkout': () => renderAbandonedCheckoutEmail('John'), + 'limit-threshold-storage-warning': () => + renderLimitThresholdEmail({ + kind: 'warning', + reason: 'storage', + userName: 'John', + usageLabel: '4.2 GB', + limitLabel: '5 GB', + percentUsed: 84, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'limit-threshold-tables-reached': () => + renderLimitThresholdEmail({ + kind: 'reached', + reason: 'tables', + userName: 'John', + usageLabel: '50,000 rows', + limitLabel: '50,000 rows', + percentUsed: 100, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'limit-threshold-seats-reached': () => + renderLimitThresholdEmail({ + kind: 'reached', + reason: 'seats', + userName: 'John', + usageLabel: '10 seats', + limitLabel: '10 seats', + percentUsed: 100, + upgradeLink: 'https://sim.ai/settings/billing', + }), 'payment-failed': () => renderPaymentFailedEmail({ userName: 'John', @@ -138,6 +187,40 @@ function isEmailTemplate(template: string): template is EmailTemplate { return template in emailTemplates } +const CATEGORIZED = { + Auth: ['otp', 'otp-sign-in', 'reset-password', 'existing-account', 'welcome'], + Invitations: ['invitation', 'batch-invitation', 'workspace-invitation', 'workspace-added'], + Support: ['help-confirmation'], + Billing: [ + 'usage-threshold', + 'usage-limit-reached', + 'usage-limit-reached-org', + 'free-tier-upgrade', + 'credits-exhausted', + 'limit-threshold-storage-warning', + 'limit-threshold-tables-reached', + 'limit-threshold-seats-reached', + 'payment-failed', + 'credit-purchase', + 'plan-welcome-pro', + 'plan-welcome-team', + 'enterprise-subscription', + ], + Notifications: ['schedule-disabled', 'schedule-disabled-auth'], + 'Plain (unbranded)': ['onboarding-followup', 'abandoned-checkout'], +} satisfies Record + +/** + * Category map for the gallery, with any template missing from {@link CATEGORIZED} + * appended rather than dropped — so a newly registered template always shows up + * even if nobody remembers to file it. + */ +const PREVIEW_CATEGORIES: Record = (() => { + const filed = new Set(Object.values(CATEGORIZED).flat()) + const unfiled = (Object.keys(emailTemplates) as EmailTemplate[]).filter((t) => !filed.has(t)) + return unfiled.length > 0 ? { ...CATEGORIZED, Uncategorized: unfiled } : CATEGORIZED +})() + export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const queryValidation = emailPreviewQuerySchema.safeParse( @@ -147,32 +230,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { template } = queryValidation.data if (!template) { - const categories = { - Auth: ['otp', 'reset-password', 'welcome'], - Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'], - Support: ['help-confirmation'], - Billing: [ - 'usage-threshold', - 'enterprise-subscription', - 'free-tier-upgrade', - 'plan-welcome-pro', - 'plan-welcome-team', - 'credit-purchase', - 'payment-failed', - 'usage-limit-reached', - 'usage-limit-reached-org', - ], - Notifications: ['schedule-disabled', 'schedule-disabled-auth'], - } - - const categoryHtml = Object.entries(categories) + const categoryHtml = Object.entries(PREVIEW_CATEGORIES) .map( ([category, templates]) => ` -

${category}

-
    - ${templates.map((t) => `
  • ${t}
  • `).join('')} -
- ` +
+

${category}

+
+ ${templates + .map( + (t) => ` +
+
${t}open ↗
+ +
` + ) + .join('')} +
+
` ) .join('') @@ -180,15 +254,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ` - Email Previews + + + Email Templates

Email Templates

+

Every email Sim sends — ${Object.keys(emailTemplates).length} previews.

${categoryHtml} `, diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts index 97a4a368759..5f8fb1f2892 100644 --- a/apps/sim/app/api/files/multipart/route.test.ts +++ b/apps/sim/app/api/files/multipart/route.test.ts @@ -52,19 +52,19 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({ })) vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ - generateExecutionAttachmentKey: mockGenerateExecutionAttachmentKey, + generateUniqueExecutionFileKey: mockGenerateUniqueExecutionFileKey, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) const { mockCheckStorageQuota, - mockGenerateExecutionAttachmentKey, + mockGenerateUniqueExecutionFileKey, mockInitiateS3MultipartUpload, mockResolveStorageBillingContext, } = vi.hoisted(() => ({ mockCheckStorageQuota: vi.fn(), - mockGenerateExecutionAttachmentKey: vi.fn(), + mockGenerateUniqueExecutionFileKey: vi.fn(), mockInitiateS3MultipartUpload: vi.fn(), mockResolveStorageBillingContext: vi.fn(), })) @@ -250,7 +250,7 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => { mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) mockCheckStorageQuota.mockResolvedValue({ allowed: true }) mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' }) - mockGenerateExecutionAttachmentKey.mockImplementation( + mockGenerateUniqueExecutionFileKey.mockImplementation( ( context: { workspaceId: string; workflowId: string; executionId: string }, fileName: string @@ -311,7 +311,7 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => { }) it('allocates distinct multipart keys for duplicate execution attachment names', async () => { - mockGenerateExecutionAttachmentKey + mockGenerateUniqueExecutionFileKey .mockReturnValueOnce('execution/ws-1/wf-1/exec-1/one-output.bin') .mockReturnValueOnce('execution/ws-1/wf-1/exec-1/two-output.bin') mockInitiateS3MultipartUpload.mockImplementation(async ({ customKey }) => ({ diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 38c1a396e58..1f20bad4141 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -215,10 +215,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const { generateExecutionAttachmentKey } = await import( + const { generateUniqueExecutionFileKey } = await import( '@/lib/uploads/contexts/execution/utils' ) - customKey = generateExecutionAttachmentKey( + customKey = generateUniqueExecutionFileKey( { workspaceId, workflowId, executionId }, fileName ) diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts index 1fd1578beb3..ac43909b0fd 100644 --- a/apps/sim/app/api/files/presigned/route.test.ts +++ b/apps/sim/app/api/files/presigned/route.test.ts @@ -24,7 +24,7 @@ const { mockIsUsingCloudStorageUploads, mockGetUserEntityPermissions, mockGenerateWorkspaceFileKey, - mockGenerateExecutionAttachmentKey, + mockGenerateUniqueExecutionFileKey, mockInsertFileMetadata, mockCheckStorageQuotaForBillingContext, mockDecrementStorageUsageForBillingContext, @@ -52,7 +52,7 @@ const { mockGenerateWorkspaceFileKey: vi.fn( (workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}` ), - mockGenerateExecutionAttachmentKey: vi.fn( + mockGenerateUniqueExecutionFileKey: vi.fn( (ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) => `execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/attachment-${fileName}` ), @@ -110,7 +110,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ })) vi.mock('@/lib/uploads/contexts/execution/utils', () => ({ - generateExecutionAttachmentKey: mockGenerateExecutionAttachmentKey, + generateUniqueExecutionFileKey: mockGenerateUniqueExecutionFileKey, })) vi.mock('@/lib/uploads/server/metadata', () => ({ @@ -752,7 +752,7 @@ describe('/api/files/presigned', () => { describe('execution uploads', () => { it('allocates distinct create-only keys for duplicate attachment names', async () => { setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' }) - mockGenerateExecutionAttachmentKey + mockGenerateUniqueExecutionFileKey .mockReturnValueOnce('execution/ws-1/wf-1/exec-1/one-output.txt') .mockReturnValueOnce('execution/ws-1/wf-1/exec-1/two-output.txt') diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 63339421bc3..881784f2f23 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -11,7 +11,7 @@ import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import { getServeStoragePrefix } from '@/lib/uploads/config' -import { generateExecutionAttachmentKey } from '@/lib/uploads/contexts/execution/utils' +import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service' @@ -222,7 +222,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw new ValidationError(fileValidationError.message) } - const customKey = generateExecutionAttachmentKey( + const customKey = generateUniqueExecutionFileKey( { workspaceId, workflowId, executionId }, fileName ) diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts new file mode 100644 index 00000000000..c1223ebec42 --- /dev/null +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -0,0 +1,129 @@ +/** + * 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. + * + * @vitest-environment node + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { baseStyles, colors, typography } from '@/components/emails/_styles' + +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' +) + +/** + * 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. + */ +function readCssVar(name: string): string { + const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`)) + if (!match) throw new Error(`--${name} not found in globals.css`) + return match[1].trim() +} + +function readTailwindFontSize(name: string): string { + const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`)) + if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`) + return match[1] +} + +/** Every email color token and the platform variable it copies. */ +const COLOR_MIRROR: Record = { + bgOuter: 'surface-1', + bgCard: 'surface-2', + surfaceSubtle: 'surface-3', + textPrimary: 'text-primary', + textBody: 'text-body', + textMuted: 'text-muted', + textInverse: 'text-inverse', + border: 'border', + errorBg: 'terminal-status-error-bg', + errorBorder: 'error-muted', + 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. + */ +const UNMIRRORED_COLORS: Record = { + brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.', +} + +describe('email color tokens mirror globals.css', () => { + for (const [token, cssVar] of Object.entries(COLOR_MIRROR)) { + it(`colors.${token} equals --${cssVar}`, () => { + expect(colors[token as keyof typeof colors]).toBe(readCssVar(cssVar)) + }) + } + + 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) + } + }) +}) + +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('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => { + expect(typography.fontSize.sm).toBe('14px') + 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'") + }) +}) + +describe('email geometry mirrors the platform', () => { + it('the card radius equals --radius', () => { + // --radius is authored in rem; emails need px. + expect(readCssVar('radius')).toBe('0.5rem') + expect(baseStyles.container.borderRadius).toBe('8px') + }) + + 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') + + expect(baseStyles.button.lineHeight).toBe('30px') + expect(baseStyles.button.borderRadius).toBe('8px') + expect(baseStyles.button.padding).toBe('0 8px') + expect(baseStyles.button.fontSize).toBe(typography.fontSize.sm) + }) +}) + +describe('email font weights stay on the platform scale', () => { + it('no token uses a weight outside 400/500/600', () => { + const offScale = Object.entries(baseStyles).filter(([, style]) => { + const weight = (style as { fontWeight?: unknown }).fontWeight + return weight !== undefined && ![400, 500, 600].includes(weight as number) + }) + expect(offScale.map(([name]) => name)).toEqual([]) + }) +}) diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index 2ff9fcc74ac..c9b51e90de4 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -13,46 +13,70 @@ function buildColors() { isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a' return { - /** Main canvas background — a hair off-white so the white card reads via contrast, not the border alone */ - bgOuter: '#f8f8f8', + /** Canvas behind the card — platform `--surface-1` (the sidebar/panel surface) */ + bgOuter: '#fbfbfb', /** Card/container background — platform `--surface-2` */ bgCard: '#ffffff', - /** Primary text — platform `--text-primary` */ + /** Headings and emphasis — platform `--text-primary` */ textPrimary: '#1a1a1a', - /** Secondary text — platform `--text-secondary` */ - textSecondary: '#525252', - /** Tertiary text — platform `--text-tertiary` */ - textTertiary: '#5c5c5c', - /** Muted text (footer) — platform `--text-muted` */ - textMuted: '#707070', - /** Brand primary — neutral by default, brand color when whitelabeled */ - brandPrimary: - isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a', + /** Body and value text — platform `--text-body` */ + textBody: '#434343', + /** Muted text (labels, footer) — platform `--text-muted` */ + textMuted: '#7a7a7a', /** Accent for buttons and links — neutral by default, brand color when whitelabeled */ brandTertiary: accentColor, - /** Border/divider — platform `--border` */ - divider: '#dedede', - /** Subtle fill for info/code boxes on the white card */ + /** Borders and dividers — platform `--border` */ + border: '#d8d8d8', + /** Fill for info/code boxes on the white card — platform `--surface-3` */ surfaceSubtle: '#f7f7f7', /** Error surface fill — platform `--terminal-status-error-bg` */ errorBg: '#fef2f2', /** Error surface border — platform `--error-muted` */ errorBorder: '#fecaca', + /** Text on an inverse (dark) fill, e.g. the CTA — platform `--text-inverse` */ + textInverse: '#ffffff', /** Footer background — matches the canvas */ - footerBg: '#f8f8f8', + footerBg: '#fbfbfb', } } export const colors = buildColors() -/** Typography settings */ +/** + * Typography settings. Enforced against the platform sources by + * `base.tokens.test.ts`. + */ export const typography = { + /** + * Mirrors the platform face and its fallback chain + * (`apps/sim/app/_styles/fonts/season/season.ts`). This matters more than the + * `` webfont in `EmailLayout` — Gmail and Outlook strip `@font-face` + * entirely, so for most recipients the fallback chain IS the rendered font. + */ fontFamily: - "'Season Sans', -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif", + "'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. + */ + systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", 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 + * `chipGeometryClass`, so the CTA has to use it. + */ fontSize: { - body: '16px', - small: '14px', caption: '12px', + sm: '14px', + base: '15px', + /** Email body copy. Larger than the app's 15px `base` — the client default. */ + md: '16px', + /** + * Display figure (OTP code, credit balance). Deliberately above the platform + * scale — the app has no headline-numeral token because it has no surface + * that needs one. + */ + display: '24px', }, lineHeight: { body: '24px', @@ -60,19 +84,43 @@ export const typography = { }, } +/** + * Weight scale. The platform allows exactly three steps (400/500/600) — never + * `bold`, which resolves to 700 and sits off the scale. + */ +export const fontWeight = { + normal: 400, + medium: 500, + semibold: 600, +} as const + +/** Platform `--radius` (`rounded-lg`) — the single radius the design system uses. */ +const RADIUS = '8px' + /** Spacing values */ export const spacing = { containerWidth: 600, gutter: 40, - sectionGap: 20, paragraphGap: 12, - /** Logo width in pixels */ - logoWidth: 90, } -export const baseStyles = { +/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */ +const bodyText = { + fontSize: typography.fontSize.md, + lineHeight: typography.lineHeight.body, + color: colors.textBody, + fontWeight: fontWeight.normal, fontFamily: typography.fontFamily, +} + +/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */ +const boxGeometry = { + padding: '16px 18px', + borderRadius: RADIUS, + margin: '16px 0', +} +export const baseStyles = { /** Main body wrapper with outer background */ main: { backgroundColor: colors.bgOuter, @@ -80,19 +128,13 @@ export const baseStyles = { padding: '32px 0', }, - /** Center wrapper for email content */ - wrapper: { - maxWidth: `${spacing.containerWidth}px`, - margin: '0 auto', - }, - /** Main card container — white surface, chip-radius, hairline border on the near-white canvas */ container: { maxWidth: `${spacing.containerWidth}px`, margin: '0 auto', backgroundColor: colors.bgCard, - border: `1px solid ${colors.divider}`, - borderRadius: '8px', + border: `1px solid ${colors.border}`, + borderRadius: RADIUS, overflow: 'hidden', }, @@ -109,35 +151,33 @@ export const baseStyles = { /** Standard paragraph text */ paragraph: { - fontSize: typography.fontSize.body, - lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontWeight: 400, - fontFamily: typography.fontFamily, + ...bodyText, margin: `${spacing.paragraphGap}px 0`, }, - /** Bold label text (e.g., "Platform:", "Time:") */ - label: { - fontSize: typography.fontSize.body, - lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontWeight: 'bold' as const, - fontFamily: typography.fontFamily, - margin: 0, - display: 'inline', + /** + * The opening line of an email body — flush to the top, so it sits a fixed + * distance below the logo instead of inheriting the paragraph gap. + */ + greeting: { + ...bodyText, + margin: `0 0 ${spacing.paragraphGap}px 0`, }, - /** Primary CTA button - matches the platform's primary Chip (inverse fill, rounded-lg, h-30, text-sm) */ + /** + * Primary CTA — the platform's primary Chip, transcribed for email: + * `chipPrimaryFillTokens` fill (`--text-primary`), `chipGeometryClass` + * geometry (`h-[30px]`, `rounded-lg`, `px-2`, `text-sm`) at normal weight. + */ button: { display: 'inline-block', backgroundColor: colors.brandTertiary, - color: '#ffffff', - fontWeight: 400, - fontSize: '14px', + color: colors.textInverse, + fontWeight: fontWeight.normal, + fontSize: typography.fontSize.sm, lineHeight: '30px', - padding: '0 12px', - borderRadius: '8px', + padding: '0 8px', + borderRadius: RADIUS, textDecoration: 'none', textAlign: 'center' as const, margin: '4px 0', @@ -147,25 +187,28 @@ export const baseStyles = { /** Link text style - neutral color, so it carries an underline to read as a link */ link: { color: colors.brandTertiary, - fontWeight: 400, + fontWeight: fontWeight.normal, textDecoration: 'underline', }, /** Horizontal divider */ divider: { - borderTop: `1px solid ${colors.divider}`, + borderTop: `1px solid ${colors.border}`, margin: `16px 0`, }, - /** Footer container (inside gray area below card) */ - footer: { - maxWidth: `${spacing.containerWidth}px`, - margin: '0 auto', - padding: `32px ${spacing.gutter}px`, - textAlign: 'left' as const, + /** + * Footer link — muted rather than {@link link}'s accent, since the footer sits + * outside the card and its links are secondary to the message. + */ + footerLink: { + color: colors.textMuted, + fontWeight: fontWeight.normal, + textDecoration: 'underline', + fontFamily: typography.fontFamily, }, - /** Footer text style */ + /** Footer text style — used inside the footer's own centered table cells */ footerText: { fontSize: typography.fontSize.caption, lineHeight: typography.lineHeight.caption, @@ -174,78 +217,96 @@ export const baseStyles = { margin: '0 0 10px 0', }, + /** + * 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. + */ + footnote: { + fontSize: typography.fontSize.caption, + lineHeight: typography.lineHeight.caption, + color: colors.textMuted, + fontFamily: typography.fontFamily, + margin: '0 0 10px 0', + textAlign: 'left' as const, + }, + /** Code/OTP container */ codeContainer: { margin: '12px 0', padding: '12px 16px', backgroundColor: colors.surfaceSubtle, - borderRadius: '8px', - border: `1px solid ${colors.divider}`, + borderRadius: RADIUS, + border: `1px solid ${colors.border}`, textAlign: 'center' as const, }, /** Code/OTP text */ code: { - fontSize: '24px', - fontWeight: 'bold' as const, + fontSize: typography.fontSize.display, + fontWeight: fontWeight.semibold, letterSpacing: '3px', color: colors.textPrimary, fontFamily: typography.fontFamily, margin: 0, }, - /** Code block text (for JSON/code display) */ - codeBlock: { - fontSize: typography.fontSize.caption, - lineHeight: typography.lineHeight.caption, - color: colors.textSecondary, - fontFamily: 'monospace', - whiteSpace: 'pre-wrap' as const, - wordWrap: 'break-word' as const, - margin: 0, - }, - /** Highlighted info box (e.g., "What you get with Pro") */ infoBox: { + ...boxGeometry, backgroundColor: colors.surfaceSubtle, - padding: '16px 18px', - borderRadius: '8px', - margin: '16px 0', + }, + + /** Error-state variant of {@link infoBox} */ + errorBox: { + ...boxGeometry, + backgroundColor: colors.errorBg, + border: `1px solid ${colors.errorBorder}`, }, /** Info box title */ infoBoxTitle: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, - fontWeight: 600, + fontWeight: fontWeight.semibold, color: colors.textPrimary, fontFamily: typography.fontFamily, margin: '0 0 8px 0', }, - /** Info box list content */ + /** + * Info box body copy. + * + * `margin: 0` — so multi-row content must be ONE `Text` with `
` between + * rows, never several `Text` nodes, which would stack flush with no gap. + */ infoBoxList: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: '1.6', - color: colors.textSecondary, + color: colors.textBody, fontFamily: typography.fontFamily, margin: 0, }, - /** Section borders - decorative accent line */ - sectionsBorders: { - width: '100%', - display: 'flex', - }, - - sectionBorder: { - borderBottom: `1px solid ${colors.divider}`, - width: '249px', + /** Muted caption inside an info box, above a {@link infoBoxValue} figure */ + infoBoxLabel: { + fontSize: typography.fontSize.sm, + lineHeight: typography.lineHeight.caption, + color: colors.textMuted, + fontWeight: fontWeight.normal, + fontFamily: typography.fontFamily, + margin: 0, }, - sectionCenter: { - borderBottom: `1px solid ${colors.brandTertiary}`, - width: '102px', + /** The headline figure of a stat info box (e.g. a credit balance) */ + infoBoxValue: { + fontSize: typography.fontSize.display, + lineHeight: '32px', + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + fontFamily: typography.fontFamily, + margin: '4px 0 0 0', }, /** Spacer row for vertical spacing in tables */ @@ -266,22 +327,13 @@ export const baseStyles = { lineHeight: '1px', width: `${spacing.gutter}px`, }, - - /** Info row (e.g., Platform, Device location, Time) */ - infoRow: { - fontSize: typography.fontSize.body, - lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontFamily: typography.fontFamily, - margin: '8px 0', - }, } -/** Styles for plain personal emails (no branding, no EmailLayout) */ +/** Styles for plain personal emails (no branding, no EmailLayout). */ export const plainEmailStyles = { body: { - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - backgroundColor: '#ffffff', + fontFamily: typography.systemFontFamily, + backgroundColor: colors.bgCard, margin: '0', padding: '0', }, @@ -291,9 +343,9 @@ export const plainEmailStyles = { padding: '0 24px', }, p: { - fontSize: '15px', + fontSize: typography.fontSize.base, lineHeight: '1.6', - color: '#1a1a1a', + color: colors.textPrimary, margin: '0 0 16px', }, } as const diff --git a/apps/sim/components/emails/_styles/index.ts b/apps/sim/components/emails/_styles/index.ts index 3b252363815..5b603e2d856 100644 --- a/apps/sim/components/emails/_styles/index.ts +++ b/apps/sim/components/emails/_styles/index.ts @@ -1 +1 @@ -export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base' +export { baseStyles, colors, fontWeight, plainEmailStyles, spacing, typography } from './base' diff --git a/apps/sim/components/emails/auth/existing-account-email.tsx b/apps/sim/components/emails/auth/existing-account-email.tsx index 0fc8f7e7524..8c13a578d57 100644 --- a/apps/sim/components/emails/auth/existing-account-email.tsx +++ b/apps/sim/components/emails/auth/existing-account-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -23,20 +23,18 @@ export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProp preview={`Someone tried to sign up with your ${brand.name} email`} showUnsubscribe={false} > - Hello {username}, + Hello {username}, Someone just tried to create a {brand.name} account using this email address, but an account already exists. If this was you, sign in instead — or reset your password if you've forgotten it. - - Sign In - + Sign In
- + If this wasn't you, no action is needed — no account was created or changed. diff --git a/apps/sim/components/emails/auth/onboarding-followup-email.tsx b/apps/sim/components/emails/auth/onboarding-followup-email.tsx index 042b2fd29a1..48564ba1c4e 100644 --- a/apps/sim/components/emails/auth/onboarding-followup-email.tsx +++ b/apps/sim/components/emails/auth/onboarding-followup-email.tsx @@ -1,11 +1,14 @@ import { Body, Head, Html, Preview, Text } from '@react-email/components' import { plainEmailStyles as styles } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' interface OnboardingFollowupEmailProps { userName?: string } export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) { + const brand = getBrandConfig() + return ( @@ -14,7 +17,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
{userName ? `Hey ${userName},` : 'Hey,'} - It's been a few days since you signed up. I hope you're enjoying Sim! + It's been a few days since you signed up. I hope you're enjoying {brand.name}! I'd love to know — what did you expect when you signed up vs. what did you get? @@ -27,7 +30,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
Emir
- Founder, Sim + Founder, {brand.name}
diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx index e23e938c5ab..6f4170286a9 100644 --- a/apps/sim/components/emails/auth/otp-verification-email.tsx +++ b/apps/sim/components/emails/auth/otp-verification-email.tsx @@ -37,7 +37,7 @@ export function OTPVerificationEmail({ return ( - Your verification code: + Your verification code:
{otp} @@ -45,10 +45,9 @@ export function OTPVerificationEmail({ This code will expire in 15 minutes. - {/* Divider */}
- + Do not share this code with anyone. If you didn't request this code, you can safely ignore this email. diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx index e86effe0fe8..ed0b5f27861 100644 --- a/apps/sim/components/emails/auth/reset-password-email.tsx +++ b/apps/sim/components/emails/auth/reset-password-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface ResetPasswordEmailProps { @@ -13,20 +13,17 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw return ( - Hello {username}, + Hello {username}, A password reset was requested for your {brand.name} account. Click below to set a new password. - - Reset Password - + Reset Password - {/* Divider */}
- + If you didn't request this, you can ignore this email. Link expires in 24 hours. diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx index d7f5b8643e7..c5050b7128e 100644 --- a/apps/sim/components/emails/auth/welcome-email.tsx +++ b/apps/sim/components/emails/auth/welcome-email.tsx @@ -1,6 +1,6 @@ import { Link, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -14,17 +14,13 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) { return ( - - {userName ? `Hey ${userName},` : 'Hey,'} - + {userName ? `Hey ${userName},` : 'Hey,'} Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI workflows in minutes. - - Get Started - + Get Started If you have any questions or feedback, just reply to this email. I read every message! @@ -40,10 +36,9 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) { - Emir, co-founder of {brand.name} - {/* Divider */}
- + You're on the Community plan with 1,000 credits to get started. diff --git a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx index adc3bb1379e..2a0ab9462e2 100644 --- a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx +++ b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx @@ -1,11 +1,14 @@ import { Body, Head, Html, Preview, Text } from '@react-email/components' import { plainEmailStyles as styles } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' interface AbandonedCheckoutEmailProps { userName?: string } export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) { + const brand = getBrandConfig() + return ( @@ -14,7 +17,8 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps
{userName ? `Hi ${userName},` : 'Hi,'} - I saw that you tried to upgrade your Sim plan but didn't end up completing it. + I saw that you tried to upgrade your {brand.name} plan but didn't end up completing + it. Did you run into an issue, or did you have a question? Here to help. @@ -22,7 +26,7 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps — Emir
- Founder, Sim + Founder, {brand.name}
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx index 8242587a284..55b14677dd3 100644 --- a/apps/sim/components/emails/billing/credit-purchase-email.tsx +++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' -import { baseStyles, colors } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -25,55 +25,20 @@ export function CreditPurchaseEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} - Your credit purchase of {dollarsToCredits(amount).toLocaleString()} credits{' '} - has been confirmed. + Your credit purchase of{' '} + {dollarsToCredits(amount).toLocaleString()} credits has been + confirmed.
- - Amount Added - - + Amount Added + {dollarsToCredits(amount).toLocaleString()} credits - - New Balance - - + New Balance + {dollarsToCredits(newBalance).toLocaleString()} credits
@@ -82,14 +47,11 @@ export function CreditPurchaseEmail({ Credits are applied automatically to your workflow executions.
- - View Dashboard - + View Dashboard - {/* Divider */}
- + Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription. diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx index d505e9e0726..c81232b8d7a 100644 --- a/apps/sim/components/emails/billing/credits-exhausted-email.tsx +++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx @@ -1,7 +1,7 @@ -import { Link, Section, Text } from '@react-email/components' -import { baseStyles, colors, typography } from '@/components/emails/_styles' -import { proFeatures } from '@/components/emails/billing/constants' -import { EmailLayout } from '@/components/emails/components' +import { Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -23,76 +23,20 @@ export function CreditsExhaustedEmail({ preview={`You've used all ${dollarsToCredits(limit).toLocaleString()} of your free ${brand.name} credits`} showUnsubscribe={true} > - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} - You've used all {dollarsToCredits(limit).toLocaleString()} of your - free credits on {brand.name}. Your workflows are paused until you upgrade. + You've used all {dollarsToCredits(limit).toLocaleString()}{' '} + of your free credits on {brand.name}. Your workflows are paused until you upgrade. -
- - Pro includes - - - - {proFeatures.map((feature, i) => ( - - - - - ))} - -
- {feature.label} - - {feature.desc} -
-
+ - - Upgrade to Pro - + Upgrade to Pro
- + One-time notification when free credits are exhausted. diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx index 722fa3edee7..e252e7839ae 100644 --- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx +++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Link, Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -22,26 +22,25 @@ export function EnterpriseSubscriptionEmail({ preview={`Your Enterprise Plan is now active on ${brand.name}`} showUnsubscribe={false} > - Hello {userName}, + Hello {userName}, - Your Enterprise Plan is now active. You have full access to advanced - features and increased capacity for your workflows. + Your Enterprise Plan is now active. You have full access to + advanced features and increased capacity for your workflows. - - Open {brand.name} - +
+ Next steps + + • Invite teammates to your organization +
• Start building your workflows +
+
- - Next steps: -
• Invite teammates to your organization -
• Start building your workflows -
+ Open {brand.name} - {/* Divider */}
- + Questions? Reply to this email or contact us at{' '} {brand.supportEmail} diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index cb4a1960dca..f34e15aeba0 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -1,7 +1,7 @@ -import { Link, Section, Text } from '@react-email/components' -import { baseStyles, colors, typography } from '@/components/emails/_styles' -import { proFeatures } from '@/components/emails/billing/constants' -import { EmailLayout } from '@/components/emails/components' +import { Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -26,81 +26,21 @@ export function FreeTierUpgradeEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} - You've used {dollarsToCredits(currentUsage).toLocaleString()} of your{' '} - {dollarsToCredits(limit).toLocaleString()} free credits ({percentUsed}%). - Upgrade to Pro to keep building without interruption. + You've used {dollarsToCredits(currentUsage).toLocaleString()} of + your {dollarsToCredits(limit).toLocaleString()} free credits ( + {percentUsed}%). Upgrade to Pro to keep building without interruption. - {/* Pro Features */} -
- - Pro includes - - - - {proFeatures.map((feature, i) => ( - - - - - ))} - -
- {feature.label} - - {feature.desc} -
-
+ - - Upgrade to Pro - + Upgrade to Pro - {/* Divider */}
- - One-time notification at 80% usage. - + One-time notification at 80% usage. ) } diff --git a/apps/sim/components/emails/billing/limit-threshold-email.tsx b/apps/sim/components/emails/billing/limit-threshold-email.tsx index 79f41df75e8..f8e1a3d913f 100644 --- a/apps/sim/components/emails/billing/limit-threshold-email.tsx +++ b/apps/sim/components/emails/billing/limit-threshold-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBrandConfig } from '@/ee/whitelabeling' @@ -39,9 +39,7 @@ export function LimitThresholdEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} {lead} Upgrade your plan for more {copy.noun}. @@ -54,17 +52,11 @@ export function LimitThresholdEmail({
- {/* Divider */} -
- - - Upgrade - + Upgrade - {/* Divider */}
- + {kind === 'reached' ? 'One-time notification at 100% usage.' : 'One-time notification at 80% usage.'} diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index 8f93c4fb2e6..ace471b9747 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,6 +1,6 @@ import { Link, Section, Text } from '@react-email/components' -import { baseStyles, colors } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface PaymentFailedEmailProps { @@ -26,15 +26,12 @@ export function PaymentFailedEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} @@ -46,57 +43,39 @@ export function PaymentFailedEmail({ unexpected charges. To restore access immediately, please update your payment method. -
- - Payment Details - - +
+ Payment Details + Amount due: ${amountDue.toFixed(2)} + {lastFourDigits && ( + <> +
+ Payment method: •••• {lastFourDigits} + + )} + {failureReason && ( + <> +
+ Reason: {failureReason} + + )}
- {lastFourDigits && ( - - Payment method: •••• {lastFourDigits} - - )} - {failureReason && ( - Reason: {failureReason} - )}
- - Update Payment Method - - - {/* Divider */} -
- - What happens next? +
+ What happens next + + • Your workflows and automations are currently paused +
• Update your payment method to restore service immediately +
• Stripe will automatically retry the charge once payment is updated +
+
- - • Your workflows and automations are currently paused -
• Update your payment method to restore service immediately -
• Stripe will automatically retry the charge once payment is updated -
+ Update Payment Method - {/* Divider */}
- + Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '} {brand.supportEmail} diff --git a/apps/sim/components/emails/billing/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx index 3df89a59465..073c359dff8 100644 --- a/apps/sim/components/emails/billing/plan-welcome-email.tsx +++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx @@ -1,6 +1,6 @@ import { Link, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -19,17 +19,13 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} - Welcome to {planName}! You're all set to build, test, and scale your - workflows. + Welcome to {planName}! You're all set to build, test, and scale + your workflows. - - Open {brand.name} - + Open {brand.name} Want help getting started?{' '} @@ -39,12 +35,9 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE with our team. - {/* Divider */}
- - Manage your subscription in Settings → Subscription. - + Manage your subscription in Settings → Subscription. ) } diff --git a/apps/sim/components/emails/billing/pro-features-box.tsx b/apps/sim/components/emails/billing/pro-features-box.tsx new file mode 100644 index 00000000000..58671fa82af --- /dev/null +++ b/apps/sim/components/emails/billing/pro-features-box.tsx @@ -0,0 +1,37 @@ +import { Section, Text } from '@react-email/components' +import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' +import { proFeatures } from '@/components/emails/billing/constants' + +const CELL = { ...baseStyles.infoBoxList, padding: '4px 0' } +const LABEL_CELL = { + ...CELL, + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + width: '45%', +} + +/** + * The "Pro includes" panel shared by the two free-tier upgrade prompts. + * + * The two-column table is what keeps a feature and its qualifier on one row + * across email clients, which a list cannot do reliably. + */ +export function ProFeaturesBox() { + return ( +
+ Pro includes + + + {proFeatures.map((feature) => ( + + + + + ))} + +
{feature.label}{feature.desc}
+
+ ) +} + +export default ProFeaturesBox diff --git a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx index 8ff73ab52d9..aa54b8272ff 100644 --- a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx +++ b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -33,9 +33,7 @@ export function UsageLimitReachedEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} {isOrganization @@ -53,25 +51,19 @@ export function UsageLimitReachedEmail({
- {/* Divider */} -
- {isOrganization ? 'Raise the organization usage limit in billing settings to resume.' : 'Raise your usage limit in billing settings, or upgrade your plan, to resume.'} - - - {isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'} - - + + {isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'} + - {/* Divider */}
- + Sent to the people who manage billing for this account. diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx index 4c4c6863e42..0157ea251e6 100644 --- a/apps/sim/components/emails/billing/usage-threshold-email.tsx +++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -27,9 +27,7 @@ export function UsageThresholdEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} You're approaching your monthly budget on the {planName} plan. @@ -43,23 +41,15 @@ export function UsageThresholdEmail({ - {/* Divider */} -
- To avoid interruptions, consider increasing your monthly limit. - - Review Limits - + Review Limits - {/* Divider */}
- - One-time notification at 80% usage. - + One-time notification at 80% usage. ) } diff --git a/apps/sim/components/emails/components/email-button.tsx b/apps/sim/components/emails/components/email-button.tsx new file mode 100644 index 00000000000..c3bc377bae8 --- /dev/null +++ b/apps/sim/components/emails/components/email-button.tsx @@ -0,0 +1,28 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' + +/** `Link` renders an underline by default; the pill owns its own chrome. */ +const RESET_LINK_STYLE = { textDecoration: 'none' } as const + +interface EmailButtonProps { + href: string + children: React.ReactNode +} + +/** + * The primary CTA pill — a `Link` wrapping a `Text` so the tappable area covers + * the whole pill in clients that ignore padding on an anchor. + * + * Every template repeated this three-node shape verbatim, including the + * underline reset; the styling lives in {@link baseStyles.button}, which + * transcribes the platform's primary Chip. + */ +export function EmailButton({ href, children }: EmailButtonProps) { + return ( + + {children} + + ) +} + +export default EmailButton diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index ef85acd95b3..f03a86b222e 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -1,9 +1,35 @@ import { Container, Img, Link, Section } from '@react-email/components' -import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles' +import { baseStyles, colors, spacing } from '@/components/emails/_styles' import { isHosted } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' +/** + * Social mark display size. Every `static/*-icon.png` is 40×40, so 20px is a + * clean 2x source — email clients do no responsive image selection, so the + * asset must be authored at 2x and pinned here. This is deliberately NOT the + * platform's 14px UI-icon size: these are brand marks in an image, not + * `--text-icon` glyphs, and 14px renders them illegibly. + */ +const SOCIAL_ICON_SIZE = 20 + +/** + * `display: block` removes the 2–3px gap Outlook adds under inline images; + * `border: 0` prevents the blue link border older Outlook draws around a linked + * image. + */ +const socialIconStyle = { display: 'block' as const, border: 0 } + +/** Trailing gap only, so the row starts flush with the gutter. */ +const SOCIAL_CELL_STYLE = { paddingRight: 16 } as const + +const SOCIAL_LINKS = [ + { path: 'x', label: 'X', icon: 'x-icon.png' }, + { path: 'linkedin', label: 'LinkedIn', icon: 'linkedin-icon.png' }, + { path: 'github', label: 'GitHub', icon: 'github-icon.png' }, + { path: 'slack', label: 'Slack', icon: 'slack-icon.png' }, +] as const + interface EmailFooterProps { baseUrl?: string messageId?: string @@ -30,23 +56,6 @@ export function EmailFooter({ const brand = getBrandConfig() const isWhitelabeled = brand.isWhitelabeled - const footerLinkStyle = { - color: colors.textMuted, - textDecoration: 'underline', - fontWeight: 'normal' as const, - fontFamily: typography.fontFamily, - } - - /** - * Social icons are linked images. `display: block` removes the 2–3px gap - * Outlook adds under inline images, and `border: 0` prevents the blue link - * border older Outlook versions draw around linked images. - */ - const socialIconStyle = { - display: 'block' as const, - border: 0, - } - return (
- - - X - - - - - LinkedIn - - - - - GitHub - - - - - Slack - - + {SOCIAL_LINKS.map(({ path, label, icon }) => ( + + + {label} + + + ))} @@ -165,14 +143,18 @@ export function EmailFooter({ )} - {/* Contact row */}   Questions?{' '} - + {/* + A raw anchor, not ``: react-email's Link hardcodes + target="_blank", which on a mailto: opens a blank tab beside + the compose window in most webmail clients. + */} + {brand.supportEmail} @@ -187,7 +169,6 @@ export function EmailFooter({ - {/* Message ID row (optional) */} {messageId && ( <> @@ -209,30 +190,37 @@ export function EmailFooter({ )} - {/* Links row */}   - + Privacy Policy - {' '} + {' '} •{' '} - + Terms of Service - + {showUnsubscribe && ( <> {' '} •{' '} - Unsubscribe - + )} @@ -241,7 +229,6 @@ export function EmailFooter({ - {/* Copyright row */}   diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx index 264fd177cdb..253f7512055 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -4,6 +4,16 @@ import { EmailFooter } from '@/components/emails/components/email-footer' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' +/** + * Wordmark display size — exactly 1/4 of `wordmark.png`'s intrinsic 272×164. + * The asset is a 4x source and must stay that way, since email clients do no + * responsive image selection; changing one dimension alone distorts the mark. + */ +const WORDMARK_SIZE = { height: '41', width: '68' } as const + +/** Whitelabeled logos are arbitrary aspect ratios, so only height is pinned. */ +const CUSTOM_LOGO_SIZE = { height: '34' } as const + interface EmailLayoutProps { /** Preview text shown in email client list view */ preview: string @@ -35,9 +45,16 @@ export function EmailLayout({ return ( + {/* + `fallbackFontFamily` only accepts react-email's fixed union, so it + cannot express the platform's full chain (`system-ui`, `Segoe UI`, + `Roboto`, …) — this is the closest allowed subset. The complete chain + lives on `typography.fontFamily`, which is applied inline to every + element and is what clients that strip `@font-face` actually use. + */} {preview} - {/* Main card container */} - {/* Header with logo */}
{brand.name}
- {/* Content */}
{children}
- {/* Footer in gray section */} {!hideFooter && } diff --git a/apps/sim/components/emails/components/email-strong.tsx b/apps/sim/components/emails/components/email-strong.tsx new file mode 100644 index 00000000000..5b169cbb3d9 --- /dev/null +++ b/apps/sim/components/emails/components/email-strong.tsx @@ -0,0 +1,21 @@ +import { fontWeight } from '@/components/emails/_styles' + +const STRONG_STYLE = { fontWeight: fontWeight.semibold } as const + +interface EmailStrongProps { + children: React.ReactNode +} + +/** + * Inline emphasis inside body copy. + * + * A bare `strong` element inherits the client's UA weight of 700, which is off + * the platform's three-step scale (400/500/600) and reads noticeably heavier + * than anything in the app. This pins it to semibold while keeping the element + * itself, so the emphasis stays semantic for screen readers. + */ +export function EmailStrong({ children }: EmailStrongProps) { + return {children} +} + +export default EmailStrong diff --git a/apps/sim/components/emails/components/index.ts b/apps/sim/components/emails/components/index.ts index d7c1d712af8..560699abc42 100644 --- a/apps/sim/components/emails/components/index.ts +++ b/apps/sim/components/emails/components/index.ts @@ -1,2 +1,4 @@ +export { EmailButton } from './email-button' export { EmailFooter } from './email-footer' export { EmailLayout } from './email-layout' +export { EmailStrong } from './email-strong' diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx index 9bd6a31ca5c..da9d3d57af7 100644 --- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -1,6 +1,7 @@ -import { Link, Text } from '@react-email/components' +import { Fragment } from 'react' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceInvitation { @@ -58,47 +59,44 @@ export function BatchInvitationEmail({ preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`} showUnsubscribe={false} > - Hello, + Hello, - {inviterName} has invited you to join {organizationName}{' '} - on {brand.name}. + {inviterName} has invited you to join{' '} + {organizationName} on {brand.name}. - {/* Team Role Information */} - - Team Role: {getRoleLabel(organizationRole)} - - - {organizationRole === 'admin' - ? "As a Team Admin, you'll be able to manage team members, billing, and workspace access." - : "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."} - +
+ Team role + + {getRoleLabel(organizationRole)} —{' '} + {organizationRole === 'admin' + ? 'you can manage team members, billing, and workspace access.' + : 'you have access to shared team billing and can be invited to workspaces.'} + +
- {/* Workspace Invitations */} {hasWorkspaces && ( - <> - - - Workspace Access ({workspaceInvitations.length} workspace - {workspaceInvitations.length !== 1 ? 's' : ''}): - +
+ + Workspace access ({workspaceInvitations.length} workspace + {workspaceInvitations.length !== 1 ? 's' : ''}) + + + {workspaceInvitations.map((ws, index) => ( + + {index > 0 &&
} + {ws.workspaceName} — {getPermissionLabel(ws.permission)} +
+ ))}
- {workspaceInvitations.map((ws) => ( - - • {ws.workspaceName} - {getPermissionLabel(ws.permission)} - - ))} - +
)} - - Accept Invitation - + Accept Invitation - {/* Divider */}
- + Invitation expires in 7 days. If unexpected, you can ignore this email. diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx index f3ce41dcf49..11dccce7995 100644 --- a/apps/sim/components/emails/invitations/invitation-email.tsx +++ b/apps/sim/components/emails/invitations/invitation-email.tsx @@ -1,7 +1,7 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { createLogger } from '@sim/logger' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -40,20 +40,17 @@ export function InvitationEmail({ preview={`You've been invited to join ${organizationName} on ${brand.name}`} showUnsubscribe={false} > - Hello, + Hello, - {inviterName} invited you to join {organizationName} on{' '} - {brand.name}. + {inviterName} invited you to join{' '} + {organizationName} on {brand.name}. - - Accept Invitation - + Accept Invitation - {/* Divider */}
- + Invitation expires in 48 hours. If unexpected, you can ignore this email. diff --git a/apps/sim/components/emails/invitations/workspace-added-email.tsx b/apps/sim/components/emails/invitations/workspace-added-email.tsx index 3e291f48048..4c22c650c0a 100644 --- a/apps/sim/components/emails/invitations/workspace-added-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-added-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceAddedEmailProps { @@ -22,21 +22,17 @@ export function WorkspaceAddedEmail({ return ( - Hello, + Hello, - {inviterName} added you to the {workspaceName} workspace - on {brand.name}. + {inviterName} added you to the{' '} + {workspaceName} workspace on {brand.name}. - - Open workspace - + Open workspace
- - If this was unexpected, contact a workspace admin. - + If this was unexpected, contact a workspace admin. ) } diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx index 819388edfbc..5ad0a4156cd 100644 --- a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceInvitationEmailProps { @@ -23,9 +23,9 @@ export function WorkspaceInvitationEmail({ return ( - Hello, + Hello, - {inviterName} invited you to join the{' '} + {inviterName} invited you to join the{' '} {workspaceNames.map((name, index) => ( {index > 0 && @@ -34,19 +34,17 @@ export function WorkspaceInvitationEmail({ ? ', and ' : ' and ' : ', ')} - {name} + {name} ))}{' '} {isMultiple ? 'workspaces' : 'workspace'} on {brand.name}. - - Accept Invitation - + Accept Invitation
- + Invitation expires in 7 days. If unexpected, you can ignore this email. diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx index 7ec78752a65..8c8cb60ae40 100644 --- a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx +++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { SCHEDULE_DISABLE_REASON_COPY, type ScheduleDisableReason, @@ -39,13 +39,11 @@ export function ScheduleDisabledEmail({ return ( - - {recipientName ? `Hi ${recipientName},` : 'Hi,'} - + {recipientName ? `Hi ${recipientName},` : 'Hi,'} {brand.name} turned off the schedule for {resourceLabel}. It will not run again until you - turn it back on. + fix the problem and turn it back on.
@@ -53,21 +51,11 @@ export function ScheduleDisabledEmail({ {reasonCopy}
- {/* Divider */} -
- - Fix the problem, then turn the schedule back on. - - {manageLink ? ( - - Open workflow - - ) : null} + {manageLink ? Open workflow : null} - {/* Divider */}
- + Sent to workspace admins and the person who created this schedule. diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx index 6e2b0726c64..ebf0ed68e9c 100644 --- a/apps/sim/components/emails/support/help-confirmation-email.tsx +++ b/apps/sim/components/emails/support/help-confirmation-email.tsx @@ -1,7 +1,7 @@ import { Text } from '@react-email/components' import { format } from 'date-fns' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailLayout, EmailStrong } from '@/components/emails/components' interface HelpConfirmationEmailProps { type?: 'bug' | 'feedback' | 'feature_request' | 'other' @@ -36,10 +36,10 @@ export function HelpConfirmationEmail({ preview={`Your ${typeLabel.toLowerCase()} has been received`} showUnsubscribe={false} > - Hello, + Hello, - We've received your {typeLabel.toLowerCase()} and will get back to you - shortly. + We've received your {typeLabel.toLowerCase()} and will get back + to you shortly. {attachmentCount > 0 && ( @@ -48,10 +48,9 @@ export function HelpConfirmationEmail({ )} - {/* Divider */}
- + Submitted on {format(submittedDate, 'MMMM do, yyyy')}. diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 3967555abf9..f8906be8e12 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -1,15 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDecryptSecret } = vi.hoisted(() => ({ +const { mockDecryptSecret, mockLogger } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn(), + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) +vi.mock('@sim/logger', () => ({ + createLogger: () => mockLogger, +})) + import { ANONYMOUS_SECRET_TRACE_REPLACEMENT, + createIncompleteResolvedSecretTraceRegistry, createResolvedSecretTraceRegistry, isResolvedSecretTraceProvenanceV1, RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION, @@ -1279,3 +1285,181 @@ describe('ResolvedSecretTraceRegistry', () => { expect(registry.getModelEgressSnapshot()).toEqual({ complete: false }) }) }) + +describe('incompleteness diagnostics', () => { + const scope = { userId: 'user-1', workspaceId: 'workspace-1' } + + beforeEach(() => { + mockLogger.warn.mockClear() + mockLogger.error.mockClear() + }) + + it('reports an originating incompleteness at error so the default log level cannot hide it', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.markIncomplete('projection-mismatch') + + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'projection-mismatch' }) + ) + }) + + it('reports an inherited incompleteness at warn so one fault does not read as several', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.markIncomplete('inherited-incomplete-source') + + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'inherited-incomplete-source' }) + ) + }) + + it('names the guard that tripped rather than reporting unspecified', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.recordResolved('MISSING', 'value-not-in-catalog') + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'unverified-resolved-entry' }) + ) + }) + + it('separates a tool-call scope mismatch from a merged child that was already incomplete', () => { + const scopeMismatch = new ResolvedSecretTraceRegistry([], scope) + const foreignChild = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-2', + }) + + scopeMismatch.mergeToolCallRegistry(foreignChild) + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'tool-call-scope-mismatch' }) + ) + + mockLogger.warn.mockClear() + mockLogger.error.mockClear() + + const sameScope = new ResolvedSecretTraceRegistry([], scope) + const incompleteChild = new ResolvedSecretTraceRegistry([], scope) + incompleteChild.markIncomplete('projection-mismatch') + mockLogger.error.mockClear() + + sameScope.mergeToolCallRegistry(incompleteChild) + + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'inherited-incomplete-source' }) + ) + }) + + it('attributes an already-incomplete bundle to its source rather than to the value filter', async () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + await registry.importProvenanceForValue( + { version: 1, complete: false, entries: [], scope }, + 'x', + { + trusted: true, + inputPath: ['prompt'], + } + ) + + const reasons = mockLogger.error.mock.calls + .concat(mockLogger.warn.mock.calls) + .map(([, details]) => (details as { reason?: string })?.reason) + expect(reasons).toContain('source-provenance-incomplete') + expect(reasons).not.toContain('value-provenance-filter-incomplete') + }) + + it('reports an incoming incomplete bundle at warn, since no catalog was ever on offer', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.markIncomplete('source-provenance-incomplete') + + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'source-provenance-incomplete' }) + ) + }) + + it('stays silent for a registry built incomplete by design, which sits on hot paths', () => { + createIncompleteResolvedSecretTraceRegistry(scope) + + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).not.toHaveBeenCalled() + }) + + it('keeps an unaudited caller taking the default reason out of the error stream', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.markIncomplete() + + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Resolved secret registry marked incomplete', + expect.objectContaining({ reason: 'unspecified' }) + ) + }) + + it('reports an incoming incomplete bundle exactly once, from the registry that knows the path', async () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + await registry.importProvenanceForValue( + { version: 1, complete: false, entries: [], scope }, + 'x', + { trusted: true, inputPath: ['prompt'] } + ) + + const records = mockLogger.error.mock.calls.concat(mockLogger.warn.mock.calls) + expect(records).toHaveLength(1) + expect(records[0]).toEqual([ + 'Resolved secret input path marked incomplete', + expect.objectContaining({ reason: 'source-provenance-incomplete', inputPath: 'prompt' }), + ]) + }) + + it('summarises decrypt failures once per import instead of once per entry', async () => { + mockDecryptSecret.mockRejectedValue(new Error('key rotated')) + const registry = new ResolvedSecretTraceRegistry([], scope) + + await registry.importProvenance( + { + version: 1, + complete: true, + entries: Array.from({ length: 25 }, (_, i) => ({ + name: `SECRET_${i}`, + encryptedValue: `encrypted-${i}`, + })), + scope, + }, + { trusted: true } + ) + + const decryptRecords = mockLogger.error.mock.calls.filter( + ([message]) => message === 'Provenance entries could not be decrypted' + ) + expect(decryptRecords).toHaveLength(1) + expect(decryptRecords[0][1]).toEqual( + expect.objectContaining({ failedEntryCount: 25, totalEntryCount: 25, error: 'key rotated' }) + ) + }) + + it('records no secret material alongside the reason', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.recordResolved('MISSING', 'super-secret-value') + + const logged = JSON.stringify(mockLogger.error.mock.calls) + expect(logged).not.toContain('super-secret-value') + expect(logged).not.toContain('MISSING') + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 6cda63975e7..348a4fba6f9 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { decryptSecret } from '@/lib/core/security/encryption' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' @@ -10,6 +12,69 @@ import { } from '@/executor/utils/resolved-secret-matcher' import { getResolvedSecretMatcherCapacityFailure } from '@/executor/utils/resolved-secret-matcher-capacity' +const logger = createLogger('ResolvedSecretTraceRegistry') + +/** + * Why a registry stopped being able to vouch for what it projects. + * + * Incompleteness is one-way and fails every later model projection in the run, surfacing to the + * user as a single opaque sentence. Recording which guard tripped is the only way to tell a + * genuine containment from a matcher that merely could not decide — the reasons are static + * literals and the logged path is block/field names, never a resolved value. + */ +type ResolvedSecretIncompletenessReason = + | 'untrusted-provenance' + | 'source-provenance-incomplete' + | 'entry-decrypt-failed' + | 'unverified-resolved-entry' + | 'projection-mismatch' + | 'unresolved-placeholder' + | 'provenance-capacity-exceeded' + | 'restored-checkpoint-unavailable' + | 'constructed-incomplete' + | 'inherited-incomplete-source' + | 'inherited-incomplete-input-path' + | 'tool-call-scope-mismatch' + | 'value-provenance-untrusted' + | 'value-provenance-import-failed' + | 'value-provenance-filter-incomplete' + | 'unspecified' + +/** + * Reasons that mean something went wrong, rather than that provenance was never on offer. + * + * These log at error: each is a guard tripping on a path that should have succeeded, none is + * reachable on a healthy run, and error is the only level surviving every default the logger falls + * back to — production, test, and a self-hosted chart that sets no `LOG_LEVEL`. + * + * Everything absent from this set logs at warn, which is the deliberate default. Incompleteness is + * also the *designed* state wherever there is no catalog to vouch with, and those paths are hot — a + * webhook execution builds an incomplete registry on every run before replacing it. Defaulting to + * warn keeps a by-design state, an upstream bundle that already declared itself incomplete, a fork + * inheriting a parent that reported moments earlier, or an unaudited caller taking the default + * reason from flooding the error stream. A reason added later without thought stays quiet. + */ +const ORIGINATING_FAULT_REASONS = new Set([ + 'untrusted-provenance', + 'entry-decrypt-failed', + 'unverified-resolved-entry', + 'projection-mismatch', + 'unresolved-placeholder', + 'provenance-capacity-exceeded', + 'tool-call-scope-mismatch', + 'value-provenance-untrusted', + 'value-provenance-import-failed', +]) + +/** + * Incompleteness that is a construction choice rather than an event: `createIncomplete…` states + * outright that no trusted catalog was available. It carries nothing a reader could act on and sits + * on hot paths, so it is not reported at all. + */ +const BY_DESIGN_INCOMPLETENESS_REASONS = new Set([ + 'constructed-incomplete', +]) + export const ANONYMOUS_SECRET_TRACE_REPLACEMENT = OPAQUE_RESOLVED_SECRET_REPLACEMENT export const RESOLVED_SECRET_TRACE_CHECKPOINT_VERSION = 1 @@ -544,15 +609,23 @@ export class ResolvedSecretTraceRegistry { } private readonly scope?: ResolvedSecretTraceScopeV1 private readonly completeProvenanceEnvelopeBytes: number + /** + * A staged registry filters one value and is then discarded. Its caller re-reports whatever + * fault it hits against the real input path, so its own summary lines would restate that with + * strictly less context. Entry-level detail still logs — the caller cannot reconstruct it. + */ + private readonly staged: boolean constructor( catalogEntries: Iterable = [], - scope?: ResolvedSecretTraceScopeV1 + scope?: ResolvedSecretTraceScopeV1, + options: { staged?: boolean } = {} ) { + this.staged = options.staged === true this.scope = scope ? cloneProvenanceScope(scope) : undefined this.completeProvenanceEnvelopeBytes = serializedProvenanceEnvelopeByteSize(true, this.scope) if (this.completeProvenanceEnvelopeBytes > MAX_SERIALIZED_PROVENANCE_BYTES) { - this.markIncomplete() + this.markIncomplete('provenance-capacity-exceeded') } let catalogEntriesSeen = 0 for (const entry of catalogEntries) { @@ -580,7 +653,7 @@ export class ResolvedSecretTraceRegistry { } this.copyResolvedInputPathsTo(fork) this.copyIncompleteInputPathsTo(fork) - if (!this.complete) fork.markIncomplete() + if (!this.complete) fork.markIncomplete('inherited-incomplete-source') return fork } @@ -591,12 +664,12 @@ export class ResolvedSecretTraceRegistry { ): ResolvedSecretTraceRegistry { const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) if (!this.complete) { - fork.markIncomplete() + fork.markIncomplete('inherited-incomplete-source') return fork } if (this.hasIncompleteInputPathOverlapping(paths)) { - fork.markIncomplete() + fork.markIncomplete('inherited-incomplete-input-path') return fork } @@ -620,14 +693,19 @@ export class ResolvedSecretTraceRegistry { fork.addActiveEntry({ ...entry }, { propagated: true }) } } - if (this.isPermanentlyIncomplete()) fork.markIncomplete() + if (this.isPermanentlyIncomplete()) fork.markIncomplete('inherited-incomplete-source') return fork } /** Merges one settled tool-call registry into the turn-scoped registry. */ mergeToolCallRegistry(child: ResolvedSecretTraceRegistry): void { - if (!scopesMatch(this.scope, child.scope) || !child.isComplete()) { - this.markIncomplete() + if (!scopesMatch(this.scope, child.scope)) { + this.markIncomplete('tool-call-scope-mismatch') + return + } + + if (!child.isComplete()) { + this.markIncomplete('inherited-incomplete-source') return } @@ -649,7 +727,7 @@ export class ResolvedSecretTraceRegistry { if (resolvedValue.length === 0) return false const entry = this.getVerifiedResolvedEntry(name, resolvedValue) if (!entry) { - this.markIncomplete() + this.markIncomplete('unverified-resolved-entry') return false } @@ -669,7 +747,7 @@ export class ResolvedSecretTraceRegistry { const entry = this.getVerifiedResolvedEntry(name, resolvedValue) if (!entry) { - this.markInputPathIncomplete(path) + this.markInputPathIncomplete(path, 'unverified-resolved-entry') return false } @@ -790,7 +868,7 @@ export class ResolvedSecretTraceRegistry { typeof projectedValue === 'string' && state.projectedValue !== projectedValue ) { - this.markInputPathIncomplete(path) + this.markInputPathIncomplete(path, 'projection-mismatch') return } for (const entryKey of entryKeys) state.entryKeys.add(entryKey) @@ -846,7 +924,7 @@ export class ResolvedSecretTraceRegistry { if (current.raw !== null && typeof current.raw === 'object') { const standaloneName = canonicalPlaceholderName(current.projected as string) if (!standaloneName || !entryKeysByName.has(standaloneName)) { - this.markInputPathIncomplete(current.path) + this.markInputPathIncomplete(current.path, 'unresolved-placeholder') return } recordProjectedMarkerAcrossRawLeaves( @@ -994,16 +1072,18 @@ export class ResolvedSecretTraceRegistry { options: ImportResolvedSecretTraceProvenanceOptions ): Promise { if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) { - this.markIncomplete() + this.markIncomplete('untrusted-provenance') return false } if (!provenance.complete) { - this.markIncomplete() + this.markIncomplete('source-provenance-incomplete') } const sameScope = scopesMatch(provenance.scope, this.scope) let importedAll = true + let decryptFailures = 0 + let firstDecryptError: string | undefined for (const entry of provenance.entries) { try { const { decrypted } = await decryptSecret(entry.encryptedValue) @@ -1016,12 +1096,27 @@ export class ResolvedSecretTraceRegistry { }, { propagated: true } ) - } catch { + } catch (error) { importedAll = false - this.markIncomplete() + decryptFailures += 1 + firstDecryptError ??= getErrorMessage(error, 'Unknown error') + this.markIncomplete('entry-decrypt-failed') } } + /** + * Summarised rather than logged per entry: one rotated or corrupt key fails every entry in the + * bundle, and a bundle may carry up to MAX_PROVENANCE_ENTRIES of them. + */ + if (decryptFailures > 0) { + logger.error('Provenance entries could not be decrypted', { + error: firstDecryptError, + failedEntryCount: decryptFailures, + totalEntryCount: provenance.entries.length, + scopeWorkspaceId: this.scope?.workspaceId, + }) + } + return importedAll } @@ -1058,19 +1153,22 @@ export class ResolvedSecretTraceRegistry { options: { trusted: boolean; inputPath?: ResolvedSecretInputPath } ): Promise { if (!options.trusted || !isResolvedSecretTraceProvenanceV1(provenance)) { - this.markInputPathIncomplete(options.inputPath) + this.markInputPathIncomplete(options.inputPath, 'value-provenance-untrusted') return { success: false, matched: false } } - const sourceRegistry = new ResolvedSecretTraceRegistry([], provenance.scope) + const sourceRegistry = new ResolvedSecretTraceRegistry([], provenance.scope, { staged: true }) const sourceImported = await sourceRegistry.importProvenance(provenance, { trusted: true }) const filteredProvenance = sourceRegistry.exportProvenanceForValue(value) if (!sourceImported) { - this.markInputPathIncomplete(options.inputPath) + this.markInputPathIncomplete(options.inputPath, 'value-provenance-import-failed') return { success: false, matched: false } } if (!filteredProvenance.complete) { - this.markInputPathIncomplete(options.inputPath) + this.markInputPathIncomplete( + options.inputPath, + provenance.complete ? 'value-provenance-filter-incomplete' : 'source-provenance-incomplete' + ) return { success: true, matched: false } } const filteredImported = await this.importProvenance(filteredProvenance, { trusted: true }) @@ -1214,10 +1312,20 @@ export class ResolvedSecretTraceRegistry { return !this.complete || this.incompleteInputPaths.size > 0 } - markIncomplete(): void { + markIncomplete(reason: ResolvedSecretIncompletenessReason = 'unspecified'): void { if (!this.complete) return this.complete = false this.modelEgressRevision += 1 + if (this.staged || BY_DESIGN_INCOMPLETENESS_REASONS.has(reason)) return + const details = { + reason, + scopeWorkspaceId: this.scope?.workspaceId, + activeEntryCount: this.activeEntries.size, + incompleteInputPathCount: this.incompleteInputPaths.size, + } + const message = 'Resolved secret registry marked incomplete' + if (ORIGINATING_FAULT_REASONS.has(reason)) logger.error(message, details) + else logger.warn(message, details) } /** @@ -1388,7 +1496,11 @@ export class ResolvedSecretTraceRegistry { matcher = createResolvedSecretMatcher( [...candidatesByScanLiteral.keys()].map((plaintext) => ({ plaintext, replacement: '' })) ) - } catch { + } catch (error) { + logger.error('Provenance filter matcher could not be built', { + error: getErrorMessage(error, 'Unknown error'), + candidateCount: candidatesByScanLiteral.size, + }) return { complete: false } } @@ -1623,15 +1735,28 @@ export class ResolvedSecretTraceRegistry { ) } - private markInputPathIncomplete(path: ResolvedSecretInputPath | undefined): void { + private markInputPathIncomplete( + path: ResolvedSecretInputPath | undefined, + reason: ResolvedSecretIncompletenessReason = 'unspecified' + ): void { if (!path || path.length === 0) { - this.markIncomplete() + this.markIncomplete(reason) return } const key = inputPathKey(path) if (this.incompleteInputPaths.has(key)) return this.incompleteInputPaths.set(key, [...path]) this.modelEgressRevision += 1 + if (this.staged || BY_DESIGN_INCOMPLETENESS_REASONS.has(reason)) return + const details = { + reason, + inputPath: path.join('.'), + scopeWorkspaceId: this.scope?.workspaceId, + activeEntryCount: this.activeEntries.size, + } + const message = 'Resolved secret input path marked incomplete' + if (ORIGINATING_FAULT_REASONS.has(reason)) logger.error(message, details) + else logger.warn(message, details) } private copyIncompleteInputPathsTo( @@ -1672,7 +1797,7 @@ export class ResolvedSecretTraceRegistry { entryBytes > MAX_SERIALIZED_PROVENANCE_BYTES ) { - this.markIncomplete() + this.markIncomplete('provenance-capacity-exceeded') return } this.activeEntries.set(key, entry) @@ -1733,7 +1858,7 @@ export async function createResolvedSecretTraceRegistry( options.restoreTrusted === true && options.restoredCheckpointVersion !== undefined ) { - registry.markIncomplete() + registry.markIncomplete('restored-checkpoint-unavailable') } return registry @@ -1744,6 +1869,6 @@ export function createIncompleteResolvedSecretTraceRegistry( scope?: ResolvedSecretTraceScopeV1 ): ResolvedSecretTraceRegistry { const registry = new ResolvedSecretTraceRegistry([], scope) - registry.markIncomplete() + registry.markIncomplete('constructed-incomplete') return registry } diff --git a/apps/sim/lib/execution/payloads/store.ts b/apps/sim/lib/execution/payloads/store.ts index d662447aaa8..90762f473ba 100644 --- a/apps/sim/lib/execution/payloads/store.ts +++ b/apps/sim/lib/execution/payloads/store.ts @@ -16,7 +16,7 @@ import { isValidLargeValueKey, readLargeValueRefFromStorage, } from '@/lib/execution/payloads/materialization.server' -import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' +import { generateLargeValuePayloadKey } from '@/lib/uploads/contexts/execution/utils' const logger = createLogger('LargeExecutionPayloadStore') @@ -75,10 +75,7 @@ async function persistValue( return undefined } - const key = generateExecutionFileKey( - { workspaceId, workflowId, executionId }, - `large-value-${id}.json` - ) + const key = generateLargeValuePayloadKey({ workspaceId, workflowId, executionId }, id) try { const { StorageService } = await import('@/lib/uploads') diff --git a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.test.ts b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.test.ts index 496358dfdd3..4f0c5d007c5 100644 --- a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.test.ts @@ -23,7 +23,13 @@ vi.mock('@/lib/uploads/providers/s3/client', () => ({ import { uploadExecutionFile } from '@/lib/uploads/contexts/execution/execution-file-manager' -describe('uploadExecutionFile replacement compatibility', () => { +const context = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', +} + +describe('uploadExecutionFile key allocation', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() @@ -35,57 +41,27 @@ describe('uploadExecutionFile replacement compatibility', () => { type: contentType, })) mockGetPresignedUrlWithConfig.mockResolvedValue('https://example.com/download') + dbChainMockFns.limit.mockResolvedValue([]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'file-1' }]) }) - it('allows changed bytes and content type at the same execution-scoped key', async () => { - const context = { - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - } - const key = 'execution/workspace-1/workflow-1/execution-1/result.txt' - const existingMetadata = { - id: 'file-1', - key, - userId: 'user-1', - workspaceId: 'workspace-1', - folderId: null, - context: 'execution', - originalName: key, - displayName: key, - contentType: 'text/plain', - size: 3, - deletedAt: null, - } - - dbChainMockFns.limit - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([existingMetadata]) - dbChainMockFns.returning.mockResolvedValueOnce([existingMetadata]) - + it('gives same-named files in one execution distinct keys', async () => { const first = await uploadExecutionFile( context, - Buffer.from('old'), - 'result.txt', - 'text/plain', + Buffer.alloc(13575), + 'image.png', + 'image/png', 'user-1' ) const second = await uploadExecutionFile( context, - Buffer.from('{"new":true}'), - 'result.txt', - 'application/json', + Buffer.alloc(37226), + 'image.png', + 'image/png', 'user-1' ) - expect(first.key).toBe(key) - expect(second).toMatchObject({ - key, - size: 12, - type: 'application/json', - }) - expect(mockUploadToS3).toHaveBeenCalledTimes(2) - expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) + expect(first.key).not.toBe(second.key) + expect(dbChainMockFns.insert).toHaveBeenCalledTimes(2) }) }) diff --git a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts index 304da53a9c4..5e3bdf77141 100644 --- a/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/execution/execution-file-manager.ts @@ -3,7 +3,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import type { ExecutionContext } from '@/lib/uploads/contexts/execution/utils' -import { generateExecutionFileKey, generateFileId } from '@/lib/uploads/contexts/execution/utils' +import { + generateFileId, + generateUniqueExecutionFileKey, +} from '@/lib/uploads/contexts/execution/utils' import type { UserFile } from '@/executor/types' const logger = createLogger('ExecutionFileStorage') @@ -78,7 +81,7 @@ export async function uploadExecutionFile( bufferSize: fileBuffer.length, }) - const storageKey = generateExecutionFileKey(context, fileName) + const storageKey = generateUniqueExecutionFileKey(context, fileName) const fileId = generateFileId() logger.info(`Generated storage key: "${storageKey}" for file: ${fileName}`) diff --git a/apps/sim/lib/uploads/contexts/execution/utils.test.ts b/apps/sim/lib/uploads/contexts/execution/utils.test.ts index d7d06b00784..da705e3ef34 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.test.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.test.ts @@ -3,8 +3,8 @@ */ import { describe, expect, it } from 'vitest' import { - generateExecutionAttachmentKey, - generateExecutionFileKey, + generateLargeValuePayloadKey, + generateUniqueExecutionFileKey, } from '@/lib/uploads/contexts/execution/utils' const context = { @@ -14,25 +14,20 @@ const context = { } describe('execution storage keys', () => { - it('retains deterministic keys for internal execution artifacts', () => { - expect(generateExecutionFileKey(context, 'result.json')).toBe( - 'execution/workspace-1/workflow-1/execution-1/result.json' - ) - expect(generateExecutionFileKey(context, 'result.json')).toBe( - 'execution/workspace-1/workflow-1/execution-1/result.json' - ) + it('retains deterministic keys for large-value payloads', () => { + const key = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abc123.json' + + expect(generateLargeValuePayloadKey(context, 'lv_abc123')).toBe(key) + expect(generateLargeValuePayloadKey(context, 'lv_abc123')).toBe(key) }) - it('allocates unique create-only keys for duplicate browser attachment names', () => { - const first = generateExecutionAttachmentKey(context, 'report final.pdf') - const second = generateExecutionAttachmentKey(context, 'report final.pdf') + it('allocates unique keys for duplicate file names, keeping the name as the final segment', () => { + const first = generateUniqueExecutionFileKey(context, 'report final.pdf') + const second = generateUniqueExecutionFileKey(context, 'report final.pdf') + const shape = /^execution\/workspace-1\/workflow-1\/execution-1\/[0-9a-f-]+\/report-final\.pdf$/ - expect(first).toMatch( - /^execution\/workspace-1\/workflow-1\/execution-1\/[0-9a-f-]+-report-final\.pdf$/ - ) - expect(second).toMatch( - /^execution\/workspace-1\/workflow-1\/execution-1\/[0-9a-f-]+-report-final\.pdf$/ - ) + expect(first).toMatch(shape) + expect(second).toMatch(shape) expect(first).not.toBe(second) }) }) diff --git a/apps/sim/lib/uploads/contexts/execution/utils.ts b/apps/sim/lib/uploads/contexts/execution/utils.ts index 11b4f04b925..b426d0515b3 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.ts @@ -13,28 +13,42 @@ export interface ExecutionContext { } /** - * Generate execution-scoped storage key with explicit prefix - * Format: execution/workspace_id/workflow_id/execution_id/filename + * Generate the deterministic storage key for a large-value execution payload. + * Format: execution/workspace_id/workflow_id/execution_id/large-value-.json + * + * Takes the payload id rather than a file name so no user-supplied name can + * reach a key without a uniquifier — that is what silently overwrote same-named + * files before {@link generateUniqueExecutionFileKey} existed. Determinism is + * load-bearing here: the cleanup job matches these keys by LIKE pattern and + * re-storing the same payload must be idempotent. */ -export function generateExecutionFileKey(context: ExecutionContext, fileName: string): string { +export function generateLargeValuePayloadKey(context: ExecutionContext, id: string): string { const { workspaceId, workflowId, executionId } = context - const safeFileName = sanitizeFileName(fileName) + const safeFileName = sanitizeFileName(`large-value-${id}.json`) return `execution/${workspaceId}/${workflowId}/${executionId}/${safeFileName}` } /** - * Generates a unique execution-scoped key for browser attachments. Browser - * uploads are create-only, and a single execution may contain multiple files - * with the same display name. Internal execution artifacts intentionally keep - * using {@link generateExecutionFileKey}'s deterministic replacement semantics. + * Generate a collision-free execution-scoped storage key. + * Format: execution/workspace_id/workflow_id/execution_id/unique_id/filename + * + * One execution routinely carries several files sharing a display name (two + * `image.png` screenshots in one Slack message, repeated tool outputs in a + * loop), which the deterministic key would overwrite. The unique id is its own + * path segment rather than a filename prefix so the last segment stays the + * original name — presigned URLs carry no content-disposition, so that segment + * is what a consumer sees. + * + * Large-value payloads, whose ids are already unique, keep using + * {@link generateLargeValuePayloadKey}. */ -export function generateExecutionAttachmentKey( +export function generateUniqueExecutionFileKey( context: ExecutionContext, fileName: string ): string { const { workspaceId, workflowId, executionId } = context const safeFileName = sanitizeFileName(fileName) - return `execution/${workspaceId}/${workflowId}/${executionId}/${generateId()}-${safeFileName}` + return `execution/${workspaceId}/${workflowId}/${executionId}/${generateId()}/${safeFileName}` } /** @@ -45,8 +59,7 @@ export function generateFileId(): string { } /** - * Check if a key matches execution file pattern - * Execution files have keys in format: execution/workspaceId/workflowId/executionId/filename + * Execution keys: execution/workspaceId/workflowId/executionId/[uniqueId/]filename */ function matchesExecutionFilePattern(key: string): boolean { if (!key || key.startsWith('/api/') || key.startsWith('http')) { @@ -65,7 +78,6 @@ function matchesExecutionFilePattern(key: string): boolean { /** * Check if a file is from execution storage based on its key pattern - * Execution files have keys in format: execution/workspaceId/workflowId/executionId/filename */ export function isExecutionFile(file: UserFile): boolean { if (!file.key) {