diff --git a/apps/sim/app/api/careers/submit/route.ts b/apps/sim/app/api/careers/submit/route.ts
index 0d6e0646d..2ce430972 100644
--- a/apps/sim/app/api/careers/submit/route.ts
+++ b/apps/sim/app/api/careers/submit/route.ts
@@ -2,8 +2,7 @@ import { render } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
-import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
-import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
+import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
import { generateRequestId } from '@/lib/core/utils/request'
import { sendEmail } from '@/lib/messaging/email/mailer'
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts
index 24526a80d..b16818f17 100644
--- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts
+++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts
@@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => {
}),
}))
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock()
+ })
+
vi.doMock('zod', () => ({
z: {
object: vi.fn().mockReturnValue({
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts
index 52948e2bf..de7b0ff4b 100644
--- a/apps/sim/app/api/chat/[identifier]/otp/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
-import { renderOTPEmail } from '@/components/emails/render-email'
+import { renderOTPEmail } from '@/components/emails'
import { getRedisClient } from '@/lib/core/config/redis'
import { getStorageMethod } from '@/lib/core/storage'
import { generateRequestId } from '@/lib/core/utils/request'
diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts
index 0eb628883..4fb96da4e 100644
--- a/apps/sim/app/api/chat/route.test.ts
+++ b/apps/sim/app/api/chat/route.test.ts
@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
}),
}))
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
- },
- isTruthy: (value: string | boolean | number | undefined) =>
- typeof value === 'string'
- ? value.toLowerCase() === 'true' || value === '1'
- : Boolean(value),
- getEnv: (variable: string) => process.env[variable],
- }))
+ })
+ })
const validData = {
workflowId: 'workflow-123',
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
}),
}))
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
- },
- isTruthy: (value: string | boolean | number | undefined) =>
- typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
- getEnv: (variable: string) => process.env[variable],
- }))
+ })
+ })
const validData = {
workflowId: 'workflow-123',
diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts
index 2556a7e93..b5d27be6e 100644
--- a/apps/sim/app/api/copilot/api-keys/route.test.ts
+++ b/apps/sim/app/api/copilot/api-keys/route.test.ts
@@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
}))
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
- SIM_AGENT_API_URL: null,
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
+ SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key',
- },
- }))
+ })
+ })
})
afterEach(() => {
diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts
index e48dff016..0d06c5edd 100644
--- a/apps/sim/app/api/copilot/stats/route.test.ts
+++ b/apps/sim/app/api/copilot/stats/route.test.ts
@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
}))
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
- SIM_AGENT_API_URL: null,
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
+ SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key',
- },
- }))
+ })
+ })
})
afterEach(() => {
diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts
new file mode 100644
index 000000000..5047a3eaa
--- /dev/null
+++ b/apps/sim/app/api/emails/preview/route.ts
@@ -0,0 +1,176 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import {
+ renderBatchInvitationEmail,
+ renderCareersConfirmationEmail,
+ renderCareersSubmissionEmail,
+ renderCreditPurchaseEmail,
+ renderEnterpriseSubscriptionEmail,
+ renderFreeTierUpgradeEmail,
+ renderHelpConfirmationEmail,
+ renderInvitationEmail,
+ renderOTPEmail,
+ renderPasswordResetEmail,
+ renderPaymentFailedEmail,
+ renderPlanWelcomeEmail,
+ renderUsageThresholdEmail,
+ renderWelcomeEmail,
+ renderWorkspaceInvitationEmail,
+} from '@/components/emails'
+
+const emailTemplates = {
+ // Auth emails
+ otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'),
+ 'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'),
+ welcome: () => renderWelcomeEmail('John'),
+
+ // Invitation emails
+ invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'),
+ 'batch-invitation': () =>
+ renderBatchInvitationEmail(
+ 'Jane Doe',
+ 'Acme Corp',
+ 'admin',
+ [
+ { workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' },
+ { workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' },
+ ],
+ 'https://sim.ai/invite/abc123'
+ ),
+ 'workspace-invitation': () =>
+ renderWorkspaceInvitationEmail(
+ 'John Smith',
+ 'Engineering Team',
+ 'https://sim.ai/workspace/invite/abc123'
+ ),
+
+ // Support emails
+ 'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2),
+
+ // Billing emails
+ 'usage-threshold': () =>
+ renderUsageThresholdEmail({
+ userName: 'John',
+ planName: 'Pro',
+ percentUsed: 75,
+ currentUsage: 15,
+ limit: 20,
+ ctaLink: 'https://sim.ai/settings/billing',
+ }),
+ 'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'),
+ 'free-tier-upgrade': () =>
+ renderFreeTierUpgradeEmail({
+ userName: 'John',
+ percentUsed: 90,
+ currentUsage: 9,
+ limit: 10,
+ upgradeLink: 'https://sim.ai/settings/billing',
+ }),
+ 'plan-welcome-pro': () =>
+ renderPlanWelcomeEmail({
+ planName: 'Pro',
+ userName: 'John',
+ loginLink: 'https://sim.ai/login',
+ }),
+ 'plan-welcome-team': () =>
+ renderPlanWelcomeEmail({
+ planName: 'Team',
+ userName: 'John',
+ loginLink: 'https://sim.ai/login',
+ }),
+ 'credit-purchase': () =>
+ renderCreditPurchaseEmail({
+ userName: 'John',
+ amount: 50,
+ newBalance: 75,
+ }),
+ 'payment-failed': () =>
+ renderPaymentFailedEmail({
+ userName: 'John',
+ amountDue: 20,
+ lastFourDigits: '4242',
+ billingPortalUrl: 'https://sim.ai/settings/billing',
+ failureReason: 'Card declined',
+ }),
+
+ // Careers emails
+ 'careers-confirmation': () => renderCareersConfirmationEmail('John Doe', 'Senior Engineer'),
+ 'careers-submission': () =>
+ renderCareersSubmissionEmail({
+ name: 'John Doe',
+ email: 'john@example.com',
+ phone: '+1 (555) 123-4567',
+ position: 'Senior Engineer',
+ linkedin: 'https://linkedin.com/in/johndoe',
+ portfolio: 'https://johndoe.dev',
+ experience: '5-10',
+ location: 'San Francisco, CA',
+ message:
+ '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.',
+ }),
+} as const
+
+type EmailTemplate = keyof typeof emailTemplates
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url)
+ const template = searchParams.get('template') as EmailTemplate | null
+
+ 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',
+ ],
+ Careers: ['careers-confirmation', 'careers-submission'],
+ }
+
+ const categoryHtml = Object.entries(categories)
+ .map(
+ ([category, templates]) => `
+
${category}
+
+ ${templates.map((t) => `- ${t}
`).join('')}
+
+ `
+ )
+ .join('')
+
+ return new NextResponse(
+ `
+
+
+ Email Previews
+
+
+
+ Email Templates
+ ${categoryHtml}
+
+`,
+ { headers: { 'Content-Type': 'text/html' } }
+ )
+ }
+
+ if (!(template in emailTemplates)) {
+ return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 })
+ }
+
+ const html = await emailTemplates[template]()
+
+ return new NextResponse(html, {
+ headers: { 'Content-Type': 'text/html' },
+ })
+}
diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts
index ca3d040c2..676397d2d 100644
--- a/apps/sim/app/api/help/route.ts
+++ b/apps/sim/app/api/help/route.ts
@@ -118,7 +118,6 @@ ${message}
// Send confirmation email to the user
try {
const confirmationHtml = await renderHelpConfirmationEmail(
- email,
type as 'bug' | 'feedback' | 'feature_request' | 'other',
images.length
)
diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts
index 46cdabea9..f72705e90 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts
@@ -16,7 +16,7 @@ import {
getEmailSubject,
renderBatchInvitationEmail,
renderInvitationEmail,
-} from '@/components/emails/render-email'
+} from '@/components/emails'
import { getSession } from '@/lib/auth'
import {
validateBulkInvitations,
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
- `${getBaseUrl()}/invite/${orgInvitation.id}`,
- email
+ `${getBaseUrl()}/invite/${orgInvitation.id}`
)
emailResult = await sendEmail({
diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts
index 4ada7c2ba..eb3f4b0cd 100644
--- a/apps/sim/app/api/organizations/[id]/members/route.ts
+++ b/apps/sim/app/api/organizations/[id]/members/route.ts
@@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
+import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization',
- `${getBaseUrl()}/invite/organization?id=${invitationId}`,
- normalizedEmail
+ `${getBaseUrl()}/invite/organization?id=${invitationId}`
)
const emailResult = await sendEmail({
diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
index 0d427f177..4d4ac7928 100644
--- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
+++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
@@ -11,7 +11,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
+import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts
index 96370bf63..b47e7bf36 100644
--- a/apps/sim/app/api/workspaces/invitations/route.test.ts
+++ b/apps/sim/app/api/workspaces/invitations/route.test.ts
@@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => {
WorkspaceInvitationEmail: vi.fn(),
}))
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
- RESEND_API_KEY: 'test-resend-key',
- NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
- FROM_EMAIL_ADDRESS: 'Sim ',
- EMAIL_DOMAIN: 'test.sim.ai',
- },
- }))
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock()
+ })
vi.doMock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts
index 6ad6285b3..19ee61087 100644
--- a/apps/sim/app/api/workspaces/invitations/route.ts
+++ b/apps/sim/app/api/workspaces/invitations/route.ts
@@ -12,7 +12,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
+import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts
new file mode 100644
index 000000000..844ac1c55
--- /dev/null
+++ b/apps/sim/components/emails/_styles/base.ts
@@ -0,0 +1,246 @@
+/**
+ * Base styles for all email templates.
+ * Colors are derived from globals.css light mode tokens.
+ */
+
+/** Color tokens from globals.css (light mode) */
+export const colors = {
+ /** Main canvas background */
+ bgOuter: '#F7F9FC',
+ /** Card/container background - pure white */
+ bgCard: '#ffffff',
+ /** Primary text color */
+ textPrimary: '#2d2d2d',
+ /** Secondary text color */
+ textSecondary: '#404040',
+ /** Tertiary text color */
+ textTertiary: '#5c5c5c',
+ /** Muted text (footer) */
+ textMuted: '#737373',
+ /** Brand primary - purple */
+ brandPrimary: '#6f3dfa',
+ /** Brand tertiary - green (matches Run/Deploy buttons) */
+ brandTertiary: '#32bd7e',
+ /** Border/divider color */
+ divider: '#ededed',
+ /** Footer background */
+ footerBg: '#F7F9FC',
+}
+
+/** Typography settings */
+export const typography = {
+ fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif",
+ fontSize: {
+ body: '16px',
+ small: '14px',
+ caption: '12px',
+ },
+ lineHeight: {
+ body: '24px',
+ caption: '20px',
+ },
+}
+
+/** Spacing values */
+export const spacing = {
+ containerWidth: 600,
+ gutter: 40,
+ sectionGap: 20,
+ paragraphGap: 12,
+ /** Logo width in pixels */
+ logoWidth: 90,
+}
+
+export const baseStyles = {
+ fontFamily: typography.fontFamily,
+
+ /** Main body wrapper with outer background */
+ main: {
+ backgroundColor: colors.bgOuter,
+ fontFamily: typography.fontFamily,
+ padding: '32px 0',
+ },
+
+ /** Center wrapper for email content */
+ wrapper: {
+ maxWidth: `${spacing.containerWidth}px`,
+ margin: '0 auto',
+ },
+
+ /** Main card container with rounded corners */
+ container: {
+ maxWidth: `${spacing.containerWidth}px`,
+ margin: '0 auto',
+ backgroundColor: colors.bgCard,
+ borderRadius: '16px',
+ overflow: 'hidden',
+ },
+
+ /** Header section with logo */
+ header: {
+ padding: `32px ${spacing.gutter}px 16px ${spacing.gutter}px`,
+ textAlign: 'left' as const,
+ },
+
+ /** Main content area with horizontal padding */
+ content: {
+ padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`,
+ },
+
+ /** Standard paragraph text */
+ paragraph: {
+ fontSize: typography.fontSize.body,
+ lineHeight: typography.lineHeight.body,
+ color: colors.textSecondary,
+ fontWeight: 400,
+ fontFamily: typography.fontFamily,
+ 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',
+ },
+
+ /** Primary CTA button - matches app tertiary button style */
+ button: {
+ display: 'inline-block',
+ backgroundColor: colors.brandTertiary,
+ color: '#ffffff',
+ fontWeight: 500,
+ fontSize: '14px',
+ padding: '6px 12px',
+ borderRadius: '5px',
+ textDecoration: 'none',
+ textAlign: 'center' as const,
+ margin: '4px 0',
+ fontFamily: typography.fontFamily,
+ },
+
+ /** Link text style */
+ link: {
+ color: colors.brandTertiary,
+ fontWeight: 'bold' as const,
+ textDecoration: 'none',
+ },
+
+ /** Horizontal divider */
+ divider: {
+ borderTop: `1px solid ${colors.divider}`,
+ 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 text style */
+ footerText: {
+ fontSize: typography.fontSize.caption,
+ lineHeight: typography.lineHeight.caption,
+ color: colors.textMuted,
+ fontFamily: typography.fontFamily,
+ margin: '0 0 10px 0',
+ },
+
+ /** Code/OTP container */
+ codeContainer: {
+ margin: '12px 0',
+ padding: '12px 16px',
+ backgroundColor: '#f8f9fa',
+ borderRadius: '6px',
+ border: `1px solid ${colors.divider}`,
+ textAlign: 'center' as const,
+ },
+
+ /** Code/OTP text */
+ code: {
+ fontSize: '24px',
+ fontWeight: 'bold' as const,
+ letterSpacing: '3px',
+ color: colors.textPrimary,
+ fontFamily: typography.fontFamily,
+ margin: 0,
+ },
+
+ /** Highlighted info box (e.g., "What you get with Pro") */
+ infoBox: {
+ backgroundColor: colors.bgOuter,
+ padding: '16px 18px',
+ borderRadius: '6px',
+ margin: '16px 0',
+ },
+
+ /** Info box title */
+ infoBoxTitle: {
+ fontSize: typography.fontSize.body,
+ lineHeight: typography.lineHeight.body,
+ fontWeight: 600,
+ color: colors.textPrimary,
+ fontFamily: typography.fontFamily,
+ margin: '0 0 8px 0',
+ },
+
+ /** Info box list content */
+ infoBoxList: {
+ fontSize: typography.fontSize.body,
+ lineHeight: '1.6',
+ color: colors.textSecondary,
+ fontFamily: typography.fontFamily,
+ margin: 0,
+ },
+
+ /** Section borders - decorative accent line */
+ sectionsBorders: {
+ width: '100%',
+ display: 'flex',
+ },
+
+ sectionBorder: {
+ borderBottom: `1px solid ${colors.divider}`,
+ width: '249px',
+ },
+
+ sectionCenter: {
+ borderBottom: `1px solid ${colors.brandTertiary}`,
+ width: '102px',
+ },
+
+ /** Spacer row for vertical spacing in tables */
+ spacer: {
+ border: 0,
+ margin: 0,
+ padding: 0,
+ fontSize: '1px',
+ lineHeight: '1px',
+ },
+
+ /** Gutter cell for horizontal padding in tables */
+ gutter: {
+ border: 0,
+ margin: 0,
+ padding: 0,
+ fontSize: '1px',
+ 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',
+ },
+}
diff --git a/apps/sim/components/emails/_styles/index.ts b/apps/sim/components/emails/_styles/index.ts
new file mode 100644
index 000000000..dd1d961d5
--- /dev/null
+++ b/apps/sim/components/emails/_styles/index.ts
@@ -0,0 +1 @@
+export { baseStyles, colors, spacing, typography } from './base'
diff --git a/apps/sim/components/emails/auth/index.ts b/apps/sim/components/emails/auth/index.ts
new file mode 100644
index 000000000..b1d04f1dd
--- /dev/null
+++ b/apps/sim/components/emails/auth/index.ts
@@ -0,0 +1,3 @@
+export { OTPVerificationEmail } from './otp-verification-email'
+export { ResetPasswordEmail } from './reset-password-email'
+export { WelcomeEmail } from './welcome-email'
diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx
new file mode 100644
index 000000000..41791adfb
--- /dev/null
+++ b/apps/sim/components/emails/auth/otp-verification-email.tsx
@@ -0,0 +1,57 @@
+import { Section, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+
+interface OTPVerificationEmailProps {
+ otp: string
+ email?: string
+ type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access'
+ chatTitle?: string
+}
+
+const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
+ switch (type) {
+ case 'sign-in':
+ return `Sign in to ${brandName}`
+ case 'email-verification':
+ return `Verify your email for ${brandName}`
+ case 'forget-password':
+ return `Reset your ${brandName} password`
+ case 'chat-access':
+ return `Verification code for ${chatTitle || 'Chat'}`
+ default:
+ return `Verification code for ${brandName}`
+ }
+}
+
+export function OTPVerificationEmail({
+ otp,
+ email = '',
+ type = 'email-verification',
+ chatTitle,
+}: OTPVerificationEmailProps) {
+ const brand = getBrandConfig()
+
+ return (
+
+ Your verification code:
+
+
+
+ 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.
+
+
+ )
+}
+
+export default OTPVerificationEmail
diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx
new file mode 100644
index 000000000..68f95c2ae
--- /dev/null
+++ b/apps/sim/components/emails/auth/reset-password-email.tsx
@@ -0,0 +1,36 @@
+import { Link, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+
+interface ResetPasswordEmailProps {
+ username?: string
+ resetLink?: string
+}
+
+export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPasswordEmailProps) {
+ const brand = getBrandConfig()
+
+ return (
+
+ Hello {username},
+
+ A password reset was requested for your {brand.name} account. Click below to set a new
+ password.
+
+
+
+ Reset Password
+
+
+ {/* Divider */}
+
+
+
+ If you didn't request this, you can ignore this email. Link expires in 24 hours.
+
+
+ )
+}
+
+export default ResetPasswordEmail
diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx
new file mode 100644
index 000000000..bbd196b08
--- /dev/null
+++ b/apps/sim/components/emails/auth/welcome-email.tsx
@@ -0,0 +1,45 @@
+import { Link, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+
+interface WelcomeEmailProps {
+ userName?: string
+}
+
+export function WelcomeEmail({ userName }: WelcomeEmailProps) {
+ const brand = getBrandConfig()
+ const baseUrl = getBaseUrl()
+
+ return (
+
+
+ {userName ? `Hey ${userName},` : 'Hey,'}
+
+
+ Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI
+ workflows in minutes.
+
+
+
+ Get Started
+
+
+
+ If you have any questions or feedback, just reply to this email. I read every message!
+
+
+ - Emir, co-founder of {brand.name}
+
+ {/* Divider */}
+
+
+
+ You're on the free plan with $10 in credits to get started.
+
+
+ )
+}
+
+export default WelcomeEmail
diff --git a/apps/sim/components/emails/base-styles.ts b/apps/sim/components/emails/base-styles.ts
deleted file mode 100644
index 633c762ee..000000000
--- a/apps/sim/components/emails/base-styles.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-// Base styles for all email templates
-
-export const baseStyles = {
- fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
- main: {
- backgroundColor: '#f5f5f7',
- fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
- },
- container: {
- maxWidth: '580px',
- margin: '30px auto',
- backgroundColor: '#ffffff',
- borderRadius: '5px',
- overflow: 'hidden',
- },
- header: {
- padding: '30px 0',
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- backgroundColor: '#ffffff',
- },
- content: {
- padding: '5px 30px 20px 30px',
- },
- paragraph: {
- fontSize: '16px',
- lineHeight: '1.5',
- color: '#333333',
- margin: '16px 0',
- },
- button: {
- display: 'inline-block',
- backgroundColor: '#6F3DFA',
- color: '#ffffff',
- fontWeight: 'bold',
- fontSize: '16px',
- padding: '12px 30px',
- borderRadius: '5px',
- textDecoration: 'none',
- textAlign: 'center' as const,
- margin: '20px 0',
- },
- link: {
- color: '#6F3DFA',
- textDecoration: 'underline',
- },
- footer: {
- maxWidth: '580px',
- margin: '0 auto',
- padding: '20px 0',
- textAlign: 'center' as const,
- },
- footerText: {
- fontSize: '12px',
- color: '#666666',
- margin: '0',
- },
- codeContainer: {
- margin: '20px 0',
- padding: '16px',
- backgroundColor: '#f8f9fa',
- borderRadius: '5px',
- border: '1px solid #eee',
- textAlign: 'center' as const,
- },
- code: {
- fontSize: '28px',
- fontWeight: 'bold',
- letterSpacing: '4px',
- color: '#333333',
- },
- sectionsBorders: {
- width: '100%',
- display: 'flex',
- },
- sectionBorder: {
- borderBottom: '1px solid #eeeeee',
- width: '249px',
- },
- sectionCenter: {
- borderBottom: '1px solid #6F3DFA',
- width: '102px',
- },
-}
diff --git a/apps/sim/components/emails/batch-invitation-email.tsx b/apps/sim/components/emails/batch-invitation-email.tsx
deleted file mode 100644
index fa67ae096..000000000
--- a/apps/sim/components/emails/batch-invitation-email.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-interface WorkspaceInvitation {
- workspaceId: string
- workspaceName: string
- permission: 'admin' | 'write' | 'read'
-}
-
-interface BatchInvitationEmailProps {
- inviterName: string
- organizationName: string
- organizationRole: 'admin' | 'member'
- workspaceInvitations: WorkspaceInvitation[]
- acceptUrl: string
-}
-
-const getPermissionLabel = (permission: string) => {
- switch (permission) {
- case 'admin':
- return 'Admin (full access)'
- case 'write':
- return 'Editor (can edit workflows)'
- case 'read':
- return 'Viewer (read-only access)'
- default:
- return permission
- }
-}
-
-const getRoleLabel = (role: string) => {
- switch (role) {
- case 'admin':
- return 'Admin'
- case 'member':
- return 'Member'
- default:
- return role
- }
-}
-
-export const BatchInvitationEmail = ({
- inviterName = 'Someone',
- organizationName = 'the team',
- organizationRole = 'member',
- workspaceInvitations = [],
- acceptUrl,
-}: BatchInvitationEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
- const hasWorkspaces = workspaceInvitations.length > 0
-
- return (
-
-
-
-
- You've been invited to join {organizationName}
- {hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Hello,
-
- {inviterName} has invited you to join{' '}
- {organizationName} on Sim.
-
-
- {/* 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."}
-
-
- {/* Workspace Invitations */}
- {hasWorkspaces && (
- <>
-
-
- Workspace Access ({workspaceInvitations.length} workspace
- {workspaceInvitations.length !== 1 ? 's' : ''}):
-
-
- {workspaceInvitations.map((ws) => (
-
- • {ws.workspaceName} - {getPermissionLabel(ws.permission)}
-
- ))}
- >
- )}
-
-
- Accept Invitation
-
-
-
- By accepting this invitation, you'll join {organizationName}
- {hasWorkspaces
- ? ` and gain access to ${workspaceInvitations.length} workspace(s)`
- : ''}
- .
-
-
-
- This invitation will expire in 7 days. If you didn't expect this invitation, you can
- safely ignore this email.
-
-
-
- Best regards,
-
- The Sim Team
-
-
-
-
-
-
-
- )
-}
-
-export default BatchInvitationEmail
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx
index 8668ef9f4..b2c62a0a0 100644
--- a/apps/sim/components/emails/billing/credit-purchase-email.tsx
+++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx
@@ -1,19 +1,6 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Hr,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { Link, Section, Text } from '@react-email/components'
+import { baseStyles, colors } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -36,89 +23,74 @@ export function CreditPurchaseEmail({
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
return (
-
-
-
-
-
-
-
-
-
-
+
+
+ {userName ? `Hi ${userName},` : 'Hi,'}
+
+
+ Your credit purchase of ${amount.toFixed(2)} has been confirmed.
+
-
+
+
+ Amount Added
+
+
+ ${amount.toFixed(2)}
+
+
+ New Balance
+
+
+ ${newBalance.toFixed(2)}
+
+
-
-
- {userName ? `Hi ${userName},` : 'Hi,'}
-
-
- Your credit purchase of ${amount.toFixed(2)} has been confirmed.
-
+
+ Credits are applied automatically to your workflow executions.
+
-
- Amount Added
-
- ${amount.toFixed(2)}
-
- New Balance
-
- ${newBalance.toFixed(2)}
-
-
+
+ View Dashboard
+
-
- These credits will be applied automatically to your workflow executions. Credits are
- consumed before any overage charges apply.
-
+ {/* Divider */}
+
-
- View Dashboard
-
-
-
-
-
- You can view your credit balance and purchase history in Settings → Subscription.
-
-
-
- Best regards,
-
- The Sim Team
-
-
-
- Purchased on {purchaseDate.toLocaleDateString()}
-
-
-
-
-
-
+
+ Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription.
+
+
)
}
diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx
index f65930ad2..28afdcb72 100644
--- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx
+++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx
@@ -1,120 +1,50 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { format } from 'date-fns'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { Link, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface EnterpriseSubscriptionEmailProps {
userName?: string
- userEmail?: string
loginLink?: string
- createdDate?: Date
}
-export const EnterpriseSubscriptionEmail = ({
+export function EnterpriseSubscriptionEmail({
userName = 'Valued User',
- userEmail = '',
loginLink,
- createdDate = new Date(),
-}: EnterpriseSubscriptionEmailProps) => {
+}: EnterpriseSubscriptionEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const effectiveLoginLink = loginLink || `${baseUrl}/login`
return (
-
-
- Your Enterprise Plan is now active on Sim
-
-
-
-
-
-
-
-
+
+ Hello {userName},
+
+ Your Enterprise Plan is now active. You have full access to advanced
+ features and increased capacity for your workflows.
+
-
+
+ Open {brand.name}
+
-
- Hello {userName},
-
- Great news! Your Enterprise Plan has been activated on Sim. You now
- have access to advanced features and increased capacity for your workflows.
-
+
+ Next steps:
+
• Invite team members to your organization
+
• Start building your workflows
+
-
- Your account has been set up with full access to your organization. Click below to log
- in and start exploring your new Enterprise features:
-
+ {/* Divider */}
+
-
- Access Your Enterprise Account
-
-
-
- What's next?
-
-
- • Invite team members to your organization
-
• Begin building your workflows
-
-
-
- If you have any questions or need assistance getting started, our support team is here
- to help.
-
-
-
- Best regards,
-
- The Sim Team
-
-
-
- This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
- regarding your Enterprise plan activation on Sim.
-
-
-
-
-
-
-
+
+ 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 857a3e9a9..c18d7bc17 100644
--- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx
+++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx
@@ -1,21 +1,7 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Hr,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { Link, Section, Text } from '@react-email/components'
+import { baseStyles, colors, typography } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
interface FreeTierUpgradeEmailProps {
userName?: string
@@ -23,119 +9,105 @@ interface FreeTierUpgradeEmailProps {
currentUsage: number
limit: number
upgradeLink: string
- updatedDate?: Date
}
+const proFeatures = [
+ { label: '$20/month', desc: 'in credits included' },
+ { label: '25 runs/min', desc: 'sync executions' },
+ { label: '200 runs/min', desc: 'async executions' },
+ { label: '50GB storage', desc: 'for files & assets' },
+ { label: 'Unlimited', desc: 'workspaces & invites' },
+]
+
export function FreeTierUpgradeEmail({
userName,
percentUsed,
currentUsage,
limit,
upgradeLink,
- updatedDate = new Date(),
}: FreeTierUpgradeEmailProps) {
const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return (
-
-
-
-
-
-
-
+
+ {userName ? `Hi ${userName},` : 'Hi,'}
+
+
+
+ You've used ${currentUsage.toFixed(2)} of your{' '}
+ ${limit.toFixed(2)} 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
+
-
-
- {userName ? `Hi ${userName},` : 'Hi,'}
-
+ {/* Divider */}
+
-
- You've used ${currentUsage.toFixed(2)} of your{' '}
- ${limit.toFixed(2)} free credits ({percentUsed}%).
-
-
-
- To ensure uninterrupted service and unlock the full power of {brand.name}, upgrade to
- Pro today.
-
-
-
-
- What you get with Pro:
-
-
- • $20/month in credits – 2x your free tier
-
• Priority support – Get help when you need it
-
• Advanced features – Access to premium blocks and
- integrations
-
• No interruptions – Never worry about running out of credits
-
-
-
-
-
- Upgrade now to keep building without limits.
-
-
- Upgrade to Pro
-
-
-
- Questions? We're here to help.
-
-
- Best regards,
-
- The {brand.name} Team
-
-
-
- Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 90%.
-
-
-
-
-
-
-
+
+ One-time notification at 90% usage.
+
+
)
}
diff --git a/apps/sim/components/emails/billing/index.ts b/apps/sim/components/emails/billing/index.ts
new file mode 100644
index 000000000..81f25ebfb
--- /dev/null
+++ b/apps/sim/components/emails/billing/index.ts
@@ -0,0 +1,6 @@
+export { CreditPurchaseEmail } from './credit-purchase-email'
+export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
+export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
+export { PaymentFailedEmail } from './payment-failed-email'
+export { PlanWelcomeEmail } from './plan-welcome-email'
+export { UsageThresholdEmail } from './usage-threshold-email'
diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx
index 17d9087a2..eb982fe39 100644
--- a/apps/sim/components/emails/billing/payment-failed-email.tsx
+++ b/apps/sim/components/emails/billing/payment-failed-email.tsx
@@ -1,21 +1,7 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Hr,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { Link, Section, Text } from '@react-email/components'
+import { baseStyles, colors } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
interface PaymentFailedEmailProps {
userName?: string
@@ -35,132 +21,88 @@ export function PaymentFailedEmail({
sentDate = new Date(),
}: PaymentFailedEmailProps) {
const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
const previewText = `${brand.name}: Payment Failed - Action Required`
return (
-
-
-
-
-
-
-
-
-
-
+
+
+ {userName ? `Hi ${userName},` : 'Hi,'}
+
-
+
+ We were unable to process your payment.
+
-
-
- {userName ? `Hi ${userName},` : 'Hi,'}
-
+
+ Your {brand.name} account has been temporarily blocked to prevent service interruptions and
+ unexpected charges. To restore access immediately, please update your payment method.
+
-
- We were unable to process your payment.
-
+
+
+ Payment Details
+
+
+ Amount due: ${amountDue.toFixed(2)}
+
+ {lastFourDigits && (
+
+ Payment method: •••• {lastFourDigits}
+
+ )}
+ {failureReason && (
+ Reason: {failureReason}
+ )}
+
-
- Your {brand.name} account has been temporarily blocked to prevent service
- interruptions and unexpected charges. To restore access immediately, please update
- your payment method.
-
+
+ Update Payment Method
+
-
-
-
-
- Payment Details
-
-
- Amount due: ${amountDue.toFixed(2)}
-
- {lastFourDigits && (
-
- Payment method: •••• {lastFourDigits}
-
- )}
- {failureReason && (
-
- Reason: {failureReason}
-
- )}
-
-
-
+ {/* Divider */}
+
-
- Update Payment Method
-
+ 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
+
-
- What happens next?
-
+ {/* Divider */}
+
-
- • 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
-
-
-
-
-
- Need help?
-
-
-
- Common reasons for payment failures include expired cards, insufficient funds, or
- incorrect billing information. If you continue to experience issues, please{' '}
-
- contact our support team
-
- .
-
-
-
- Best regards,
-
- The Sim Team
-
-
-
- Sent on {sentDate.toLocaleDateString()} • This is a critical transactional
- notification.
-
-
-
-
-
-
-
+
+ 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 fbac398b8..253d4a39f 100644
--- a/apps/sim/components/emails/billing/plan-welcome-email.tsx
+++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx
@@ -1,19 +1,6 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Hr,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { Link, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -21,15 +8,9 @@ interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team'
userName?: string
loginLink?: string
- createdDate?: Date
}
-export function PlanWelcomeEmail({
- planName,
- userName,
- loginLink,
- createdDate = new Date(),
-}: PlanWelcomeEmailProps) {
+export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const cta = loginLink || `${baseUrl}/login`
@@ -37,76 +18,34 @@ export function PlanWelcomeEmail({
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
-
-
-
-
-
-
-
-
-
-
+
+
+ {userName ? `Hi ${userName},` : 'Hi,'}
+
+
+ Welcome to {planName}! You're all set to build, test, and scale your
+ workflows.
+
-
+
+ Open {brand.name}
+
-
-
- {userName ? `Hi ${userName},` : 'Hi,'}
-
-
- Welcome to the {planName} plan on {brand.name}. You're all set to
- build, test, and scale your agentic workflows.
-
+
+ Want help getting started?{' '}
+
+ Schedule a call
+ {' '}
+ with our team.
+
-
- Open {brand.name}
-
+ {/* Divider */}
+
-
- Want to discuss your plan or get personalized help getting started?{' '}
-
- Schedule a 15-minute call
- {' '}
- with our team.
-
-
-
-
-
- Need to invite teammates, adjust usage limits, or manage billing? You can do that from
- Settings → Subscription.
-
-
-
- Best regards,
-
- The Sim Team
-
-
-
- Sent on {createdDate.toLocaleDateString()}
-
-
-
-
-
-
+
+ Manage your subscription in Settings → Subscription.
+
+
)
}
diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx
index 1885e2e60..fdcb01c32 100644
--- a/apps/sim/components/emails/billing/usage-threshold-email.tsx
+++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx
@@ -1,21 +1,7 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Hr,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { Link, Section, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
interface UsageThresholdEmailProps {
userName?: string
@@ -24,7 +10,6 @@ interface UsageThresholdEmailProps {
currentUsage: number
limit: number
ctaLink: string
- updatedDate?: Date
}
export function UsageThresholdEmail({
@@ -34,89 +19,46 @@ export function UsageThresholdEmail({
currentUsage,
limit,
ctaLink,
- updatedDate = new Date(),
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
-
-
-
-
-
-
-
-
-
-
+
+
+ {userName ? `Hi ${userName},` : 'Hi,'}
+
-
+
+ You're approaching your monthly budget on the {planName} plan.
+
-
-
- {userName ? `Hi ${userName},` : 'Hi,'}
-
+
+ Usage
+
+ ${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
+
+
-
- You're approaching your monthly budget on the {planName} plan.
-
+ {/* Divider */}
+
-
-
-
-
- Usage
-
-
- ${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
-
-
-
-
+
+ To avoid interruptions, consider increasing your monthly limit.
+
-
+
+ Review Limits
+
-
- To avoid interruptions, consider increasing your monthly limit.
-
+ {/* Divider */}
+
-
- Review limits
-
-
-
- Best regards,
-
- The Sim Team
-
-
-
- Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 80%.
-
-
-
-
-
-
-
+
+ One-time notification at 80% usage.
+
+
)
}
diff --git a/apps/sim/components/emails/careers/careers-confirmation-email.tsx b/apps/sim/components/emails/careers/careers-confirmation-email.tsx
index 6c15c1c09..beb07e1da 100644
--- a/apps/sim/components/emails/careers/careers-confirmation-email.tsx
+++ b/apps/sim/components/emails/careers/careers-confirmation-email.tsx
@@ -1,18 +1,7 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
+import { Text } from '@react-email/components'
import { format } from 'date-fns'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -22,96 +11,46 @@ interface CareersConfirmationEmailProps {
submittedDate?: Date
}
-export const CareersConfirmationEmail = ({
+export function CareersConfirmationEmail({
name,
position,
submittedDate = new Date(),
-}: CareersConfirmationEmailProps) => {
+}: CareersConfirmationEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
-
-
- Your application to {brand.name} has been received
-
-
-
-
-
-
-
-
+
+ Hello {name},
+
+ We've received your application for {position}. Our team reviews every
+ application and will reach out if there's a match.
+
-
+
+ In the meantime, explore our{' '}
+
+ docs
+ {' '}
+ or{' '}
+
+ blog
+ {' '}
+ to learn more about what we're building.
+
-
- Hello {name},
-
- Thank you for your interest in joining the {brand.name} team! We've received your
- application for the {position} position.
-
+ {/* Divider */}
+
-
- Our team carefully reviews every application and will get back to you within the next
- few weeks. If your qualifications match what we're looking for, we'll reach out to
- schedule an initial conversation.
-
-
-
- In the meantime, feel free to explore our{' '}
-
- documentation
- {' '}
- to learn more about what we're building, or check out our{' '}
-
- blog
- {' '}
- for the latest updates.
-
-
-
- Best regards,
-
- The {brand.name} Team
-
-
-
- This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
- {format(submittedDate, 'h:mm a')}.
-
-
-
-
-
-
-
+
+ Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
+
+
)
}
diff --git a/apps/sim/components/emails/careers/careers-submission-email.tsx b/apps/sim/components/emails/careers/careers-submission-email.tsx
index 9beed5d3a..0d12664be 100644
--- a/apps/sim/components/emails/careers/careers-submission-email.tsx
+++ b/apps/sim/components/emails/careers/careers-submission-email.tsx
@@ -1,19 +1,7 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
+import { Section, Text } from '@react-email/components'
import { format } from 'date-fns'
-import { baseStyles } from '@/components/emails/base-styles'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
+import { baseStyles, colors } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
interface CareersSubmissionEmailProps {
name: string
@@ -39,7 +27,7 @@ const getExperienceLabel = (experience: string) => {
return labels[experience] || experience
}
-export const CareersSubmissionEmail = ({
+export function CareersSubmissionEmail({
name,
email,
phone,
@@ -50,263 +38,299 @@ export const CareersSubmissionEmail = ({
location,
message,
submittedDate = new Date(),
-}: CareersSubmissionEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
-
+}: CareersSubmissionEmailProps) {
return (
-
-
- New Career Application from {name}
-
-
-
-
-
-
-
-
+
+
+ New Career Application
+
-
+
+ A new career application has been submitted on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
+ {format(submittedDate, 'h:mm a')}.
+
-
-
- New Career Application
-
+ {/* Applicant Information */}
+
+
+ Applicant Information
+
-
- A new career application has been submitted on{' '}
- {format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}.
-
-
- {/* Applicant Information */}
-
-
+
+
+
- Applicant Information
-
-
-
-
- |
- Name:
- |
- {name} |
-
-
- |
- Email:
- |
-
-
- {email}
-
- |
-
- {phone && (
-
- |
- Phone:
- |
-
-
- {phone}
-
- |
-
- )}
-
- |
- Position:
- |
-
- {position}
- |
-
-
- |
- Experience:
- |
-
- {getExperienceLabel(experience)}
- |
-
-
- |
- Location:
- |
-
- {location}
- |
-
- {linkedin && (
-
- |
- LinkedIn:
- |
-
-
- View Profile
-
- |
-
- )}
- {portfolio && (
-
- |
- Portfolio:
- |
-
-
- View Portfolio
-
- |
-
- )}
-
-
-
- {/* Message */}
-
-
- About Themselves
-
-
- {message}
-
-
+ Name:
+ |
+
+ {name}
+ |
+
+
+ |
+ Email:
+ |
+
+
+ {email}
+
+ |
+
+ {phone && (
+
+ |
+ Phone:
+ |
+
+
+ {phone}
+
+ |
+
+ )}
+
+ |
+ Position:
+ |
+
+ {position}
+ |
+
+
+ |
+ Experience:
+ |
+
+ {getExperienceLabel(experience)}
+ |
+
+
+ |
+ Location:
+ |
+
+ {location}
+ |
+
+ {linkedin && (
+
+ |
+ LinkedIn:
+ |
+
+
+ View Profile
+
+ |
+
+ )}
+ {portfolio && (
+
+ |
+ Portfolio:
+ |
+
+
+ View Portfolio
+
+ |
+
+ )}
+
+
+
-
- Please review this application and reach out to the candidate at your earliest
- convenience.
-
-
-
-
-
+ {/* Message */}
+
+
+ About Themselves
+
+
+ {message}
+
+
+
)
}
diff --git a/apps/sim/components/emails/careers/index.ts b/apps/sim/components/emails/careers/index.ts
new file mode 100644
index 000000000..b4d335415
--- /dev/null
+++ b/apps/sim/components/emails/careers/index.ts
@@ -0,0 +1,2 @@
+export { CareersConfirmationEmail } from './careers-confirmation-email'
+export { CareersSubmissionEmail } from './careers-submission-email'
diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx
new file mode 100644
index 000000000..76ef355ee
--- /dev/null
+++ b/apps/sim/components/emails/components/email-footer.tsx
@@ -0,0 +1,233 @@
+import { Container, Img, Link, Section } from '@react-email/components'
+import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
+import { getBrandConfig } from '@/lib/branding/branding'
+import { isHosted } from '@/lib/core/config/feature-flags'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+
+interface UnsubscribeOptions {
+ unsubscribeToken?: string
+ email?: string
+}
+
+interface EmailFooterProps {
+ baseUrl?: string
+ unsubscribe?: UnsubscribeOptions
+ messageId?: string
+}
+
+/**
+ * Email footer component styled to match Stripe's email design.
+ * Sits in the gray area below the main white card.
+ */
+export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) {
+ const brand = getBrandConfig()
+
+ const footerLinkStyle = {
+ color: colors.textMuted,
+ textDecoration: 'underline',
+ fontWeight: 'normal' as const,
+ fontFamily: typography.fontFamily,
+ }
+
+ return (
+
+
+
+
+
+ |
+
+ |
+
+
+ {/* Social links row */}
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+ |
+
+ |
+
+
+ {/* Address row */}
+
+ |
+
+ |
+
+ {brand.name}
+ {isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA>}
+ |
+
+
+ |
+
+
+
+ |
+
+ |
+
+
+ {/* Contact row */}
+
+ |
+
+ |
+
+ Questions?{' '}
+
+ {brand.supportEmail}
+
+ |
+
+
+ |
+
+
+
+ |
+
+ |
+
+
+ {/* Message ID row (optional) */}
+ {messageId && (
+ <>
+
+ |
+
+ |
+
+ Need to refer to this message? Use this ID: {messageId}
+ |
+
+
+ |
+
+
+ |
+
+ |
+
+ >
+ )}
+
+ {/* Links row */}
+
+ |
+
+ |
+
+
+ Privacy Policy
+ {' '}
+ •{' '}
+
+ Terms of Service
+ {' '}
+ •{' '}
+
+ Unsubscribe
+
+ |
+
+
+ |
+
+
+ {/* Copyright row */}
+
+ |
+
+ |
+
+
+ |
+
+ |
+
+ © {new Date().getFullYear()} {brand.name}, All Rights Reserved
+ |
+
+
+ |
+
+
+
+ |
+
+ |
+
+
+
+
+
+ )
+}
+
+export default EmailFooter
diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx
new file mode 100644
index 000000000..4f6f5395e
--- /dev/null
+++ b/apps/sim/components/emails/components/email-layout.tsx
@@ -0,0 +1,52 @@
+import { Body, Container, Head, Html, Img, Preview, Section } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailFooter } from '@/components/emails/components/email-footer'
+import { getBrandConfig } from '@/lib/branding/branding'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+
+interface EmailLayoutProps {
+ /** Preview text shown in email client list view */
+ preview: string
+ /** Email content to render inside the layout */
+ children: React.ReactNode
+ /** Optional: hide footer for internal emails */
+ hideFooter?: boolean
+}
+
+/**
+ * Shared email layout wrapper providing consistent structure.
+ * Includes Html, Head, Body, Container with logo header, and Footer.
+ */
+export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) {
+ const brand = getBrandConfig()
+ const baseUrl = getBaseUrl()
+
+ return (
+
+
+ {/* Main card container */}
+
+ {/* Header with logo */}
+
+
+
+
+ {/* Content */}
+
+
+
+ {/* Footer in gray section */}
+ {!hideFooter && }
+
+
+ )
+}
+
+export default EmailLayout
diff --git a/apps/sim/components/emails/components/index.ts b/apps/sim/components/emails/components/index.ts
new file mode 100644
index 000000000..d7c1d712a
--- /dev/null
+++ b/apps/sim/components/emails/components/index.ts
@@ -0,0 +1,2 @@
+export { EmailFooter } from './email-footer'
+export { EmailLayout } from './email-layout'
diff --git a/apps/sim/components/emails/footer.tsx b/apps/sim/components/emails/footer.tsx
deleted file mode 100644
index f6eb4044d..000000000
--- a/apps/sim/components/emails/footer.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Container, Img, Link, Section, Text } from '@react-email/components'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { isHosted } from '@/lib/core/config/feature-flags'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-interface UnsubscribeOptions {
- unsubscribeToken?: string
- email?: string
-}
-
-interface EmailFooterProps {
- baseUrl?: string
- unsubscribe?: UnsubscribeOptions
-}
-
-export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe }: EmailFooterProps) => {
- const brand = getBrandConfig()
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
- |
-
-
-
-
- |
-
-
- |
-
-
-
-
- © {new Date().getFullYear()} {brand.name}, All Rights Reserved
-
- If you have any questions, please contact us at{' '}
-
- {brand.supportEmail}
-
- {isHosted && (
- <>
-
- Sim, 80 Langton St, San Francisco, CA 94133, USA
- >
- )}
-
-
- |
-
-
-
-
- )
-}
-
-export default EmailFooter
diff --git a/apps/sim/components/emails/help-confirmation-email.tsx b/apps/sim/components/emails/help-confirmation-email.tsx
deleted file mode 100644
index 834fd233e..000000000
--- a/apps/sim/components/emails/help-confirmation-email.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { format } from 'date-fns'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-interface HelpConfirmationEmailProps {
- userEmail?: string
- type?: 'bug' | 'feedback' | 'feature_request' | 'other'
- attachmentCount?: number
- submittedDate?: Date
-}
-
-const getTypeLabel = (type: string) => {
- switch (type) {
- case 'bug':
- return 'Bug Report'
- case 'feedback':
- return 'Feedback'
- case 'feature_request':
- return 'Feature Request'
- case 'other':
- return 'General Inquiry'
- default:
- return 'Request'
- }
-}
-
-export const HelpConfirmationEmail = ({
- userEmail = '',
- type = 'other',
- attachmentCount = 0,
- submittedDate = new Date(),
-}: HelpConfirmationEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
- const typeLabel = getTypeLabel(type)
-
- return (
-
-
- Your {typeLabel.toLowerCase()} has been received
-
-
-
-
-
-
-
-
-
-
-
-
- Hello,
-
- Thank you for your {typeLabel.toLowerCase()} submission. We've
- received your request and will get back to you as soon as possible.
-
-
- {attachmentCount > 0 && (
-
- You attached{' '}
-
- {attachmentCount} image{attachmentCount > 1 ? 's' : ''}
- {' '}
- with your request.
-
- )}
-
-
- We typically respond to{' '}
- {type === 'bug'
- ? 'bug reports'
- : type === 'feature_request'
- ? 'feature requests'
- : 'inquiries'}{' '}
- within a few hours. If you need immediate assistance, please don't hesitate to reach
- out to us directly.
-
-
-
- Best regards,
-
- The {brand.name} Team
-
-
-
- This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} for your{' '}
- {typeLabel.toLowerCase()} submission from {userEmail}.
-
-
-
-
-
-
-
- )
-}
-
-export default HelpConfirmationEmail
diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts
index d2d7d70d0..6cd1fea0d 100644
--- a/apps/sim/components/emails/index.ts
+++ b/apps/sim/components/emails/index.ts
@@ -1,12 +1,17 @@
-export * from './base-styles'
-export { BatchInvitationEmail } from './batch-invitation-email'
-export { EnterpriseSubscriptionEmail } from './billing/enterprise-subscription-email'
-export { PlanWelcomeEmail } from './billing/plan-welcome-email'
-export { UsageThresholdEmail } from './billing/usage-threshold-email'
-export { default as EmailFooter } from './footer'
-export { HelpConfirmationEmail } from './help-confirmation-email'
-export { InvitationEmail } from './invitation-email'
-export { OTPVerificationEmail } from './otp-verification-email'
-export * from './render-email'
-export { ResetPasswordEmail } from './reset-password-email'
-export { WorkspaceInvitationEmail } from './workspace-invitation'
+// Styles
+export * from './_styles'
+// Auth emails
+export * from './auth'
+// Billing emails
+export * from './billing'
+// Careers emails
+export * from './careers'
+// Shared components
+export * from './components'
+// Invitation emails
+export * from './invitations'
+// Render functions and subjects
+export * from './render'
+export * from './subjects'
+// Support emails
+export * from './support'
diff --git a/apps/sim/components/emails/invitation-email.tsx b/apps/sim/components/emails/invitation-email.tsx
deleted file mode 100644
index 42c769354..000000000
--- a/apps/sim/components/emails/invitation-email.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { createLogger } from '@sim/logger'
-import { format } from 'date-fns'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-interface InvitationEmailProps {
- inviterName?: string
- organizationName?: string
- inviteLink?: string
- invitedEmail?: string
- updatedDate?: Date
-}
-
-const logger = createLogger('InvitationEmail')
-
-export const InvitationEmail = ({
- inviterName = 'A team member',
- organizationName = 'an organization',
- inviteLink = '',
- invitedEmail = '',
- updatedDate = new Date(),
-}: InvitationEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
-
- // Extract invitation ID or token from inviteLink if present
- let enhancedLink = inviteLink
-
- // Check if link contains an ID (old format) and append token parameter if needed
- if (inviteLink && !inviteLink.includes('token=')) {
- try {
- const url = new URL(inviteLink)
- const invitationId = url.pathname.split('/').pop()
- if (invitationId) {
- enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
- }
- } catch (e) {
- logger.error('Error parsing invite link:', e)
- }
- }
-
- return (
-
-
- You've been invited to join {organizationName} on Sim
-
-
-
-
-
-
-
-
-
-
-
-
- Hello,
-
- {inviterName} has invited you to join{' '}
- {organizationName} on Sim. Sim is a powerful, user-friendly platform
- for building, testing, and optimizing agentic workflows.
-
-
- Accept Invitation
-
-
- This invitation will expire in 48 hours. If you believe this invitation was sent in
- error, please ignore this email.
-
-
- Best regards,
-
- The Sim Team
-
-
- This email was sent on {format(updatedDate, 'MMMM do, yyyy')} to {invitedEmail} with
- an invitation to join {organizationName} on Sim.
-
-
-
-
-
-
-
- )
-}
-
-export default InvitationEmail
diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx
new file mode 100644
index 000000000..b955af326
--- /dev/null
+++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx
@@ -0,0 +1,105 @@
+import { Link, Text } from '@react-email/components'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+
+interface WorkspaceInvitation {
+ workspaceId: string
+ workspaceName: string
+ permission: 'admin' | 'write' | 'read'
+}
+
+interface BatchInvitationEmailProps {
+ inviterName: string
+ organizationName: string
+ organizationRole: 'admin' | 'member'
+ workspaceInvitations: WorkspaceInvitation[]
+ acceptUrl: string
+}
+
+const getPermissionLabel = (permission: string) => {
+ switch (permission) {
+ case 'admin':
+ return 'Admin (full access)'
+ case 'write':
+ return 'Editor (can edit workflows)'
+ case 'read':
+ return 'Viewer (read-only access)'
+ default:
+ return permission
+ }
+}
+
+const getRoleLabel = (role: string) => {
+ switch (role) {
+ case 'admin':
+ return 'Admin'
+ case 'member':
+ return 'Member'
+ default:
+ return role
+ }
+}
+
+export function BatchInvitationEmail({
+ inviterName = 'Someone',
+ organizationName = 'the team',
+ organizationRole = 'member',
+ workspaceInvitations = [],
+ acceptUrl,
+}: BatchInvitationEmailProps) {
+ const brand = getBrandConfig()
+ const hasWorkspaces = workspaceInvitations.length > 0
+
+ return (
+
+ Hello,
+
+ {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."}
+
+
+ {/* Workspace Invitations */}
+ {hasWorkspaces && (
+ <>
+
+
+ Workspace Access ({workspaceInvitations.length} workspace
+ {workspaceInvitations.length !== 1 ? 's' : ''}):
+
+
+ {workspaceInvitations.map((ws) => (
+
+ • {ws.workspaceName} - {getPermissionLabel(ws.permission)}
+
+ ))}
+ >
+ )}
+
+
+ Accept Invitation
+
+
+ {/* Divider */}
+
+
+
+ Invitation expires in 7 days. If unexpected, you can ignore this email.
+
+
+ )
+}
+
+export default BatchInvitationEmail
diff --git a/apps/sim/components/emails/invitations/index.ts b/apps/sim/components/emails/invitations/index.ts
new file mode 100644
index 000000000..2133bad90
--- /dev/null
+++ b/apps/sim/components/emails/invitations/index.ts
@@ -0,0 +1,3 @@
+export { BatchInvitationEmail } from './batch-invitation-email'
+export { InvitationEmail } from './invitation-email'
+export { WorkspaceInvitationEmail } from './workspace-invitation-email'
diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx
new file mode 100644
index 000000000..fae1ec1c8
--- /dev/null
+++ b/apps/sim/components/emails/invitations/invitation-email.tsx
@@ -0,0 +1,60 @@
+import { Link, Text } from '@react-email/components'
+import { createLogger } from '@sim/logger'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+
+interface InvitationEmailProps {
+ inviterName?: string
+ organizationName?: string
+ inviteLink?: string
+}
+
+const logger = createLogger('InvitationEmail')
+
+export function InvitationEmail({
+ inviterName = 'A team member',
+ organizationName = 'an organization',
+ inviteLink = '',
+}: InvitationEmailProps) {
+ const brand = getBrandConfig()
+ const baseUrl = getBaseUrl()
+
+ let enhancedLink = inviteLink
+
+ if (inviteLink && !inviteLink.includes('token=')) {
+ try {
+ const url = new URL(inviteLink)
+ const invitationId = url.pathname.split('/').pop()
+ if (invitationId) {
+ enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
+ }
+ } catch (e) {
+ logger.error('Error parsing invite link:', e)
+ }
+ }
+
+ return (
+
+ Hello,
+
+ {inviterName} invited you to join {organizationName} on{' '}
+ {brand.name}.
+
+
+
+ Accept Invitation
+
+
+ {/* Divider */}
+
+
+
+ Invitation expires in 48 hours. If unexpected, you can ignore this email.
+
+
+ )
+}
+
+export default InvitationEmail
diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx
new file mode 100644
index 000000000..f9c2dca54
--- /dev/null
+++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx
@@ -0,0 +1,65 @@
+import { Link, Text } from '@react-email/components'
+import { createLogger } from '@sim/logger'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+import { getBrandConfig } from '@/lib/branding/branding'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+
+const logger = createLogger('WorkspaceInvitationEmail')
+
+interface WorkspaceInvitationEmailProps {
+ workspaceName?: string
+ inviterName?: string
+ invitationLink?: string
+}
+
+export function WorkspaceInvitationEmail({
+ workspaceName = 'Workspace',
+ inviterName = 'Someone',
+ invitationLink = '',
+}: WorkspaceInvitationEmailProps) {
+ const brand = getBrandConfig()
+ const baseUrl = getBaseUrl()
+
+ let enhancedLink = invitationLink
+
+ try {
+ if (
+ invitationLink.includes('/api/workspaces/invitations/accept') ||
+ invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/)
+ ) {
+ const url = new URL(invitationLink)
+ const token = url.searchParams.get('token')
+ if (token) {
+ enhancedLink = `${baseUrl}/invite/${token}?token=${token}`
+ }
+ }
+ } catch (e) {
+ logger.error('Error enhancing invitation link:', e)
+ }
+
+ return (
+
+ Hello,
+
+ {inviterName} invited you to join the {workspaceName}{' '}
+ workspace on {brand.name}.
+
+
+
+ Accept Invitation
+
+
+ {/* Divider */}
+
+
+
+ Invitation expires in 7 days. If unexpected, you can ignore this email.
+
+
+ )
+}
+
+export default WorkspaceInvitationEmail
diff --git a/apps/sim/components/emails/otp-verification-email.tsx b/apps/sim/components/emails/otp-verification-email.tsx
deleted file mode 100644
index 1fdaa87ff..000000000
--- a/apps/sim/components/emails/otp-verification-email.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-interface OTPVerificationEmailProps {
- otp: string
- email?: string
- type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access'
- chatTitle?: string
-}
-
-const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
- switch (type) {
- case 'sign-in':
- return `Sign in to ${brandName}`
- case 'email-verification':
- return `Verify your email for ${brandName}`
- case 'forget-password':
- return `Reset your ${brandName} password`
- case 'chat-access':
- return `Verification code for ${chatTitle || 'Chat'}`
- default:
- return `Verification code for ${brandName}`
- }
-}
-
-export const OTPVerificationEmail = ({
- otp,
- email = '',
- type = 'email-verification',
- chatTitle,
-}: OTPVerificationEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
-
- // Get a message based on the type
- const getMessage = () => {
- switch (type) {
- case 'sign-in':
- return `Sign in to ${brand.name}`
- case 'forget-password':
- return `Reset your password for ${brand.name}`
- case 'chat-access':
- return `Access ${chatTitle || 'the chat'}`
- default:
- return `Welcome to ${brand.name}`
- }
- }
-
- return (
-
-
- {getSubjectByType(type, brand.name, chatTitle)}
-
-
-
-
-
-
-
-
-
-
- {getMessage()}
- Your verification code is:
-
- This code will expire in 15 minutes.
-
- If you didn't request this code, you can safely ignore this email.
-
-
- Best regards,
-
- The Sim Team
-
-
-
-
-
-
-
- )
-}
-
-export default OTPVerificationEmail
diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render.ts
similarity index 59%
rename from apps/sim/components/emails/render-email.ts
rename to apps/sim/components/emails/render.ts
index 15efb1cf1..bd18eeedc 100644
--- a/apps/sim/components/emails/render-email.ts
+++ b/apps/sim/components/emails/render.ts
@@ -1,19 +1,31 @@
import { render } from '@react-email/components'
+import { OTPVerificationEmail, ResetPasswordEmail, WelcomeEmail } from '@/components/emails/auth'
+import {
+ CreditPurchaseEmail,
+ EnterpriseSubscriptionEmail,
+ FreeTierUpgradeEmail,
+ PaymentFailedEmail,
+ PlanWelcomeEmail,
+ UsageThresholdEmail,
+} from '@/components/emails/billing'
+import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails/careers'
import {
BatchInvitationEmail,
- EnterpriseSubscriptionEmail,
- HelpConfirmationEmail,
InvitationEmail,
- OTPVerificationEmail,
- PlanWelcomeEmail,
- ResetPasswordEmail,
- UsageThresholdEmail,
-} from '@/components/emails'
-import CreditPurchaseEmail from '@/components/emails/billing/credit-purchase-email'
-import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email'
-import { getBrandConfig } from '@/lib/branding/branding'
+ WorkspaceInvitationEmail,
+} from '@/components/emails/invitations'
+import { HelpConfirmationEmail } from '@/components/emails/support'
import { getBaseUrl } from '@/lib/core/utils/urls'
+export type { EmailSubjectType } from './subjects'
+export { getEmailSubject } from './subjects'
+
+interface WorkspaceInvitation {
+ workspaceId: string
+ workspaceName: string
+ permission: 'admin' | 'write' | 'read'
+}
+
export async function renderOTPEmail(
otp: string,
email: string,
@@ -27,34 +39,23 @@ export async function renderPasswordResetEmail(
username: string,
resetLink: string
): Promise {
- return await render(
- ResetPasswordEmail({ username, resetLink: resetLink, updatedDate: new Date() })
- )
+ return await render(ResetPasswordEmail({ username, resetLink }))
}
export async function renderInvitationEmail(
inviterName: string,
organizationName: string,
- invitationUrl: string,
- email: string
+ invitationUrl: string
): Promise {
return await render(
InvitationEmail({
inviterName,
organizationName,
inviteLink: invitationUrl,
- invitedEmail: email,
- updatedDate: new Date(),
})
)
}
-interface WorkspaceInvitation {
- workspaceId: string
- workspaceName: string
- permission: 'admin' | 'write' | 'read'
-}
-
export async function renderBatchInvitationEmail(
inviterName: string,
organizationName: string,
@@ -74,13 +75,11 @@ export async function renderBatchInvitationEmail(
}
export async function renderHelpConfirmationEmail(
- userEmail: string,
type: 'bug' | 'feedback' | 'feature_request' | 'other',
attachmentCount = 0
): Promise {
return await render(
HelpConfirmationEmail({
- userEmail,
type,
attachmentCount,
submittedDate: new Date(),
@@ -88,19 +87,14 @@ export async function renderHelpConfirmationEmail(
)
}
-export async function renderEnterpriseSubscriptionEmail(
- userName: string,
- userEmail: string
-): Promise {
+export async function renderEnterpriseSubscriptionEmail(userName: string): Promise {
const baseUrl = getBaseUrl()
const loginLink = `${baseUrl}/login`
return await render(
EnterpriseSubscriptionEmail({
userName,
- userEmail,
loginLink,
- createdDate: new Date(),
})
)
}
@@ -121,7 +115,6 @@ export async function renderUsageThresholdEmail(params: {
currentUsage: params.currentUsage,
limit: params.limit,
ctaLink: params.ctaLink,
- updatedDate: new Date(),
})
)
}
@@ -140,61 +133,10 @@ export async function renderFreeTierUpgradeEmail(params: {
currentUsage: params.currentUsage,
limit: params.limit,
upgradeLink: params.upgradeLink,
- updatedDate: new Date(),
})
)
}
-export function getEmailSubject(
- type:
- | 'sign-in'
- | 'email-verification'
- | 'forget-password'
- | 'reset-password'
- | 'invitation'
- | 'batch-invitation'
- | 'help-confirmation'
- | 'enterprise-subscription'
- | 'usage-threshold'
- | 'free-tier-upgrade'
- | 'plan-welcome-pro'
- | 'plan-welcome-team'
- | 'credit-purchase'
-): string {
- const brandName = getBrandConfig().name
-
- switch (type) {
- case 'sign-in':
- return `Sign in to ${brandName}`
- case 'email-verification':
- return `Verify your email for ${brandName}`
- case 'forget-password':
- return `Reset your ${brandName} password`
- case 'reset-password':
- return `Reset your ${brandName} password`
- case 'invitation':
- return `You've been invited to join a team on ${brandName}`
- case 'batch-invitation':
- return `You've been invited to join a team and workspaces on ${brandName}`
- case 'help-confirmation':
- return 'Your request has been received'
- case 'enterprise-subscription':
- return `Your Enterprise Plan is now active on ${brandName}`
- case 'usage-threshold':
- return `You're nearing your monthly budget on ${brandName}`
- case 'free-tier-upgrade':
- return `You're at 90% of your free credits on ${brandName}`
- case 'plan-welcome-pro':
- return `Your Pro plan is now active on ${brandName}`
- case 'plan-welcome-team':
- return `Your Team plan is now active on ${brandName}`
- case 'credit-purchase':
- return `Credits added to your ${brandName} account`
- default:
- return brandName
- }
-}
-
export async function renderPlanWelcomeEmail(params: {
planName: 'Pro' | 'Team'
userName?: string
@@ -205,11 +147,14 @@ export async function renderPlanWelcomeEmail(params: {
planName: params.planName,
userName: params.userName,
loginLink: params.loginLink,
- createdDate: new Date(),
})
)
}
+export async function renderWelcomeEmail(userName?: string): Promise {
+ return await render(WelcomeEmail({ userName }))
+}
+
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number
@@ -224,3 +169,73 @@ export async function renderCreditPurchaseEmail(params: {
})
)
}
+
+export async function renderWorkspaceInvitationEmail(
+ inviterName: string,
+ workspaceName: string,
+ invitationLink: string
+): Promise {
+ return await render(
+ WorkspaceInvitationEmail({
+ inviterName,
+ workspaceName,
+ invitationLink,
+ })
+ )
+}
+
+export async function renderPaymentFailedEmail(params: {
+ userName?: string
+ amountDue: number
+ lastFourDigits?: string
+ billingPortalUrl: string
+ failureReason?: string
+}): Promise {
+ return await render(
+ PaymentFailedEmail({
+ userName: params.userName,
+ amountDue: params.amountDue,
+ lastFourDigits: params.lastFourDigits,
+ billingPortalUrl: params.billingPortalUrl,
+ failureReason: params.failureReason,
+ })
+ )
+}
+
+export async function renderCareersConfirmationEmail(
+ name: string,
+ position: string
+): Promise {
+ return await render(
+ CareersConfirmationEmail({
+ name,
+ position,
+ })
+ )
+}
+
+export async function renderCareersSubmissionEmail(params: {
+ name: string
+ email: string
+ phone?: string
+ position: string
+ linkedin?: string
+ portfolio?: string
+ experience: string
+ location: string
+ message: string
+}): Promise {
+ return await render(
+ CareersSubmissionEmail({
+ name: params.name,
+ email: params.email,
+ phone: params.phone,
+ position: params.position,
+ linkedin: params.linkedin,
+ portfolio: params.portfolio,
+ experience: params.experience,
+ location: params.location,
+ message: params.message,
+ })
+ )
+}
diff --git a/apps/sim/components/emails/reset-password-email.tsx b/apps/sim/components/emails/reset-password-email.tsx
deleted file mode 100644
index 92d86b1ca..000000000
--- a/apps/sim/components/emails/reset-password-email.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { format } from 'date-fns'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-interface ResetPasswordEmailProps {
- username?: string
- resetLink?: string
- updatedDate?: Date
-}
-
-export const ResetPasswordEmail = ({
- username = '',
- resetLink = '',
- updatedDate = new Date(),
-}: ResetPasswordEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
-
- return (
-
-
-
- Reset your {brand.name} password
-
-
-
-
-
-
-
-
-
-
-
-
- Hello {username},
-
- You recently requested to reset your password for your {brand.name} account. Use the
- button below to reset it. This password reset is only valid for the next 24 hours.
-
-
- Reset Your Password
-
-
- If you did not request a password reset, please ignore this email or contact support
- if you have concerns.
-
-
- Best regards,
-
- The {brand.name} Team
-
-
- This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset
- was requested for your account.
-
-
-
-
-
-
-
- )
-}
-
-export default ResetPasswordEmail
diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts
new file mode 100644
index 000000000..26f451270
--- /dev/null
+++ b/apps/sim/components/emails/subjects.ts
@@ -0,0 +1,60 @@
+import { getBrandConfig } from '@/lib/branding/branding'
+
+/** Email subject type for all supported email templates */
+export type EmailSubjectType =
+ | 'sign-in'
+ | 'email-verification'
+ | 'forget-password'
+ | 'reset-password'
+ | 'invitation'
+ | 'batch-invitation'
+ | 'help-confirmation'
+ | 'enterprise-subscription'
+ | 'usage-threshold'
+ | 'free-tier-upgrade'
+ | 'plan-welcome-pro'
+ | 'plan-welcome-team'
+ | 'credit-purchase'
+ | 'welcome'
+
+/**
+ * Returns the email subject line for a given email type.
+ * @param type - The type of email being sent
+ * @returns The subject line for the email
+ */
+export function getEmailSubject(type: EmailSubjectType): string {
+ const brandName = getBrandConfig().name
+
+ switch (type) {
+ case 'sign-in':
+ return `Sign in to ${brandName}`
+ case 'email-verification':
+ return `Verify your email for ${brandName}`
+ case 'forget-password':
+ return `Reset your ${brandName} password`
+ case 'reset-password':
+ return `Reset your ${brandName} password`
+ case 'invitation':
+ return `You've been invited to join a team on ${brandName}`
+ case 'batch-invitation':
+ return `You've been invited to join a team and workspaces on ${brandName}`
+ case 'help-confirmation':
+ return 'Your request has been received'
+ case 'enterprise-subscription':
+ return `Your Enterprise Plan is now active on ${brandName}`
+ case 'usage-threshold':
+ return `You're nearing your monthly budget on ${brandName}`
+ case 'free-tier-upgrade':
+ return `You're at 90% of your free credits on ${brandName}`
+ case 'plan-welcome-pro':
+ return `Your Pro plan is now active on ${brandName}`
+ case 'plan-welcome-team':
+ return `Your Team plan is now active on ${brandName}`
+ case 'credit-purchase':
+ return `Credits added to your ${brandName} account`
+ case 'welcome':
+ return `Welcome to ${brandName}`
+ default:
+ return brandName
+ }
+}
diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx
new file mode 100644
index 000000000..354a50826
--- /dev/null
+++ b/apps/sim/components/emails/support/help-confirmation-email.tsx
@@ -0,0 +1,58 @@
+import { Text } from '@react-email/components'
+import { format } from 'date-fns'
+import { baseStyles } from '@/components/emails/_styles'
+import { EmailLayout } from '@/components/emails/components'
+
+interface HelpConfirmationEmailProps {
+ type?: 'bug' | 'feedback' | 'feature_request' | 'other'
+ attachmentCount?: number
+ submittedDate?: Date
+}
+
+const getTypeLabel = (type: string) => {
+ switch (type) {
+ case 'bug':
+ return 'Bug Report'
+ case 'feedback':
+ return 'Feedback'
+ case 'feature_request':
+ return 'Feature Request'
+ case 'other':
+ return 'General Inquiry'
+ default:
+ return 'Request'
+ }
+}
+
+export function HelpConfirmationEmail({
+ type = 'other',
+ attachmentCount = 0,
+ submittedDate = new Date(),
+}: HelpConfirmationEmailProps) {
+ const typeLabel = getTypeLabel(type)
+
+ return (
+
+ Hello,
+
+ We've received your {typeLabel.toLowerCase()} and will get back to you
+ shortly.
+
+
+ {attachmentCount > 0 && (
+
+ {attachmentCount} image{attachmentCount > 1 ? 's' : ''} attached.
+
+ )}
+
+ {/* Divider */}
+
+
+
+ Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
+
+
+ )
+}
+
+export default HelpConfirmationEmail
diff --git a/apps/sim/components/emails/support/index.ts b/apps/sim/components/emails/support/index.ts
new file mode 100644
index 000000000..5bea3567b
--- /dev/null
+++ b/apps/sim/components/emails/support/index.ts
@@ -0,0 +1 @@
+export { HelpConfirmationEmail } from './help-confirmation-email'
diff --git a/apps/sim/components/emails/workspace-invitation.tsx b/apps/sim/components/emails/workspace-invitation.tsx
deleted file mode 100644
index dfad9f88a..000000000
--- a/apps/sim/components/emails/workspace-invitation.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import {
- Body,
- Column,
- Container,
- Head,
- Html,
- Img,
- Link,
- Preview,
- Row,
- Section,
- Text,
-} from '@react-email/components'
-import { createLogger } from '@sim/logger'
-import { baseStyles } from '@/components/emails/base-styles'
-import EmailFooter from '@/components/emails/footer'
-import { getBrandConfig } from '@/lib/branding/branding'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-
-const logger = createLogger('WorkspaceInvitationEmail')
-
-interface WorkspaceInvitationEmailProps {
- workspaceName?: string
- inviterName?: string
- invitationLink?: string
-}
-
-export const WorkspaceInvitationEmail = ({
- workspaceName = 'Workspace',
- inviterName = 'Someone',
- invitationLink = '',
-}: WorkspaceInvitationEmailProps) => {
- const brand = getBrandConfig()
- const baseUrl = getBaseUrl()
-
- // Extract token from the link to ensure we're using the correct format
- let enhancedLink = invitationLink
-
- try {
- // If the link is pointing to any API endpoint directly, update it to use the client route
- if (
- invitationLink.includes('/api/workspaces/invitations/accept') ||
- invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/)
- ) {
- const url = new URL(invitationLink)
- const token = url.searchParams.get('token')
- if (token) {
- enhancedLink = `${baseUrl}/invite/${token}?token=${token}`
- }
- }
- } catch (e) {
- logger.error('Error enhancing invitation link:', e)
- }
-
- return (
-
-
-
- You've been invited to join the "{workspaceName}" workspace on Sim!
-
-
-
-
-
-
-
-
-
-
-
-
- Hello,
-
- {inviterName} has invited you to join the "{workspaceName}" workspace on Sim!
-
-
- Sim is a powerful platform for building, testing, and optimizing AI workflows. Join
- this workspace to collaborate with your team.
-
-
- Accept Invitation
-
-
- This invitation link will expire in 7 days. If you have any questions or need
- assistance, feel free to reach out to our support team.
-
-
- Best regards,
-
- The Sim Team
-
-
-
-
-
-
-
- )
-}
-
-export default WorkspaceInvitationEmail
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 2708aa52a..3922c4f0b 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -21,7 +21,8 @@ import {
getEmailSubject,
renderOTPEmail,
renderPasswordResetEmail,
-} from '@/components/emails/render-email'
+ renderWelcomeEmail,
+} from '@/components/emails'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { handleNewUser } from '@/lib/billing/core/usage'
@@ -47,19 +48,18 @@ import {
isAuthDisabled,
isBillingEnabled,
isEmailVerificationEnabled,
+ isHosted,
isRegistrationDisabled,
} from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
-import { getFromEmailAddress } from '@/lib/messaging/email/utils'
+import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth')
-// Only initialize Stripe if the key is provided
-// This allows local development without a Stripe account
const validStripeKey = env.STRIPE_SECRET_KEY
let stripeClient = null
@@ -104,6 +104,31 @@ export const auth = betterAuth({
error,
})
}
+
+ if (isHosted && user.email && user.emailVerified) {
+ try {
+ const html = await renderWelcomeEmail(user.name || undefined)
+ const { from, replyTo } = getPersonalEmailFrom()
+
+ await sendEmail({
+ to: user.email,
+ subject: getEmailSubject('welcome'),
+ html,
+ from,
+ replyTo,
+ emailType: 'transactional',
+ })
+
+ logger.info('[databaseHooks.user.create.after] Welcome email sent to OAuth user', {
+ userId: user.id,
+ })
+ } catch (error) {
+ logger.error('[databaseHooks.user.create.after] Failed to send welcome email', {
+ userId: user.id,
+ error,
+ })
+ }
+ }
},
},
},
@@ -294,6 +319,35 @@ export const auth = betterAuth({
],
},
},
+ emailVerification: {
+ autoSignInAfterVerification: true,
+ afterEmailVerification: async (user) => {
+ if (isHosted && user.email) {
+ try {
+ const html = await renderWelcomeEmail(user.name || undefined)
+ const { from, replyTo } = getPersonalEmailFrom()
+
+ await sendEmail({
+ to: user.email,
+ subject: getEmailSubject('welcome'),
+ html,
+ from,
+ replyTo,
+ emailType: 'transactional',
+ })
+
+ logger.info('[emailVerification.afterEmailVerification] Welcome email sent', {
+ userId: user.id,
+ })
+ } catch (error) {
+ logger.error('[emailVerification.afterEmailVerification] Failed to send welcome email', {
+ userId: user.id,
+ error,
+ })
+ }
+ }
+ },
+ },
emailAndPassword: {
enabled: true,
requireEmailVerification: isEmailVerificationEnabled,
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index 6ed490627..9b5f4047b 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -298,9 +298,7 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise {
.limit(1)
if (users.length > 0 && users[0].email) {
- const { getEmailSubject, renderPlanWelcomeEmail } = await import(
- '@/components/emails/render-email'
- )
+ const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails')
const { sendEmail } = await import('@/lib/messaging/email/mailer')
const baseUrl = getBaseUrl()
diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts
index 448351b70..f0c1f4b78 100644
--- a/apps/sim/lib/billing/core/usage.ts
+++ b/apps/sim/lib/billing/core/usage.ts
@@ -6,7 +6,7 @@ import {
getEmailSubject,
renderFreeTierUpgradeEmail,
renderUsageThresholdEmail,
-} from '@/components/emails/render-email'
+} from '@/components/emails'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
canEditUsageLimit,
diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts
index 83ddcb457..cf20b52b3 100644
--- a/apps/sim/lib/billing/webhooks/enterprise.ts
+++ b/apps/sim/lib/billing/webhooks/enterprise.ts
@@ -3,10 +3,7 @@ import { organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
-import {
- getEmailSubject,
- renderEnterpriseSubscriptionEmail,
-} from '@/components/emails/render-email'
+import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import type { EnterpriseSubscriptionMetadata } from '../types'
@@ -208,7 +205,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const user = userDetails[0]
const org = orgDetails[0]
- const html = await renderEnterpriseSubscriptionEmail(user.name || user.email, user.email)
+ const html = await renderEnterpriseSubscriptionEmail(user.name || user.email)
const emailResult = await sendEmail({
to: user.email,
diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts
index a3cafeb6a..e06bbb8fe 100644
--- a/apps/sim/lib/billing/webhooks/invoices.ts
+++ b/apps/sim/lib/billing/webhooks/invoices.ts
@@ -10,14 +10,14 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe'
-import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email'
-import { getEmailSubject, renderCreditPurchaseEmail } from '@/components/emails/render-email'
+import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
+import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
const logger = createLogger('StripeInvoiceWebhooks')
@@ -139,6 +139,7 @@ async function getPaymentMethodDetails(
/**
* Send payment failure notification emails to affected users
+ * Note: This is only called when billing is enabled (Stripe plugin loaded)
*/
async function sendPaymentFailureEmails(
sub: { plan: string | null; referenceId: string },
@@ -203,10 +204,13 @@ async function sendPaymentFailureEmails(
})
)
+ const { from, replyTo } = getPersonalEmailFrom()
await sendEmail({
to: userToNotify.email,
subject: 'Payment Failed - Action Required',
html: emailHtml,
+ from,
+ replyTo,
emailType: 'transactional',
})
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index dc627c01a..b42ae407a 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -60,6 +60,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_ENABLED: z.boolean().optional(), // Enable email verification for user registration and login (defaults to false)
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim " or "noreply@domain.com")
+ PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
diff --git a/apps/sim/lib/messaging/email/utils.ts b/apps/sim/lib/messaging/email/utils.ts
index 4b342c5d4..2c26737e1 100644
--- a/apps/sim/lib/messaging/email/utils.ts
+++ b/apps/sim/lib/messaging/email/utils.ts
@@ -11,3 +11,34 @@ export function getFromEmailAddress(): string {
// Fallback to constructing from EMAIL_DOMAIN
return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
}
+
+/**
+ * Extract the email address from a "Name " formatted string"
+ */
+export function extractEmailFromAddress(fromAddress: string): string | undefined {
+ const match = fromAddress.match(/<([^>]+)>/)
+ if (match) {
+ return match[1]
+ }
+ if (fromAddress.includes('@') && !fromAddress.includes('<')) {
+ return fromAddress.trim()
+ }
+ return undefined
+}
+
+/**
+ * Get the personal email from address and reply-to
+ */
+export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } {
+ const personalFrom = env.PERSONAL_EMAIL_FROM
+ if (personalFrom) {
+ return {
+ from: personalFrom,
+ replyTo: extractEmailFromAddress(personalFrom),
+ }
+ }
+ return {
+ from: getFromEmailAddress(),
+ replyTo: undefined,
+ }
+}
diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts
index eea96cd00..7377b8901 100644
--- a/apps/sim/lib/uploads/providers/blob/client.test.ts
+++ b/apps/sim/lib/uploads/providers/blob/client.test.ts
@@ -54,15 +54,16 @@ describe('Azure Blob Storage Client', () => {
toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test',
})
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
AZURE_ACCOUNT_NAME: 'testaccount',
AZURE_ACCOUNT_KEY: 'testkey',
AZURE_CONNECTION_STRING:
'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net',
AZURE_STORAGE_CONTAINER_NAME: 'testcontainer',
- },
- }))
+ })
+ })
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({
diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts
index 8ea6321e7..b6ba4c1ad 100644
--- a/apps/sim/lib/uploads/providers/s3/client.test.ts
+++ b/apps/sim/lib/uploads/providers/s3/client.test.ts
@@ -31,14 +31,15 @@ describe('S3 Client', () => {
getSignedUrl: mockGetSignedUrl,
}))
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: 'test-access-key',
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
- },
- }))
+ })
+ })
vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({
@@ -298,14 +299,15 @@ describe('S3 Client', () => {
describe('s3Client initialization', () => {
it('should initialize with correct configuration when credentials are available', async () => {
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: 'test-access-key',
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
- },
- }))
+ })
+ })
vi.doMock('@/lib/uploads/setup', () => ({
S3_CONFIG: {
@@ -331,14 +333,15 @@ describe('S3 Client', () => {
})
it('should initialize without credentials when env vars are not available', async () => {
- vi.doMock('@/lib/core/config/env', () => ({
- env: {
+ vi.doMock('@/lib/core/config/env', async () => {
+ const { createEnvMock } = await import('@sim/testing')
+ return createEnvMock({
S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
- },
- }))
+ })
+ })
vi.doMock('@/lib/uploads/setup', () => ({
S3_CONFIG: {
diff --git a/apps/sim/public/brand/color/email/type.png b/apps/sim/public/brand/color/email/type.png
new file mode 100644
index 000000000..ec148e331
Binary files /dev/null and b/apps/sim/public/brand/color/email/type.png differ
diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts
index cd57ad5e7..816fd496c 100644
--- a/packages/testing/src/index.ts
+++ b/packages/testing/src/index.ts
@@ -45,14 +45,18 @@ export * from './assertions'
export * from './builders'
export * from './factories'
export {
+ createEnvMock,
createMockDb,
createMockFetch,
+ createMockGetEnv,
createMockLogger,
createMockResponse,
createMockSocket,
createMockStorage,
databaseMock,
+ defaultMockEnv,
drizzleOrmMock,
+ envMock,
loggerMock,
type MockFetchResponse,
setupGlobalFetchMock,
diff --git a/packages/testing/src/mocks/env.mock.ts b/packages/testing/src/mocks/env.mock.ts
new file mode 100644
index 000000000..f216721af
--- /dev/null
+++ b/packages/testing/src/mocks/env.mock.ts
@@ -0,0 +1,67 @@
+import { vi } from 'vitest'
+
+/**
+ * Default mock environment values for testing
+ */
+export const defaultMockEnv = {
+ // Core
+ DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
+ BETTER_AUTH_URL: 'https://test.sim.ai',
+ BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-chars-long',
+ ENCRYPTION_KEY: 'test-encryption-key-32-chars-long!',
+ INTERNAL_API_SECRET: 'test-internal-api-secret-32-chars!',
+
+ // Email
+ RESEND_API_KEY: 'test-resend-key',
+ FROM_EMAIL_ADDRESS: 'Sim ',
+ EMAIL_DOMAIN: 'test.sim.ai',
+ PERSONAL_EMAIL_FROM: 'Test ',
+
+ // URLs
+ NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
+}
+
+/**
+ * Creates a mock getEnv function that returns values from the provided env object
+ */
+export function createMockGetEnv(envValues: Record = defaultMockEnv) {
+ return vi.fn((key: string) => envValues[key])
+}
+
+/**
+ * Creates a complete env mock object for use with vi.doMock
+ *
+ * @example
+ * ```ts
+ * vi.doMock('@/lib/core/config/env', () => createEnvMock())
+ *
+ * // With custom values
+ * vi.doMock('@/lib/core/config/env', () => createEnvMock({
+ * NEXT_PUBLIC_APP_URL: 'https://custom.example.com',
+ * }))
+ * ```
+ */
+export function createEnvMock(overrides: Record = {}) {
+ const envValues = { ...defaultMockEnv, ...overrides }
+
+ return {
+ env: envValues,
+ getEnv: createMockGetEnv(envValues),
+ isTruthy: (value: string | boolean | number | undefined) =>
+ typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
+ isFalsy: (value: string | boolean | number | undefined) =>
+ typeof value === 'string'
+ ? value.toLowerCase() === 'false' || value === '0'
+ : value === false,
+ }
+}
+
+/**
+ * Pre-configured env mock for direct use with vi.mock
+ *
+ * @example
+ * ```ts
+ * vi.mock('@/lib/core/config/env', () => envMock)
+ * ```
+ */
+export const envMock = createEnvMock()
diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts
index b08e250a5..52eec208c 100644
--- a/packages/testing/src/mocks/index.ts
+++ b/packages/testing/src/mocks/index.ts
@@ -24,6 +24,8 @@ export {
databaseMock,
drizzleOrmMock,
} from './database.mock'
+// Env mocks
+export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock'
// Fetch mocks
export {
createMockFetch,