feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2026-01-02 00:16:27 -08:00
committed by GitHub
parent 852562cfdd
commit 79be435918
62 changed files with 2187 additions and 2133 deletions

View File

@@ -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'

View File

@@ -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({

View File

@@ -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'

View File

@@ -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',

View File

@@ -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(() => {

View File

@@ -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(() => {

View File

@@ -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]) => `
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #32bd7e; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
</ul>
`
)
.join('')
return new NextResponse(
`<!DOCTYPE html>
<html>
<head>
<title>Email Previews</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
h1 { color: #333; margin-bottom: 32px; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Email Templates</h1>
${categoryHtml}
</body>
</html>`,
{ 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' },
})
}

View File

@@ -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
)

View File

@@ -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({

View File

@@ -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({

View File

@@ -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'

View File

@@ -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 <noreply@test.sim.ai>',
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'),

View File

@@ -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'

View File

@@ -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',
},
}

View File

@@ -0,0 +1 @@
export { baseStyles, colors, spacing, typography } from './base'

View File

@@ -0,0 +1,3 @@
export { OTPVerificationEmail } from './otp-verification-email'
export { ResetPasswordEmail } from './reset-password-email'
export { WelcomeEmail } from './welcome-email'

View File

@@ -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 (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Do not share this code with anyone. If you didn't request this code, you can safely ignore
this email.
</Text>
</EmailLayout>
)
}
export default OTPVerificationEmail

View File

@@ -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 (
<EmailLayout preview={`Reset your ${brand.name} password`}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
A password reset was requested for your {brand.name} account. Click below to set a new
password.
</Text>
<Link href={resetLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Reset Password</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If you didn't request this, you can ignore this email. Link expires in 24 hours.
</Text>
</EmailLayout>
)
}
export default ResetPasswordEmail

View File

@@ -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 (
<EmailLayout preview={`Welcome to ${brand.name}`}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hey ${userName},` : 'Hey,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI
workflows in minutes.
</Text>
<Link href={`${baseUrl}/w`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Get Started</Text>
</Link>
<Text style={baseStyles.paragraph}>
If you have any questions or feedback, just reply to this email. I read every message!
</Text>
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You're on the free plan with $10 in credits to get started.
</Text>
</EmailLayout>
)
}
export default WelcomeEmail

View File

@@ -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',
},
}

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>
You've been invited to join {organizationName}
{hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}
</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join{' '}
<strong>{organizationName}</strong> on Sim.
</Text>
{/* Team Role Information */}
<Text style={baseStyles.paragraph}>
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
</Text>
<Text style={baseStyles.paragraph}>
{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."}
</Text>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
</Text>
{workspaceInvitations.map((ws) => (
<Text
key={ws.workspaceId}
style={{ ...baseStyles.paragraph, marginLeft: '20px' }}
>
• <strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<Text style={baseStyles.paragraph}>
By accepting this invitation, you'll join {organizationName}
{hasWorkspaces
? ` and gain access to ${workspaceInvitations.length} workspace(s)`
: ''}
.
</Text>
<Text style={baseStyles.paragraph}>
This invitation will expire in 7 days. If you didn't expect this invitation, you can
safely ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default BatchInvitationEmail

View File

@@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.infoBox}>
<Text
style={{
margin: 0,
fontSize: '14px',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Amount Added
</Text>
<Text
style={{
margin: '4px 0 16px',
fontSize: '24px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
${amount.toFixed(2)}
</Text>
<Text
style={{
margin: 0,
fontSize: '14px',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
New Balance
</Text>
<Text
style={{
margin: '4px 0 0',
fontSize: '24px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
${newBalance.toFixed(2)}
</Text>
</Section>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
</Text>
<Text style={baseStyles.paragraph}>
Credits are applied automatically to your workflow executions.
</Text>
<Section
style={{
background: '#f4f4f5',
borderRadius: '8px',
padding: '16px',
margin: '24px 0',
}}
>
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>Amount Added</Text>
<Text style={{ margin: '4px 0 16px', fontSize: '24px', fontWeight: 'bold' }}>
${amount.toFixed(2)}
</Text>
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>New Balance</Text>
<Text style={{ margin: '4px 0 0', fontSize: '24px', fontWeight: 'bold' }}>
${newBalance.toFixed(2)}
</Text>
</Section>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
<Text style={baseStyles.paragraph}>
These credits will be applied automatically to your workflow executions. Credits are
consumed before any overage charges apply.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
<Hr />
<Text style={baseStyles.paragraph}>
You can view your credit balance and purchase history in Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Purchased on {purchaseDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings Subscription.
</Text>
</EmailLayout>
)
}

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your Enterprise Plan is now active on Sim</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
features and increased capacity for your workflows.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now
have access to advanced features and increased capacity for your workflows.
</Text>
<Text style={baseStyles.paragraph}>
<strong>Next steps:</strong>
<br /> Invite team members to your organization
<br /> Start building your workflows
</Text>
<Text style={baseStyles.paragraph}>
Your account has been set up with full access to your organization. Click below to log
in and start exploring your new Enterprise features:
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Access Your Enterprise Account</Text>
</Link>
<Text style={baseStyles.paragraph}>
<strong>What's next?</strong>
</Text>
<Text style={baseStyles.paragraph}>
• Invite team members to your organization
<br />• Begin building your workflows
</Text>
<Text style={baseStyles.paragraph}>
If you have any questions or need assistance getting started, our support team is here
to help.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
regarding your Enterprise plan activation on Sim.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Questions? Reply to this email or contact us at{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}

View File

@@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%). Upgrade to Pro to keep
building without interruption.
</Text>
{/* Pro Features */}
<Section
style={{
backgroundColor: '#f8faf9',
border: `1px solid ${colors.brandTertiary}20`,
borderRadius: '8px',
padding: '16px 20px',
margin: '16px 0',
}}
>
<Text
style={{
fontSize: '14px',
fontWeight: 600,
color: colors.brandTertiary,
fontFamily: typography.fontFamily,
margin: '0 0 12px 0',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
}}
>
Pro includes
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{proFeatures.map((feature, i) => (
<tr key={i}>
<td
style={{
margin: '0 auto',
padding: '6px 0',
fontSize: '15px',
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
width: '45%',
}}
/>
</Column>
</Row>
</Section>
>
{feature.label}
</td>
<td
style={{
padding: '6px 0',
fontSize: '14px',
color: colors.textMuted,
fontFamily: typography.fontFamily,
}}
>
{feature.desc}
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%).
</Text>
<Text style={baseStyles.paragraph}>
To ensure uninterrupted service and unlock the full power of {brand.name}, upgrade to
Pro today.
</Text>
<Section
style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '5px',
margin: '20px 0',
}}
>
<Text
style={{
...baseStyles.paragraph,
marginTop: 0,
marginBottom: 12,
fontWeight: 'bold',
}}
>
What you get with Pro:
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '8px 0', lineHeight: 1.6 }}>
• <strong>$20/month in credits</strong> 2x your free tier
<br />• <strong>Priority support</strong> Get help when you need it
<br />• <strong>Advanced features</strong> Access to premium blocks and
integrations
<br />• <strong>No interruptions</strong> Never worry about running out of credits
</Text>
</Section>
<Hr />
<Text style={baseStyles.paragraph}>Upgrade now to keep building without limits.</Text>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<Text style={baseStyles.paragraph}>
Questions? We're here to help.
<br />
<br />
Best regards,
<br />
The {brand.name} Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {updatedDate.toLocaleDateString()} This is a one-time notification at 90%.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 90% usage.
</Text>
</EmailLayout>
)
}

View File

@@ -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'

View File

@@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text
style={{
...baseStyles.paragraph,
fontSize: '16px',
fontWeight: 600,
color: colors.textPrimary,
}}
>
We were unable to process your payment.
</Text>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your {brand.name} account has been temporarily blocked to prevent service interruptions and
unexpected charges. To restore access immediately, please update your payment method.
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
We were unable to process your payment.
</Text>
<Section
style={{
backgroundColor: '#fff5f5',
border: '1px solid #fed7d7',
borderRadius: '6px',
padding: '16px 18px',
margin: '16px 0',
}}
>
<Text
style={{
...baseStyles.paragraph,
marginBottom: 8,
marginTop: 0,
fontWeight: 'bold',
}}
>
Payment Details
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Amount due: ${amountDue.toFixed(2)}
</Text>
{lastFourDigits && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Payment method: {lastFourDigits}
</Text>
)}
{failureReason && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>Reason: {failureReason}</Text>
)}
</Section>
<Text style={baseStyles.paragraph}>
Your {brand.name} account has been temporarily blocked to prevent service
interruptions and unexpected charges. To restore access immediately, please update
your payment method.
</Text>
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Update Payment Method</Text>
</Link>
<Section
style={{
backgroundColor: '#fff5f5',
border: '1px solid #fed7d7',
borderRadius: '5px',
padding: '16px',
margin: '20px 0',
}}
>
<Row>
<Column>
<Text style={{ ...baseStyles.paragraph, marginBottom: 8, marginTop: 0 }}>
<strong>Payment Details</strong>
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Amount due: ${amountDue.toFixed(2)}
</Text>
{lastFourDigits && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Payment method: {lastFourDigits}
</Text>
)}
{failureReason && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Reason: {failureReason}
</Text>
)}
</Column>
</Row>
</Section>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Update Payment Method</Text>
</Link>
<Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
<Hr />
<Text style={baseStyles.paragraph}>
Your workflows and automations are currently paused
<br /> Update your payment method to restore service immediately
<br /> Stripe will automatically retry the charge once payment is updated
</Text>
<Text style={baseStyles.paragraph}>
<strong>What happens next?</strong>
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
Your workflows and automations are currently paused
<br /> Update your payment method to restore service immediately
<br /> Stripe will automatically retry the charge once payment is updated
</Text>
<Hr />
<Text style={baseStyles.paragraph}>
<strong>Need help?</strong>
</Text>
<Text style={baseStyles.paragraph}>
Common reasons for payment failures include expired cards, insufficient funds, or
incorrect billing information. If you continue to experience issues, please{' '}
<Link href={`${baseUrl}/support`} style={baseStyles.link}>
contact our support team
</Link>
.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {sentDate.toLocaleDateString()} This is a critical transactional
notification.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}

View File

@@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
workflows.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to
build, test, and scale your agentic workflows.
</Text>
<Text style={baseStyles.paragraph}>
Want help getting started?{' '}
<Link href='https://cal.com/emirkarabeg/sim-team' style={baseStyles.link}>
Schedule a call
</Link>{' '}
with our team.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
Want to discuss your plan or get personalized help getting started?{' '}
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}>
Schedule a 15-minute call
</Link>{' '}
with our team.
</Text>
<Hr />
<Text style={baseStyles.paragraph}>
Need to invite teammates, adjust usage limits, or manage billing? You can do that from
Settings Subscription.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {createdDate.toLocaleDateString()}
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Manage your subscription in Settings Subscription.
</Text>
</EmailLayout>
)
}

View File

@@ -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 (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={baseStyles.main}>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={previewText}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Section>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Section>
<Row>
<Column>
<Text style={{ ...baseStyles.paragraph, marginBottom: 8 }}>
<strong>Usage</strong>
</Text>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Column>
</Row>
</Section>
<Text style={baseStyles.paragraph}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
<Hr />
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review Limits</Text>
</Link>
<Text style={{ ...baseStyles.paragraph }}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review limits</Text>
</Link>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
Sent on {updatedDate.toLocaleDateString()} This is a one-time notification at 80%.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
}

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your application to {brand.name} has been received</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
<Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}>
We've received your application for <strong>{position}</strong>. Our team reviews every
application and will reach out if there's a match.
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text style={baseStyles.paragraph}>
In the meantime, explore our{' '}
<a
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
style={baseStyles.link}
>
docs
</a>{' '}
or{' '}
<a href={`${baseUrl}/studio`} style={baseStyles.link}>
blog
</a>{' '}
to learn more about what we're building.
</Text>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}>
Thank you for your interest in joining the {brand.name} team! We've received your
application for the <strong>{position}</strong> position.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
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.
</Text>
<Text style={baseStyles.paragraph}>
In the meantime, feel free to explore our{' '}
<a
href='https://docs.sim.ai'
target='_blank'
rel='noopener noreferrer'
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
documentation
</a>{' '}
to learn more about what we're building, or check out our{' '}
<a href={`${baseUrl}/studio`} style={{ color: '#6F3DFA', textDecoration: 'none' }}>
blog
</a>{' '}
for the latest updates.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The {brand.name} Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
{format(submittedDate, 'h:mm a')}.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
</Text>
</EmailLayout>
)
}

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>New Career Application from {name}</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
<Text
style={{
...baseStyles.paragraph,
fontSize: '18px',
fontWeight: 'bold',
color: colors.textPrimary,
}}
>
New Career Application
</Text>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Text style={baseStyles.paragraph}>
A new career application has been submitted on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
{format(submittedDate, 'h:mm a')}.
</Text>
<Section style={baseStyles.content}>
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
New Career Application
</Text>
{/* Applicant Information */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: colors.bgOuter,
borderRadius: '8px',
border: `1px solid ${colors.divider}`,
}}
>
<Text
style={{
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
Applicant Information
</Text>
<Text style={baseStyles.paragraph}>
A new career application has been submitted on{' '}
{format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}.
</Text>
{/* Applicant Information */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #e5e5e5',
}}
>
<Text
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td
style={{
margin: '0 0 16px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333333',
}}
>
Applicant Information
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
width: '40%',
}}
>
Name:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>{name}</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Email:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a
href={`mailto:${email}`}
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
{email}
</a>
</td>
</tr>
{phone && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Phone:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a href={`tel:${phone}`} style={{ color: '#6F3DFA', textDecoration: 'none' }}>
{phone}
</a>
</td>
</tr>
)}
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Position:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
{position}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Experience:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
{getExperienceLabel(experience)}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Location:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
{location}
</td>
</tr>
{linkedin && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
LinkedIn:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a
href={linkedin}
target='_blank'
rel='noopener noreferrer'
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
View Profile
</a>
</td>
</tr>
)}
{portfolio && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: '#666666',
}}
>
Portfolio:
</td>
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
<a
href={portfolio}
target='_blank'
rel='noopener noreferrer'
style={{ color: '#6F3DFA', textDecoration: 'none' }}
>
View Portfolio
</a>
</td>
</tr>
)}
</table>
</Section>
{/* Message */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #e5e5e5',
}}
>
<Text
style={{
margin: '0 0 12px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333333',
}}
>
About Themselves
</Text>
<Text
style={{
margin: '0',
padding: '8px 0',
fontSize: '14px',
color: '#333333',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
fontWeight: 'bold',
color: colors.textMuted,
width: '40%',
fontFamily: baseStyles.fontFamily,
}}
>
{message}
</Text>
</Section>
Name:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{name}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Email:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a href={`mailto:${email}`} style={baseStyles.link}>
{email}
</a>
</td>
</tr>
{phone && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Phone:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a href={`tel:${phone}`} style={baseStyles.link}>
{phone}
</a>
</td>
</tr>
)}
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Position:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{position}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Experience:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{getExperienceLabel(experience)}
</td>
</tr>
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Location:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
{location}
</td>
</tr>
{linkedin && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
LinkedIn:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a
href={linkedin}
target='_blank'
rel='noopener noreferrer'
style={baseStyles.link}
>
View Profile
</a>
</td>
</tr>
)}
{portfolio && (
<tr>
<td
style={{
padding: '8px 0',
fontSize: '14px',
fontWeight: 'bold',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Portfolio:
</td>
<td
style={{
padding: '8px 0',
fontSize: '14px',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<a
href={portfolio}
target='_blank'
rel='noopener noreferrer'
style={baseStyles.link}
>
View Portfolio
</a>
</td>
</tr>
)}
</tbody>
</table>
</Section>
<Text style={baseStyles.paragraph}>
Please review this application and reach out to the candidate at your earliest
convenience.
</Text>
</Section>
</Container>
</Body>
</Html>
{/* Message */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: colors.bgOuter,
borderRadius: '8px',
border: `1px solid ${colors.divider}`,
}}
>
<Text
style={{
margin: '0 0 12px 0',
fontSize: '16px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
About Themselves
</Text>
<Text
style={{
margin: '0',
fontSize: '14px',
color: colors.textPrimary,
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
fontFamily: baseStyles.fontFamily,
}}
>
{message}
</Text>
</Section>
</EmailLayout>
)
}

View File

@@ -0,0 +1,2 @@
export { CareersConfirmationEmail } from './careers-confirmation-email'
export { CareersSubmissionEmail } from './careers-submission-email'

View File

@@ -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 (
<Section
style={{
backgroundColor: colors.footerBg,
width: '100%',
}}
>
<Container style={{ maxWidth: `${spacing.containerWidth}px`, margin: '0 auto' }}>
<table
cellPadding={0}
cellSpacing={0}
border={0}
width='100%'
style={{ minWidth: `${spacing.containerWidth}px` }}
>
<tbody>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
{/* Social links row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td>
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
<tbody>
<tr>
<td align='left' style={{ padding: '0 8px 0 0' }}>
<Link href='https://x.com/simdotai' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/x-icon.png`}
width='20'
height='20'
alt='X'
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href='https://discord.gg/Hr4UWYEcTT' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/discord-icon.png`}
width='20'
height='20'
alt='Discord'
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href='https://github.com/simstudioai/sim' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/github-icon.png`}
width='20'
height='20'
alt='GitHub'
/>
</Link>
</td>
</tr>
</tbody>
</table>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
{/* Address row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
{brand.name}
{isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA</>}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
{/* Contact row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Questions?{' '}
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
{brand.supportEmail}
</a>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
{/* Message ID row (optional) */}
{messageId && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Need to refer to this message? Use this ID: {messageId}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
</>
)}
{/* Links row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
<a href={`${baseUrl}/privacy`} style={footerLinkStyle} rel='noopener noreferrer'>
Privacy Policy
</a>{' '}
{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
{/* Copyright row */}
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
</tbody>
</table>
</Container>
</Section>
)
}
export default EmailFooter

View File

@@ -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 (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={baseStyles.main}>
{/* Main card container */}
<Container style={baseStyles.container}>
{/* Header with logo */}
<Section style={baseStyles.header}>
<Img
src={brand.logoUrl || `${baseUrl}/brand/color/email/type.png`}
width='70'
alt={brand.name}
style={{ display: 'block' }}
/>
</Section>
{/* Content */}
<Section style={baseStyles.content}>{children}</Section>
</Container>
{/* Footer in gray section */}
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
</Body>
</Html>
)
}
export default EmailLayout

View File

@@ -0,0 +1,2 @@
export { EmailFooter } from './email-footer'
export { EmailLayout } from './email-layout'

View File

@@ -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 (
<Container>
<Section style={{ maxWidth: '580px', margin: '0 auto', padding: '20px 0' }}>
<table style={{ width: '100%' }}>
<tr>
<td align='center'>
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
<tr>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://x.com/simdotai' rel='noopener noreferrer'>
<Img src={`${baseUrl}/static/x-icon.png`} width='24' height='24' alt='X' />
</Link>
</td>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://discord.gg/Hr4UWYEcTT' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/discord-icon.png`}
width='24'
height='24'
alt='Discord'
/>
</Link>
</td>
<td align='center' style={{ padding: '0 8px' }}>
<Link href='https://github.com/simstudioai/sim' rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/github-icon.png`}
width='24'
height='24'
alt='GitHub'
/>
</Link>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align='center' style={{ paddingTop: '12px' }}>
<Text
style={{
fontSize: '12px',
color: '#706a7b',
margin: '8px 0 0 0',
}}
>
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
<br />
If you have any questions, please contact us at{' '}
<a
href={`mailto:${brand.supportEmail}`}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
>
{brand.supportEmail}
</a>
{isHosted && (
<>
<br />
Sim, 80 Langton St, San Francisco, CA 94133, USA
</>
)}
</Text>
<table cellPadding={0} cellSpacing={0} style={{ width: '100%', marginTop: '4px' }}>
<tr>
<td align='center'>
<p
style={{
fontSize: '12px',
color: '#706a7b',
margin: '8px 0 0 0',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
>
<a
href={`${baseUrl}/privacy`}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Privacy Policy
</a>{' '}
{' '}
<a
href={`${baseUrl}/terms`}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</Section>
</Container>
)
}
export default EmailFooter

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Your {typeLabel.toLowerCase()} has been received</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
Thank you for your <strong>{typeLabel.toLowerCase()}</strong> submission. We've
received your request and will get back to you as soon as possible.
</Text>
{attachmentCount > 0 && (
<Text style={baseStyles.paragraph}>
You attached{' '}
<strong>
{attachmentCount} image{attachmentCount > 1 ? 's' : ''}
</strong>{' '}
with your request.
</Text>
)}
<Text style={baseStyles.paragraph}>
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.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The {brand.name} Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} for your{' '}
{typeLabel.toLowerCase()} submission from {userEmail}.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default HelpConfirmationEmail

View File

@@ -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'

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>You've been invited to join {organizationName} on Sim</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join{' '}
<strong>{organizationName}</strong> on Sim. Sim is a powerful, user-friendly platform
for building, testing, and optimizing agentic workflows.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<Text style={baseStyles.paragraph}>
This invitation will expire in 48 hours. If you believe this invitation was sent in
error, please ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} to {invitedEmail} with
an invitation to join {organizationName} on Sim.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default InvitationEmail

View File

@@ -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 (
<EmailLayout
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
on {brand.name}.
</Text>
{/* Team Role Information */}
<Text style={baseStyles.paragraph}>
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
</Text>
<Text style={baseStyles.paragraph}>
{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."}
</Text>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
</Text>
{workspaceInvitations.map((ws) => (
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
<strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default BatchInvitationEmail

View File

@@ -0,0 +1,3 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'

View File

@@ -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 (
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
{brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 48 hours. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default InvitationEmail

View File

@@ -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 (
<EmailLayout
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join the <strong>{workspaceName}</strong>{' '}
workspace on {brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default WorkspaceInvitationEmail

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>{getSubjectByType(type, brand.name, chatTitle)}</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>{getMessage()}</Text>
<Text style={baseStyles.paragraph}>Your verification code is:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
<Text style={baseStyles.paragraph}>
If you didn't request this code, you can safely ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default OTPVerificationEmail

View File

@@ -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<string> {
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<string> {
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<string> {
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<string> {
export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
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<string> {
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<string> {
return await render(
WorkspaceInvitationEmail({
inviterName,
workspaceName,
invitationLink,
})
)
}
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number
lastFourDigits?: string
billingPortalUrl: string
failureReason?: string
}): Promise<string> {
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<string> {
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<string> {
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,
})
)
}

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Reset your {brand.name} password</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
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.
</Text>
<Link href={resetLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Reset Your Password</Text>
</Link>
<Text style={baseStyles.paragraph}>
If you did not request a password reset, please ignore this email or contact support
if you have concerns.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The {brand.name} Team
</Text>
<Text
style={{
...baseStyles.footerText,
marginTop: '40px',
textAlign: 'left',
color: '#666666',
}}
>
This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset
was requested for your account.
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default ResetPasswordEmail

View File

@@ -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
}
}

View File

@@ -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 (
<EmailLayout preview={`Your ${typeLabel.toLowerCase()} has been received`}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
shortly.
</Text>
{attachmentCount > 0 && (
<Text style={baseStyles.paragraph}>
{attachmentCount} image{attachmentCount > 1 ? 's' : ''} attached.
</Text>
)}
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
</Text>
</EmailLayout>
)
}
export default HelpConfirmationEmail

View File

@@ -0,0 +1 @@
export { HelpConfirmationEmail } from './help-confirmation-email'

View File

@@ -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 (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>You've been invited to join the "{workspaceName}" workspace on Sim!</Preview>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
<Row>
<Column style={{ textAlign: 'center' }}>
<Img
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
width='114'
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
{inviterName} has invited you to join the "{workspaceName}" workspace on Sim!
</Text>
<Text style={baseStyles.paragraph}>
Sim is a powerful platform for building, testing, and optimizing AI workflows. Join
this workspace to collaborate with your team.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<Text style={baseStyles.paragraph}>
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.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Team
</Text>
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
</Body>
</Html>
)
}
export default WorkspaceInvitationEmail

View File

@@ -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,

View File

@@ -298,9 +298,7 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
.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()

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',
})

View File

@@ -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 <noreply@domain.com>" 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

View File

@@ -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 <email>" 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,
}
}

View File

@@ -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({

View File

@@ -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: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -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,

View File

@@ -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 <noreply@test.sim.ai>',
EMAIL_DOMAIN: 'test.sim.ai',
PERSONAL_EMAIL_FROM: 'Test <test@test.sim.ai>',
// 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<string, string | undefined> = 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<string, string | undefined> = {}) {
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()

View File

@@ -24,6 +24,8 @@ export {
databaseMock,
drizzleOrmMock,
} from './database.mock'
// Env mocks
export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock'
// Fetch mocks
export {
createMockFetch,