From cdeb83d167b37a192734c6e010a0ee28a5799fc4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 17:02:38 -0700 Subject: [PATCH 1/4] fix(emails): align the schedule-disabled email with the standard template rhythm (#6477) --- .../emails/notifications/schedule-disabled-email.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx index 7ec78752a65..e221cce3200 100644 --- a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx +++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx @@ -45,7 +45,7 @@ export function ScheduleDisabledEmail({ {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,11 +53,6 @@ export function ScheduleDisabledEmail({ {reasonCopy}
- {/* Divider */} -
- - Fix the problem, then turn the schedule back on. - {manageLink ? ( Open workflow From 04fd63dad29e572d7501e3bf1a2eb2b75b321530 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 17:51:50 -0700 Subject: [PATCH 2/4] fix(execution): give each execution file a unique storage key (#6480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Execution file keys were built as execution/{workspaceId}/{workflowId}/{executionId}/{fileName}, so two files sharing a display name within one execution resolved to the same key and URL. The second upload overwrote the first in object storage and updated its workspace_files row instead of inserting, silently losing a file. Any trigger that ingests multiple attachments hits this — repeated screenshot names, mail clients that reuse inline-image names, or a loop emitting the same output name. generateUniqueExecutionFileKey now allocates a unique directory segment (.../{executionId}/{uuid}/{fileName}) and uploadExecutionFile uses it, so every execution file gets its own key. The uniquifier is its own path segment rather than a filename prefix because presigned URLs carry no content-disposition: the key's final segment is the name a consumer sees, and a prefix would rename every download. The deterministic generator is renamed to generateLargeValuePayloadKey and takes the payload id instead of a free-form file name, so no user-supplied name can reach a key without a uniquifier. Its output is unchanged — determinism is load-bearing there, since the cleanup job matches those keys by LIKE pattern and the trace store recovers workflowId by segment position. Every reader tolerates the extra segment: key parsers use parts.length >= 5 with fixed indices, storage providers write a preserved key verbatim, and local-disk storage already creates the dirname recursively. --- .../sim/app/api/files/multipart/route.test.ts | 10 ++-- apps/sim/app/api/files/multipart/route.ts | 4 +- .../sim/app/api/files/presigned/route.test.ts | 8 +-- apps/sim/app/api/files/presigned/route.ts | 4 +- apps/sim/lib/execution/payloads/store.ts | 7 +-- .../execution/execution-file-manager.test.ts | 60 ++++++------------- .../execution/execution-file-manager.ts | 7 ++- .../uploads/contexts/execution/utils.test.ts | 31 ++++------ .../lib/uploads/contexts/execution/utils.ts | 38 ++++++++---- 9 files changed, 76 insertions(+), 93 deletions(-) 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/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) { From 0e08a4120917fdfb71381f24b82e1462fe09bcfe Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 18:09:13 -0700 Subject: [PATCH 3/4] improvement(emails): align the email design tokens with the platform design system (#6479) * improvement(emails): align the email design tokens with the platform design system * improvement(emails): preview every template in a single gallery page * improvement(emails): align font stack, logo and social-icon sizing with the platform * improvement(emails): tokenize the CTA and footnote, and enforce the platform mirror with a test * fix(emails): restore row spacing in the payment-failed details box * fix(emails): restore row spacing in the batch-invitation workspace list --- apps/sim/app/api/emails/preview/route.ts | 144 ++++++++-- .../emails/_styles/base.tokens.test.ts | 129 +++++++++ apps/sim/components/emails/_styles/base.ts | 272 +++++++++++------- apps/sim/components/emails/_styles/index.ts | 2 +- .../emails/auth/existing-account-email.tsx | 12 +- .../emails/auth/onboarding-followup-email.tsx | 7 +- .../emails/auth/otp-verification-email.tsx | 5 +- .../emails/auth/reset-password-email.tsx | 13 +- .../components/emails/auth/welcome-email.tsx | 13 +- .../billing/abandoned-checkout-email.tsx | 8 +- .../emails/billing/credit-purchase-email.tsx | 64 +---- .../billing/credits-exhausted-email.tsx | 76 +---- .../billing/enterprise-subscription-email.tsx | 29 +- .../billing/free-tier-upgrade-email.tsx | 82 +----- .../emails/billing/limit-threshold-email.tsx | 18 +- .../emails/billing/payment-failed-email.tsx | 79 ++--- .../emails/billing/plan-welcome-email.tsx | 19 +- .../emails/billing/pro-features-box.tsx | 37 +++ .../billing/usage-limit-reached-email.tsx | 22 +- .../emails/billing/usage-threshold-email.tsx | 20 +- .../emails/components/email-button.tsx | 28 ++ .../emails/components/email-footer.tsx | 135 ++++----- .../emails/components/email-layout.tsx | 25 +- .../emails/components/email-strong.tsx | 21 ++ .../sim/components/emails/components/index.ts | 2 + .../invitations/batch-invitation-email.tsx | 62 ++-- .../emails/invitations/invitation-email.tsx | 17 +- .../invitations/workspace-added-email.tsx | 18 +- .../workspace-invitation-email.tsx | 16 +- .../notifications/schedule-disabled-email.tsx | 17 +- .../support/help-confirmation-email.tsx | 11 +- 31 files changed, 764 insertions(+), 639 deletions(-) create mode 100644 apps/sim/components/emails/_styles/base.tokens.test.ts create mode 100644 apps/sim/components/emails/billing/pro-features-box.tsx create mode 100644 apps/sim/components/emails/components/email-button.tsx create mode 100644 apps/sim/components/emails/components/email-strong.tsx 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/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 e221cce3200..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,9 +39,7 @@ 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 @@ -53,16 +51,11 @@ export function ScheduleDisabledEmail({ {reasonCopy}
- {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')}. From a5544303efcf726d93d41e43dd30987e3f026c67 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 9 Aug 2026 18:28:31 -0700 Subject: [PATCH 4/4] fix(provenance): record why a resolved-secret registry became incomplete (#6478) Incompleteness is one-way: once any guard trips, every later model projection in the run fails and the user is left with a single opaque sentence. Every guard could set it and none recorded which, in a file that imported no logger at all, so the cause could not be recovered after the fact. Name each guard with a static reason literal. Originating causes log at error because they permanently fail the run and error is the only level that survives every default the logger falls back to; reasons that merely carry an upstream fault forward log at warn so one fault does not read as several. The decrypt catch no longer discards its cause. No behaviour change. Reasons are static literals and the logged input path is block/field names; no resolved value is recorded. --- .../resolved-secret-trace-registry.test.ts | 186 +++++++++++++++++- .../utils/resolved-secret-trace-registry.ts | 179 ++++++++++++++--- 2 files changed, 337 insertions(+), 28 deletions(-) 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 }