Skip to content

Commit cb28d14

Browse files
authored
v0.7.65: email template alignment, logs enrichment, execution files unique keys
2 parents e816933 + a554430 commit cb28d14

42 files changed

Lines changed: 1178 additions & 766 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/emails/preview/route.ts

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { NextRequest } from 'next/server'
22
import { NextResponse } from 'next/server'
33
import {
4+
renderAbandonedCheckoutEmail,
45
renderBatchInvitationEmail,
56
renderCreditPurchaseEmail,
7+
renderCreditsExhaustedEmail,
68
renderEnterpriseSubscriptionEmail,
9+
renderExistingAccountEmail,
710
renderFreeTierUpgradeEmail,
811
renderHelpConfirmationEmail,
912
renderInvitationEmail,
13+
renderLimitThresholdEmail,
14+
renderOnboardingFollowupEmail,
1015
renderOTPEmail,
1116
renderPasswordResetEmail,
1217
renderPaymentFailedEmail,
@@ -15,20 +20,27 @@ import {
1520
renderUsageLimitReachedEmail,
1621
renderUsageThresholdEmail,
1722
renderWelcomeEmail,
23+
renderWorkspaceAddedEmail,
1824
renderWorkspaceInvitationEmail,
1925
} from '@/components/emails'
26+
import { colors, typography } from '@/components/emails/_styles'
2027
import { emailPreviewQuerySchema } from '@/lib/api/contracts/common'
2128
import { validationErrorResponse } from '@/lib/api/server'
2229
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2330

2431
const emailTemplates = {
2532
// Auth emails
2633
otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
34+
'otp-sign-in': () => renderOTPEmail('123456', 'user@example.com', 'sign-in'),
2735
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
36+
'existing-account': () => renderExistingAccountEmail('John'),
2837
welcome: () => renderWelcomeEmail('John'),
38+
'onboarding-followup': () => renderOnboardingFollowupEmail('John'),
2939

3040
// Invitation emails
3141
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
42+
'workspace-added': () =>
43+
renderWorkspaceAddedEmail('Jane Doe', 'Engineering', 'https://sim.ai/workspace/ws_123'),
3244
'batch-invitation': () =>
3345
renderBatchInvitationEmail(
3446
'Jane Doe',
@@ -87,6 +99,43 @@ const emailTemplates = {
8799
amount: 50,
88100
newBalance: 75,
89101
}),
102+
'credits-exhausted': () =>
103+
renderCreditsExhaustedEmail({
104+
userName: 'John',
105+
limit: 10,
106+
upgradeLink: 'https://sim.ai/settings/billing',
107+
}),
108+
'abandoned-checkout': () => renderAbandonedCheckoutEmail('John'),
109+
'limit-threshold-storage-warning': () =>
110+
renderLimitThresholdEmail({
111+
kind: 'warning',
112+
reason: 'storage',
113+
userName: 'John',
114+
usageLabel: '4.2 GB',
115+
limitLabel: '5 GB',
116+
percentUsed: 84,
117+
upgradeLink: 'https://sim.ai/settings/billing',
118+
}),
119+
'limit-threshold-tables-reached': () =>
120+
renderLimitThresholdEmail({
121+
kind: 'reached',
122+
reason: 'tables',
123+
userName: 'John',
124+
usageLabel: '50,000 rows',
125+
limitLabel: '50,000 rows',
126+
percentUsed: 100,
127+
upgradeLink: 'https://sim.ai/settings/billing',
128+
}),
129+
'limit-threshold-seats-reached': () =>
130+
renderLimitThresholdEmail({
131+
kind: 'reached',
132+
reason: 'seats',
133+
userName: 'John',
134+
usageLabel: '10 seats',
135+
limitLabel: '10 seats',
136+
percentUsed: 100,
137+
upgradeLink: 'https://sim.ai/settings/billing',
138+
}),
90139
'payment-failed': () =>
91140
renderPaymentFailedEmail({
92141
userName: 'John',
@@ -138,6 +187,40 @@ function isEmailTemplate(template: string): template is EmailTemplate {
138187
return template in emailTemplates
139188
}
140189

190+
const CATEGORIZED = {
191+
Auth: ['otp', 'otp-sign-in', 'reset-password', 'existing-account', 'welcome'],
192+
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation', 'workspace-added'],
193+
Support: ['help-confirmation'],
194+
Billing: [
195+
'usage-threshold',
196+
'usage-limit-reached',
197+
'usage-limit-reached-org',
198+
'free-tier-upgrade',
199+
'credits-exhausted',
200+
'limit-threshold-storage-warning',
201+
'limit-threshold-tables-reached',
202+
'limit-threshold-seats-reached',
203+
'payment-failed',
204+
'credit-purchase',
205+
'plan-welcome-pro',
206+
'plan-welcome-team',
207+
'enterprise-subscription',
208+
],
209+
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
210+
'Plain (unbranded)': ['onboarding-followup', 'abandoned-checkout'],
211+
} satisfies Record<string, EmailTemplate[]>
212+
213+
/**
214+
* Category map for the gallery, with any template missing from {@link CATEGORIZED}
215+
* appended rather than dropped — so a newly registered template always shows up
216+
* even if nobody remembers to file it.
217+
*/
218+
const PREVIEW_CATEGORIES: Record<string, EmailTemplate[]> = (() => {
219+
const filed = new Set<string>(Object.values(CATEGORIZED).flat())
220+
const unfiled = (Object.keys(emailTemplates) as EmailTemplate[]).filter((t) => !filed.has(t))
221+
return unfiled.length > 0 ? { ...CATEGORIZED, Uncategorized: unfiled } : CATEGORIZED
222+
})()
223+
141224
export const GET = withRouteHandler(async (request: NextRequest) => {
142225
const { searchParams } = new URL(request.url)
143226
const queryValidation = emailPreviewQuerySchema.safeParse(
@@ -147,48 +230,53 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
147230
const { template } = queryValidation.data
148231

149232
if (!template) {
150-
const categories = {
151-
Auth: ['otp', 'reset-password', 'welcome'],
152-
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
153-
Support: ['help-confirmation'],
154-
Billing: [
155-
'usage-threshold',
156-
'enterprise-subscription',
157-
'free-tier-upgrade',
158-
'plan-welcome-pro',
159-
'plan-welcome-team',
160-
'credit-purchase',
161-
'payment-failed',
162-
'usage-limit-reached',
163-
'usage-limit-reached-org',
164-
],
165-
Notifications: ['schedule-disabled', 'schedule-disabled-auth'],
166-
}
167-
168-
const categoryHtml = Object.entries(categories)
233+
const categoryHtml = Object.entries(PREVIEW_CATEGORIES)
169234
.map(
170235
([category, templates]) => `
171-
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
172-
<ul style="list-style: none; padding: 0; margin: 0;">
173-
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
174-
</ul>
175-
`
236+
<section>
237+
<h2>${category}</h2>
238+
<div class="grid">
239+
${templates
240+
.map(
241+
(t) => `
242+
<figure>
243+
<figcaption><span>${t}</span><a href="?template=${t}" target="_blank" rel="noreferrer">open ↗</a></figcaption>
244+
<iframe src="?template=${t}" title="${t}" loading="lazy"></iframe>
245+
</figure>`
246+
)
247+
.join('')}
248+
</div>
249+
</section>`
176250
)
177251
.join('')
178252

179253
return new NextResponse(
180254
`<!DOCTYPE html>
181255
<html>
182256
<head>
183-
<title>Email Previews</title>
257+
<meta charset="utf-8" />
258+
<meta name="viewport" content="width=device-width, initial-scale=1" />
259+
<title>Email Templates</title>
184260
<style>
185-
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
186-
h1 { color: #333; margin-bottom: 32px; }
187-
a:hover { text-decoration: underline; }
261+
:root { color-scheme: light; }
262+
body { font-family: ${typography.systemFontFamily}; margin: 0; padding: 40px 24px 80px; background: ${colors.bgCard}; color: ${colors.textPrimary}; }
263+
h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
264+
.count { color: ${colors.textMuted}; font-size: 14px; margin: 0 0 40px; }
265+
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: ${colors.textMuted}; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid ${colors.border}; }
266+
section { max-width: 1400px; margin: 0 auto; }
267+
section > h2:first-child { margin-top: 0; }
268+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
269+
figure { margin: 0 0 32px; }
270+
figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
271+
figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: ${colors.textBody}; }
272+
figcaption a { color: ${colors.textMuted}; text-decoration: none; font-size: 12px; }
273+
figcaption a:hover { color: ${colors.textPrimary}; }
274+
iframe { width: 100%; height: 900px; border: 1px solid ${colors.border}; border-radius: 8px; background: ${colors.bgCard}; display: block; }
188275
</style>
189276
</head>
190277
<body>
191278
<h1>Email Templates</h1>
279+
<p class="count">Every email Sim sends — ${Object.keys(emailTemplates).length} previews.</p>
192280
${categoryHtml}
193281
</body>
194282
</html>`,

apps/sim/app/api/files/multipart/route.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,19 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({
5252
}))
5353

5454
vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
55-
generateExecutionAttachmentKey: mockGenerateExecutionAttachmentKey,
55+
generateUniqueExecutionFileKey: mockGenerateUniqueExecutionFileKey,
5656
}))
5757

5858
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
5959

6060
const {
6161
mockCheckStorageQuota,
62-
mockGenerateExecutionAttachmentKey,
62+
mockGenerateUniqueExecutionFileKey,
6363
mockInitiateS3MultipartUpload,
6464
mockResolveStorageBillingContext,
6565
} = vi.hoisted(() => ({
6666
mockCheckStorageQuota: vi.fn(),
67-
mockGenerateExecutionAttachmentKey: vi.fn(),
67+
mockGenerateUniqueExecutionFileKey: vi.fn(),
6868
mockInitiateS3MultipartUpload: vi.fn(),
6969
mockResolveStorageBillingContext: vi.fn(),
7070
}))
@@ -250,7 +250,7 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
250250
mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
251251
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
252252
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
253-
mockGenerateExecutionAttachmentKey.mockImplementation(
253+
mockGenerateUniqueExecutionFileKey.mockImplementation(
254254
(
255255
context: { workspaceId: string; workflowId: string; executionId: string },
256256
fileName: string
@@ -311,7 +311,7 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
311311
})
312312

313313
it('allocates distinct multipart keys for duplicate execution attachment names', async () => {
314-
mockGenerateExecutionAttachmentKey
314+
mockGenerateUniqueExecutionFileKey
315315
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/one-output.bin')
316316
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/two-output.bin')
317317
mockInitiateS3MultipartUpload.mockImplementation(async ({ customKey }) => ({

apps/sim/app/api/files/multipart/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
215215
{ status: 400 }
216216
)
217217
}
218-
const { generateExecutionAttachmentKey } = await import(
218+
const { generateUniqueExecutionFileKey } = await import(
219219
'@/lib/uploads/contexts/execution/utils'
220220
)
221-
customKey = generateExecutionAttachmentKey(
221+
customKey = generateUniqueExecutionFileKey(
222222
{ workspaceId, workflowId, executionId },
223223
fileName
224224
)

apps/sim/app/api/files/presigned/route.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const {
2424
mockIsUsingCloudStorageUploads,
2525
mockGetUserEntityPermissions,
2626
mockGenerateWorkspaceFileKey,
27-
mockGenerateExecutionAttachmentKey,
27+
mockGenerateUniqueExecutionFileKey,
2828
mockInsertFileMetadata,
2929
mockCheckStorageQuotaForBillingContext,
3030
mockDecrementStorageUsageForBillingContext,
@@ -52,7 +52,7 @@ const {
5252
mockGenerateWorkspaceFileKey: vi.fn(
5353
(workspaceId: string, fileName: string) => `workspace/${workspaceId}/${fileName}`
5454
),
55-
mockGenerateExecutionAttachmentKey: vi.fn(
55+
mockGenerateUniqueExecutionFileKey: vi.fn(
5656
(ctx: { workspaceId: string; workflowId: string; executionId: string }, fileName: string) =>
5757
`execution/${ctx.workspaceId}/${ctx.workflowId}/${ctx.executionId}/attachment-${fileName}`
5858
),
@@ -110,7 +110,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
110110
}))
111111

112112
vi.mock('@/lib/uploads/contexts/execution/utils', () => ({
113-
generateExecutionAttachmentKey: mockGenerateExecutionAttachmentKey,
113+
generateUniqueExecutionFileKey: mockGenerateUniqueExecutionFileKey,
114114
}))
115115

116116
vi.mock('@/lib/uploads/server/metadata', () => ({
@@ -752,7 +752,7 @@ describe('/api/files/presigned', () => {
752752
describe('execution uploads', () => {
753753
it('allocates distinct create-only keys for duplicate attachment names', async () => {
754754
setupFileApiMocks({ cloudEnabled: true, storageProvider: 's3' })
755-
mockGenerateExecutionAttachmentKey
755+
mockGenerateUniqueExecutionFileKey
756756
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/one-output.txt')
757757
.mockReturnValueOnce('execution/ws-1/wf-1/exec-1/two-output.txt')
758758

apps/sim/app/api/files/presigned/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getSession } from '@/lib/auth'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { CopilotFiles } from '@/lib/uploads'
1313
import { getServeStoragePrefix } from '@/lib/uploads/config'
14-
import { generateExecutionAttachmentKey } from '@/lib/uploads/contexts/execution/utils'
14+
import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
1515
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
1616
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1717
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
@@ -222,7 +222,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
222222
throw new ValidationError(fileValidationError.message)
223223
}
224224

225-
const customKey = generateExecutionAttachmentKey(
225+
const customKey = generateUniqueExecutionFileKey(
226226
{ workspaceId, workflowId, executionId },
227227
fileName
228228
)

0 commit comments

Comments
 (0)