Skip to content

Commit 79be435

Browse files
feat(email): welcome email; improvement(emails): ui/ux (#2658)
* feat(email): welcome email; improvement(emails): ui/ux * improvement(emails): links, accounts, preview * refactor(emails): file structure and wrapper components * added envvar for personal emails sent, added isHosted gate * fixed failing tests, added env mock * fix: removed comment --------- Co-authored-by: waleed <[email protected]>
1 parent 852562c commit 79be435

Some content is hidden

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

62 files changed

+2201
-2147
lines changed

apps/sim/app/api/careers/submit/route.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { render } from '@react-email/components'
22
import { createLogger } from '@sim/logger'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
5-
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
6-
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
5+
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
76
import { generateRequestId } from '@/lib/core/utils/request'
87
import { sendEmail } from '@/lib/messaging/email/mailer'
98

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => {
156156
}),
157157
}))
158158

159+
vi.doMock('@/lib/core/config/env', async () => {
160+
const { createEnvMock } = await import('@sim/testing')
161+
return createEnvMock()
162+
})
163+
159164
vi.doMock('zod', () => ({
160165
z: {
161166
object: vi.fn().mockReturnValue({

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { and, eq, gt } from 'drizzle-orm'
66
import type { NextRequest } from 'next/server'
77
import { z } from 'zod'
8-
import { renderOTPEmail } from '@/components/emails/render-email'
8+
import { renderOTPEmail } from '@/components/emails'
99
import { getRedisClient } from '@/lib/core/config/redis'
1010
import { getStorageMethod } from '@/lib/core/storage'
1111
import { generateRequestId } from '@/lib/core/utils/request'

apps/sim/app/api/chat/route.test.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
249249
}),
250250
}))
251251

252-
vi.doMock('@/lib/core/config/env', () => ({
253-
env: {
252+
vi.doMock('@/lib/core/config/env', async () => {
253+
const { createEnvMock } = await import('@sim/testing')
254+
return createEnvMock({
254255
NODE_ENV: 'development',
255256
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
256-
},
257-
isTruthy: (value: string | boolean | number | undefined) =>
258-
typeof value === 'string'
259-
? value.toLowerCase() === 'true' || value === '1'
260-
: Boolean(value),
261-
getEnv: (variable: string) => process.env[variable],
262-
}))
257+
})
258+
})
263259

264260
const validData = {
265261
workflowId: 'workflow-123',
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
296292
}),
297293
}))
298294

299-
vi.doMock('@/lib/core/config/env', () => ({
300-
env: {
295+
vi.doMock('@/lib/core/config/env', async () => {
296+
const { createEnvMock } = await import('@sim/testing')
297+
return createEnvMock({
301298
NODE_ENV: 'development',
302299
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
303-
},
304-
isTruthy: (value: string | boolean | number | undefined) =>
305-
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
306-
getEnv: (variable: string) => process.env[variable],
307-
}))
300+
})
301+
})
308302

309303
const validData = {
310304
workflowId: 'workflow-123',

apps/sim/app/api/copilot/api-keys/route.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => {
2121
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
2222
}))
2323

24-
vi.doMock('@/lib/core/config/env', () => ({
25-
env: {
26-
SIM_AGENT_API_URL: null,
24+
vi.doMock('@/lib/core/config/env', async () => {
25+
const { createEnvMock } = await import('@sim/testing')
26+
return createEnvMock({
27+
SIM_AGENT_API_URL: undefined,
2728
COPILOT_API_KEY: 'test-api-key',
28-
},
29-
}))
29+
})
30+
})
3031
})
3132

3233
afterEach(() => {

apps/sim/app/api/copilot/stats/route.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
4646
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
4747
}))
4848

49-
vi.doMock('@/lib/core/config/env', () => ({
50-
env: {
51-
SIM_AGENT_API_URL: null,
49+
vi.doMock('@/lib/core/config/env', async () => {
50+
const { createEnvMock } = await import('@sim/testing')
51+
return createEnvMock({
52+
SIM_AGENT_API_URL: undefined,
5253
COPILOT_API_KEY: 'test-api-key',
53-
},
54-
}))
54+
})
55+
})
5556
})
5657

5758
afterEach(() => {
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import {
4+
renderBatchInvitationEmail,
5+
renderCareersConfirmationEmail,
6+
renderCareersSubmissionEmail,
7+
renderCreditPurchaseEmail,
8+
renderEnterpriseSubscriptionEmail,
9+
renderFreeTierUpgradeEmail,
10+
renderHelpConfirmationEmail,
11+
renderInvitationEmail,
12+
renderOTPEmail,
13+
renderPasswordResetEmail,
14+
renderPaymentFailedEmail,
15+
renderPlanWelcomeEmail,
16+
renderUsageThresholdEmail,
17+
renderWelcomeEmail,
18+
renderWorkspaceInvitationEmail,
19+
} from '@/components/emails'
20+
21+
const emailTemplates = {
22+
// Auth emails
23+
otp: () => renderOTPEmail('123456', '[email protected]', 'email-verification'),
24+
'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
25+
welcome: () => renderWelcomeEmail('John'),
26+
27+
// Invitation emails
28+
invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
29+
'batch-invitation': () =>
30+
renderBatchInvitationEmail(
31+
'Jane Doe',
32+
'Acme Corp',
33+
'admin',
34+
[
35+
{ workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' },
36+
{ workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' },
37+
],
38+
'https://sim.ai/invite/abc123'
39+
),
40+
'workspace-invitation': () =>
41+
renderWorkspaceInvitationEmail(
42+
'John Smith',
43+
'Engineering Team',
44+
'https://sim.ai/workspace/invite/abc123'
45+
),
46+
47+
// Support emails
48+
'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2),
49+
50+
// Billing emails
51+
'usage-threshold': () =>
52+
renderUsageThresholdEmail({
53+
userName: 'John',
54+
planName: 'Pro',
55+
percentUsed: 75,
56+
currentUsage: 15,
57+
limit: 20,
58+
ctaLink: 'https://sim.ai/settings/billing',
59+
}),
60+
'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'),
61+
'free-tier-upgrade': () =>
62+
renderFreeTierUpgradeEmail({
63+
userName: 'John',
64+
percentUsed: 90,
65+
currentUsage: 9,
66+
limit: 10,
67+
upgradeLink: 'https://sim.ai/settings/billing',
68+
}),
69+
'plan-welcome-pro': () =>
70+
renderPlanWelcomeEmail({
71+
planName: 'Pro',
72+
userName: 'John',
73+
loginLink: 'https://sim.ai/login',
74+
}),
75+
'plan-welcome-team': () =>
76+
renderPlanWelcomeEmail({
77+
planName: 'Team',
78+
userName: 'John',
79+
loginLink: 'https://sim.ai/login',
80+
}),
81+
'credit-purchase': () =>
82+
renderCreditPurchaseEmail({
83+
userName: 'John',
84+
amount: 50,
85+
newBalance: 75,
86+
}),
87+
'payment-failed': () =>
88+
renderPaymentFailedEmail({
89+
userName: 'John',
90+
amountDue: 20,
91+
lastFourDigits: '4242',
92+
billingPortalUrl: 'https://sim.ai/settings/billing',
93+
failureReason: 'Card declined',
94+
}),
95+
96+
// Careers emails
97+
'careers-confirmation': () => renderCareersConfirmationEmail('John Doe', 'Senior Engineer'),
98+
'careers-submission': () =>
99+
renderCareersSubmissionEmail({
100+
name: 'John Doe',
101+
102+
phone: '+1 (555) 123-4567',
103+
position: 'Senior Engineer',
104+
linkedin: 'https://linkedin.com/in/johndoe',
105+
portfolio: 'https://johndoe.dev',
106+
experience: '5-10',
107+
location: 'San Francisco, CA',
108+
message:
109+
'I have 10 years of experience building scalable distributed systems. Most recently, I led a team at a Series B startup where we scaled from 100K to 10M users.',
110+
}),
111+
} as const
112+
113+
type EmailTemplate = keyof typeof emailTemplates
114+
115+
export async function GET(request: NextRequest) {
116+
const { searchParams } = new URL(request.url)
117+
const template = searchParams.get('template') as EmailTemplate | null
118+
119+
if (!template) {
120+
const categories = {
121+
Auth: ['otp', 'reset-password', 'welcome'],
122+
Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'],
123+
Support: ['help-confirmation'],
124+
Billing: [
125+
'usage-threshold',
126+
'enterprise-subscription',
127+
'free-tier-upgrade',
128+
'plan-welcome-pro',
129+
'plan-welcome-team',
130+
'credit-purchase',
131+
'payment-failed',
132+
],
133+
Careers: ['careers-confirmation', 'careers-submission'],
134+
}
135+
136+
const categoryHtml = Object.entries(categories)
137+
.map(
138+
([category, templates]) => `
139+
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
140+
<ul style="list-style: none; padding: 0; margin: 0;">
141+
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
142+
</ul>
143+
`
144+
)
145+
.join('')
146+
147+
return new NextResponse(
148+
`<!DOCTYPE html>
149+
<html>
150+
<head>
151+
<title>Email Previews</title>
152+
<style>
153+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
154+
h1 { color: #333; margin-bottom: 32px; }
155+
a:hover { text-decoration: underline; }
156+
</style>
157+
</head>
158+
<body>
159+
<h1>Email Templates</h1>
160+
${categoryHtml}
161+
</body>
162+
</html>`,
163+
{ headers: { 'Content-Type': 'text/html' } }
164+
)
165+
}
166+
167+
if (!(template in emailTemplates)) {
168+
return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 })
169+
}
170+
171+
const html = await emailTemplates[template]()
172+
173+
return new NextResponse(html, {
174+
headers: { 'Content-Type': 'text/html' },
175+
})
176+
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ ${message}
118118
// Send confirmation email to the user
119119
try {
120120
const confirmationHtml = await renderHelpConfirmationEmail(
121-
email,
122121
type as 'bug' | 'feedback' | 'feature_request' | 'other',
123122
images.length
124123
)

apps/sim/app/api/organizations/[id]/invitations/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
getEmailSubject,
1717
renderBatchInvitationEmail,
1818
renderInvitationEmail,
19-
} from '@/components/emails/render-email'
19+
} from '@/components/emails'
2020
import { getSession } from '@/lib/auth'
2121
import {
2222
validateBulkInvitations,
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
376376
const emailHtml = await renderInvitationEmail(
377377
inviter[0]?.name || 'Someone',
378378
organizationEntry[0]?.name || 'organization',
379-
`${getBaseUrl()}/invite/${orgInvitation.id}`,
380-
email
379+
`${getBaseUrl()}/invite/${orgInvitation.id}`
381380
)
382381

383382
emailResult = await sendEmail({

apps/sim/app/api/organizations/[id]/members/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem
44
import { createLogger } from '@sim/logger'
55
import { and, eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
7-
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
7+
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
88
import { getSession } from '@/lib/auth'
99
import { getUserUsageData } from '@/lib/billing/core/usage'
1010
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
260260
const emailHtml = await renderInvitationEmail(
261261
inviter[0]?.name || 'Someone',
262262
organizationEntry[0]?.name || 'organization',
263-
`${getBaseUrl()}/invite/organization?id=${invitationId}`,
264-
normalizedEmail
263+
`${getBaseUrl()}/invite/organization?id=${invitationId}`
265264
)
266265

267266
const emailResult = await sendEmail({

0 commit comments

Comments
 (0)