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 { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email' import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { sendEmail } from '@/lib/messaging/email/mailer' 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', () => ({ vi.doMock('zod', () => ({
z: { z: {
object: vi.fn().mockReturnValue({ object: vi.fn().mockReturnValue({

View File

@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, gt } from 'drizzle-orm' import { and, eq, gt } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { renderOTPEmail } from '@/components/emails/render-email' import { renderOTPEmail } from '@/components/emails'
import { getRedisClient } from '@/lib/core/config/redis' import { getRedisClient } from '@/lib/core/config/redis'
import { getStorageMethod } from '@/lib/core/storage' import { getStorageMethod } from '@/lib/core/storage'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'

View File

@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
}), }),
})) }))
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
NODE_ENV: 'development', NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000', 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 = { const validData = {
workflowId: 'workflow-123', workflowId: 'workflow-123',
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
}), }),
})) }))
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
NODE_ENV: 'development', NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000', 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 = { const validData = {
workflowId: 'workflow-123', 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', SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
})) }))
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
SIM_AGENT_API_URL: null, return createEnvMock({
SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key', COPILOT_API_KEY: 'test-api-key',
}, })
})) })
}) })
afterEach(() => { afterEach(() => {

View File

@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
})) }))
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
SIM_AGENT_API_URL: null, return createEnvMock({
SIM_AGENT_API_URL: undefined,
COPILOT_API_KEY: 'test-api-key', COPILOT_API_KEY: 'test-api-key',
}, })
})) })
}) })
afterEach(() => { 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 // Send confirmation email to the user
try { try {
const confirmationHtml = await renderHelpConfirmationEmail( const confirmationHtml = await renderHelpConfirmationEmail(
email,
type as 'bug' | 'feedback' | 'feature_request' | 'other', type as 'bug' | 'feedback' | 'feature_request' | 'other',
images.length images.length
) )

View File

@@ -16,7 +16,7 @@ import {
getEmailSubject, getEmailSubject,
renderBatchInvitationEmail, renderBatchInvitationEmail,
renderInvitationEmail, renderInvitationEmail,
} from '@/components/emails/render-email' } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { import {
validateBulkInvitations, validateBulkInvitations,
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailHtml = await renderInvitationEmail( const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone', inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization', organizationEntry[0]?.name || 'organization',
`${getBaseUrl()}/invite/${orgInvitation.id}`, `${getBaseUrl()}/invite/${orgInvitation.id}`
email
) )
emailResult = await sendEmail({ 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 { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' 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 { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage' import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' 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( const emailHtml = await renderInvitationEmail(
inviter[0]?.name || 'Someone', inviter[0]?.name || 'Someone',
organizationEntry[0]?.name || 'organization', organizationEntry[0]?.name || 'organization',
`${getBaseUrl()}/invite/organization?id=${invitationId}`, `${getBaseUrl()}/invite/organization?id=${invitationId}`
normalizedEmail
) )
const emailResult = await sendEmail({ const emailResult = await sendEmail({

View File

@@ -11,7 +11,7 @@ import {
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' 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 { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'

View File

@@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => {
WorkspaceInvitationEmail: vi.fn(), WorkspaceInvitationEmail: vi.fn(),
})) }))
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
RESEND_API_KEY: 'test-resend-key', return createEnvMock()
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/utils/urls', () => ({ vi.doMock('@/lib/core/utils/urls', () => ({
getEmailDomain: vi.fn().mockReturnValue('sim.ai'), getEmailDomain: vi.fn().mockReturnValue('sim.ai'),

View File

@@ -12,7 +12,7 @@ import {
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' 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 { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' 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 { import { Link, Section, Text } from '@react-email/components'
Body, import { baseStyles, colors } from '@/components/emails/_styles'
Column, import { EmailLayout } from '@/components/emails/components'
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 { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls' 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` const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
return ( return (
<Html> <EmailLayout preview={previewText}>
<Head /> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
<Preview>{previewText}</Preview> {userName ? `Hi ${userName},` : 'Hi,'}
<Body style={baseStyles.main}> </Text>
<Container style={baseStyles.container}> <Text style={baseStyles.paragraph}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}> Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
<Row> </Text>
<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}> <Section style={baseStyles.infoBox}>
<Row> <Text
<Column style={baseStyles.sectionBorder} /> style={{
<Column style={baseStyles.sectionCenter} /> margin: 0,
<Column style={baseStyles.sectionBorder} /> fontSize: '14px',
</Row> color: colors.textMuted,
</Section> 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}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> Credits are applied automatically to your workflow executions.
{userName ? `Hi ${userName},` : 'Hi,'} </Text>
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
</Text>
<Section <Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
style={{ <Text style={baseStyles.button}>View Dashboard</Text>
background: '#f4f4f5', </Link>
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>
<Text style={baseStyles.paragraph}> {/* Divider */}
These credits will be applied automatically to your workflow executions. Credits are <div style={baseStyles.divider} />
consumed before any overage charges apply.
</Text>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.button}>View Dashboard</Text> Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings Subscription.
</Link> </Text>
</EmailLayout>
<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>
) )
} }

View File

@@ -1,120 +1,50 @@
import { import { Link, Text } from '@react-email/components'
Body, import { baseStyles } from '@/components/emails/_styles'
Column, import { EmailLayout } from '@/components/emails/components'
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 { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
interface EnterpriseSubscriptionEmailProps { interface EnterpriseSubscriptionEmailProps {
userName?: string userName?: string
userEmail?: string
loginLink?: string loginLink?: string
createdDate?: Date
} }
export const EnterpriseSubscriptionEmail = ({ export function EnterpriseSubscriptionEmail({
userName = 'Valued User', userName = 'Valued User',
userEmail = '',
loginLink, loginLink,
createdDate = new Date(), }: EnterpriseSubscriptionEmailProps) {
}: EnterpriseSubscriptionEmailProps) => {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
const effectiveLoginLink = loginLink || `${baseUrl}/login` const effectiveLoginLink = loginLink || `${baseUrl}/login`
return ( return (
<Html> <EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
<Head /> <Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Body style={baseStyles.main}> <Text style={baseStyles.paragraph}>
<Preview>Your Enterprise Plan is now active on Sim</Preview> Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
<Container style={baseStyles.container}> features and increased capacity for your workflows.
<Section style={{ padding: '30px 0', textAlign: 'center' }}> </Text>
<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}> <Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Row> <Text style={baseStyles.button}>Open {brand.name}</Text>
<Column style={baseStyles.sectionBorder} /> </Link>
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}> <Text style={baseStyles.paragraph}>
<Text style={baseStyles.paragraph}>Hello {userName},</Text> <strong>Next steps:</strong>
<Text style={baseStyles.paragraph}> <br /> Invite team members to your organization
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now <br /> Start building your workflows
have access to advanced features and increased capacity for your workflows. </Text>
</Text>
<Text style={baseStyles.paragraph}> {/* Divider */}
Your account has been set up with full access to your organization. Click below to log <div style={baseStyles.divider} />
in and start exploring your new Enterprise features:
</Text>
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.button}>Access Your Enterprise Account</Text> Questions? Reply to this email or contact us at{' '}
</Link> <Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
<Text style={baseStyles.paragraph}> </Link>
<strong>What's next?</strong> </Text>
</Text> </EmailLayout>
<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>
) )
} }

View File

@@ -1,21 +1,7 @@
import { import { Link, Section, Text } from '@react-email/components'
Body, import { baseStyles, colors, typography } from '@/components/emails/_styles'
Column, import { EmailLayout } from '@/components/emails/components'
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 { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface FreeTierUpgradeEmailProps { interface FreeTierUpgradeEmailProps {
userName?: string userName?: string
@@ -23,119 +9,105 @@ interface FreeTierUpgradeEmailProps {
currentUsage: number currentUsage: number
limit: number limit: number
upgradeLink: string 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({ export function FreeTierUpgradeEmail({
userName, userName,
percentUsed, percentUsed,
currentUsage, currentUsage,
limit, limit,
upgradeLink, upgradeLink,
updatedDate = new Date(),
}: FreeTierUpgradeEmailProps) { }: FreeTierUpgradeEmailProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits` const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return ( return (
<Html> <EmailLayout preview={previewText}>
<Head /> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
<Preview>{previewText}</Preview> {userName ? `Hi ${userName},` : 'Hi,'}
<Body style={baseStyles.main}> </Text>
<Container style={baseStyles.container}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}> <Text style={baseStyles.paragraph}>
<Row> You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
<Column style={{ textAlign: 'center' }}> <strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%). Upgrade to Pro to keep
<Img building without interruption.
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`} </Text>
width='114'
alt={brand.name} {/* 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={{ style={{
margin: '0 auto', padding: '6px 0',
fontSize: '15px',
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
width: '45%',
}} }}
/> >
</Column> {feature.label}
</Row> </td>
</Section> <td
style={{
padding: '6px 0',
fontSize: '14px',
color: colors.textMuted,
fontFamily: typography.fontFamily,
}}
>
{feature.desc}
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section style={baseStyles.sectionsBorders}> <Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Row> <Text style={baseStyles.button}>Upgrade to Pro</Text>
<Column style={baseStyles.sectionBorder} /> </Link>
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}> {/* Divider */}
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <div style={baseStyles.divider} />
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '} One-time notification at 90% usage.
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%). </Text>
</Text> </EmailLayout>
<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>
) )
} }

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 { import { Link, Section, Text } from '@react-email/components'
Body, import { baseStyles, colors } from '@/components/emails/_styles'
Column, import { EmailLayout } from '@/components/emails/components'
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 { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface PaymentFailedEmailProps { interface PaymentFailedEmailProps {
userName?: string userName?: string
@@ -35,132 +21,88 @@ export function PaymentFailedEmail({
sentDate = new Date(), sentDate = new Date(),
}: PaymentFailedEmailProps) { }: PaymentFailedEmailProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: Payment Failed - Action Required` const previewText = `${brand.name}: Payment Failed - Action Required`
return ( return (
<Html> <EmailLayout preview={previewText}>
<Head /> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
<Preview>{previewText}</Preview> {userName ? `Hi ${userName},` : 'Hi,'}
<Body style={baseStyles.main}> </Text>
<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}> <Text
<Row> style={{
<Column style={baseStyles.sectionBorder} /> ...baseStyles.paragraph,
<Column style={baseStyles.sectionCenter} /> fontSize: '16px',
<Column style={baseStyles.sectionBorder} /> fontWeight: 600,
</Row> color: colors.textPrimary,
</Section> }}
>
We were unable to process your payment.
</Text>
<Section style={baseStyles.content}> <Text style={baseStyles.paragraph}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> Your {brand.name} account has been temporarily blocked to prevent service interruptions and
{userName ? `Hi ${userName},` : 'Hi,'} unexpected charges. To restore access immediately, please update your payment method.
</Text> </Text>
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}> <Section
We were unable to process your payment. style={{
</Text> 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}> <Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
Your {brand.name} account has been temporarily blocked to prevent service <Text style={baseStyles.button}>Update Payment Method</Text>
interruptions and unexpected charges. To restore access immediately, please update </Link>
your payment method.
</Text>
<Section {/* Divider */}
style={{ <div style={baseStyles.divider} />
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>
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}> <Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
<Text style={baseStyles.button}>Update Payment Method</Text>
</Link>
<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}> {/* Divider */}
<strong>What happens next?</strong> <div style={baseStyles.divider} />
</Text>
<Text style={baseStyles.paragraph}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Your workflows and automations are currently paused Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
<br /> Update your payment method to restore service immediately <Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
<br /> Stripe will automatically retry the charge once payment is updated {brand.supportEmail}
</Text> </Link>
</Text>
<Hr /> </EmailLayout>
<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>
) )
} }

View File

@@ -1,19 +1,6 @@
import { import { Link, Text } from '@react-email/components'
Body, import { baseStyles } from '@/components/emails/_styles'
Column, import { EmailLayout } from '@/components/emails/components'
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 { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -21,15 +8,9 @@ interface PlanWelcomeEmailProps {
planName: 'Pro' | 'Team' planName: 'Pro' | 'Team'
userName?: string userName?: string
loginLink?: string loginLink?: string
createdDate?: Date
} }
export function PlanWelcomeEmail({ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
planName,
userName,
loginLink,
createdDate = new Date(),
}: PlanWelcomeEmailProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
const cta = loginLink || `${baseUrl}/login` const cta = loginLink || `${baseUrl}/login`
@@ -37,76 +18,34 @@ export function PlanWelcomeEmail({
const previewText = `${brand.name}: Your ${planName} plan is active` const previewText = `${brand.name}: Your ${planName} plan is active`
return ( return (
<Html> <EmailLayout preview={previewText}>
<Head /> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
<Preview>{previewText}</Preview> {userName ? `Hi ${userName},` : 'Hi,'}
<Body style={baseStyles.main}> </Text>
<Container style={baseStyles.container}> <Text style={baseStyles.paragraph}>
<Section style={{ padding: '30px 0', textAlign: 'center' }}> Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
<Row> workflows.
<Column style={{ textAlign: 'center' }}> </Text>
<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}> <Link href={cta} style={{ textDecoration: 'none' }}>
<Row> <Text style={baseStyles.button}>Open {brand.name}</Text>
<Column style={baseStyles.sectionBorder} /> </Link>
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}> <Text style={baseStyles.paragraph}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> Want help getting started?{' '}
{userName ? `Hi ${userName},` : 'Hi,'} <Link href='https://cal.com/emirkarabeg/sim-team' style={baseStyles.link}>
</Text> Schedule a call
<Text style={baseStyles.paragraph}> </Link>{' '}
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to with our team.
build, test, and scale your agentic workflows. </Text>
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}> {/* Divider */}
<Text style={baseStyles.button}>Open {brand.name}</Text> <div style={baseStyles.divider} />
</Link>
<Text style={baseStyles.paragraph}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Want to discuss your plan or get personalized help getting started?{' '} Manage your subscription in Settings Subscription.
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}> </Text>
Schedule a 15-minute call </EmailLayout>
</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>
) )
} }

View File

@@ -1,21 +1,7 @@
import { import { Link, Section, Text } from '@react-email/components'
Body, import { baseStyles } from '@/components/emails/_styles'
Column, import { EmailLayout } from '@/components/emails/components'
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 { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface UsageThresholdEmailProps { interface UsageThresholdEmailProps {
userName?: string userName?: string
@@ -24,7 +10,6 @@ interface UsageThresholdEmailProps {
currentUsage: number currentUsage: number
limit: number limit: number
ctaLink: string ctaLink: string
updatedDate?: Date
} }
export function UsageThresholdEmail({ export function UsageThresholdEmail({
@@ -34,89 +19,46 @@ export function UsageThresholdEmail({
currentUsage, currentUsage,
limit, limit,
ctaLink, ctaLink,
updatedDate = new Date(),
}: UsageThresholdEmailProps) { }: UsageThresholdEmailProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget` const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return ( return (
<Html> <EmailLayout preview={previewText}>
<Head /> <Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
<Preview>{previewText}</Preview> {userName ? `Hi ${userName},` : 'Hi,'}
<Body style={baseStyles.main}> </Text>
<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}> <Text style={baseStyles.paragraph}>
<Row> You're approaching your monthly budget on the {planName} plan.
<Column style={baseStyles.sectionBorder} /> </Text>
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}> <Section style={baseStyles.infoBox}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}> <Text style={baseStyles.infoBoxTitle}>Usage</Text>
{userName ? `Hi ${userName},` : 'Hi,'} <Text style={baseStyles.infoBoxList}>
</Text> ${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
</Text>
</Section>
<Text style={baseStyles.paragraph}> {/* Divider */}
You're approaching your monthly budget on the {planName} plan. <div style={baseStyles.divider} />
</Text>
<Section> <Text style={baseStyles.paragraph}>
<Row> To avoid interruptions, consider increasing your monthly limit.
<Column> </Text>
<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>
<Hr /> <Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review Limits</Text>
</Link>
<Text style={{ ...baseStyles.paragraph }}> {/* Divider */}
To avoid interruptions, consider increasing your monthly limit. <div style={baseStyles.divider} />
</Text>
<Link href={ctaLink} style={{ textDecoration: 'none' }}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.button}>Review limits</Text> One-time notification at 80% usage.
</Link> </Text>
</EmailLayout>
<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>
) )
} }

View File

@@ -1,18 +1,7 @@
import { import { Text } from '@react-email/components'
Body,
Column,
Container,
Head,
Html,
Img,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns' import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles' import { baseStyles } from '@/components/emails/_styles'
import EmailFooter from '@/components/emails/footer' import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/lib/branding/branding' import { getBrandConfig } from '@/lib/branding/branding'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -22,96 +11,46 @@ interface CareersConfirmationEmailProps {
submittedDate?: Date submittedDate?: Date
} }
export const CareersConfirmationEmail = ({ export function CareersConfirmationEmail({
name, name,
position, position,
submittedDate = new Date(), submittedDate = new Date(),
}: CareersConfirmationEmailProps) => { }: CareersConfirmationEmailProps) {
const brand = getBrandConfig() const brand = getBrandConfig()
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
return ( return (
<Html> <EmailLayout preview={`Your application to ${brand.name} has been received`}>
<Head /> <Text style={baseStyles.paragraph}>Hello {name},</Text>
<Body style={baseStyles.main}> <Text style={baseStyles.paragraph}>
<Preview>Your application to {brand.name} has been received</Preview> We've received your application for <strong>{position}</strong>. Our team reviews every
<Container style={baseStyles.container}> application and will reach out if there's a match.
<Section style={{ padding: '30px 0', textAlign: 'center' }}> </Text>
<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}> <Text style={baseStyles.paragraph}>
<Row> In the meantime, explore our{' '}
<Column style={baseStyles.sectionBorder} /> <a
<Column style={baseStyles.sectionCenter} /> href='https://docs.sim.ai'
<Column style={baseStyles.sectionBorder} /> target='_blank'
</Row> rel='noopener noreferrer'
</Section> 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}> {/* Divider */}
<Text style={baseStyles.paragraph}>Hello {name},</Text> <div style={baseStyles.divider} />
<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>
<Text style={baseStyles.paragraph}> <Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Our team carefully reviews every application and will get back to you within the next Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
few weeks. If your qualifications match what we're looking for, we'll reach out to </Text>
schedule an initial conversation. </EmailLayout>
</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>
) )
} }

View File

@@ -1,19 +1,7 @@
import { import { Section, Text } from '@react-email/components'
Body,
Column,
Container,
Head,
Html,
Img,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { format } from 'date-fns' import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/base-styles' import { baseStyles, colors } from '@/components/emails/_styles'
import { getBrandConfig } from '@/lib/branding/branding' import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
interface CareersSubmissionEmailProps { interface CareersSubmissionEmailProps {
name: string name: string
@@ -39,7 +27,7 @@ const getExperienceLabel = (experience: string) => {
return labels[experience] || experience return labels[experience] || experience
} }
export const CareersSubmissionEmail = ({ export function CareersSubmissionEmail({
name, name,
email, email,
phone, phone,
@@ -50,263 +38,299 @@ export const CareersSubmissionEmail = ({
location, location,
message, message,
submittedDate = new Date(), submittedDate = new Date(),
}: CareersSubmissionEmailProps) => { }: CareersSubmissionEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return ( return (
<Html> <EmailLayout preview={`New Career Application from ${name}`} hideFooter>
<Head /> <Text
<Body style={baseStyles.main}> style={{
<Preview>New Career Application from {name}</Preview> ...baseStyles.paragraph,
<Container style={baseStyles.container}> fontSize: '18px',
<Section style={{ padding: '30px 0', textAlign: 'center' }}> fontWeight: 'bold',
<Row> color: colors.textPrimary,
<Column style={{ textAlign: 'center' }}> }}
<Img >
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`} New Career Application
width='114' </Text>
alt={brand.name}
style={{
margin: '0 auto',
}}
/>
</Column>
</Row>
</Section>
<Section style={baseStyles.sectionsBorders}> <Text style={baseStyles.paragraph}>
<Row> A new career application has been submitted on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
<Column style={baseStyles.sectionBorder} /> {format(submittedDate, 'h:mm a')}.
<Column style={baseStyles.sectionCenter} /> </Text>
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}> {/* Applicant Information */}
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}> <Section
New Career Application style={{
</Text> 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}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
A new career application has been submitted on{' '} <tbody>
{format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}. <tr>
</Text> <td
{/* Applicant Information */}
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #e5e5e5',
}}
>
<Text
style={{ style={{
margin: '0 0 16px 0', padding: '8px 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',
fontSize: '14px', fontSize: '14px',
color: '#333333', fontWeight: 'bold',
lineHeight: '1.6', color: colors.textMuted,
whiteSpace: 'pre-wrap', width: '40%',
fontFamily: baseStyles.fontFamily,
}} }}
> >
{message} Name:
</Text> </td>
</Section> <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}> {/* Message */}
Please review this application and reach out to the candidate at your earliest <Section
convenience. style={{
</Text> marginTop: '24px',
</Section> marginBottom: '24px',
</Container> padding: '20px',
</Body> backgroundColor: colors.bgOuter,
</Html> 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' // Styles
export { BatchInvitationEmail } from './batch-invitation-email' export * from './_styles'
export { EnterpriseSubscriptionEmail } from './billing/enterprise-subscription-email' // Auth emails
export { PlanWelcomeEmail } from './billing/plan-welcome-email' export * from './auth'
export { UsageThresholdEmail } from './billing/usage-threshold-email' // Billing emails
export { default as EmailFooter } from './footer' export * from './billing'
export { HelpConfirmationEmail } from './help-confirmation-email' // Careers emails
export { InvitationEmail } from './invitation-email' export * from './careers'
export { OTPVerificationEmail } from './otp-verification-email' // Shared components
export * from './render-email' export * from './components'
export { ResetPasswordEmail } from './reset-password-email' // Invitation emails
export { WorkspaceInvitationEmail } from './workspace-invitation' 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 { 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 { import {
BatchInvitationEmail, BatchInvitationEmail,
EnterpriseSubscriptionEmail,
HelpConfirmationEmail,
InvitationEmail, InvitationEmail,
OTPVerificationEmail, WorkspaceInvitationEmail,
PlanWelcomeEmail, } from '@/components/emails/invitations'
ResetPasswordEmail, import { HelpConfirmationEmail } from '@/components/emails/support'
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'
import { getBaseUrl } from '@/lib/core/utils/urls' 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( export async function renderOTPEmail(
otp: string, otp: string,
email: string, email: string,
@@ -27,34 +39,23 @@ export async function renderPasswordResetEmail(
username: string, username: string,
resetLink: string resetLink: string
): Promise<string> { ): Promise<string> {
return await render( return await render(ResetPasswordEmail({ username, resetLink }))
ResetPasswordEmail({ username, resetLink: resetLink, updatedDate: new Date() })
)
} }
export async function renderInvitationEmail( export async function renderInvitationEmail(
inviterName: string, inviterName: string,
organizationName: string, organizationName: string,
invitationUrl: string, invitationUrl: string
email: string
): Promise<string> { ): Promise<string> {
return await render( return await render(
InvitationEmail({ InvitationEmail({
inviterName, inviterName,
organizationName, organizationName,
inviteLink: invitationUrl, inviteLink: invitationUrl,
invitedEmail: email,
updatedDate: new Date(),
}) })
) )
} }
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
export async function renderBatchInvitationEmail( export async function renderBatchInvitationEmail(
inviterName: string, inviterName: string,
organizationName: string, organizationName: string,
@@ -74,13 +75,11 @@ export async function renderBatchInvitationEmail(
} }
export async function renderHelpConfirmationEmail( export async function renderHelpConfirmationEmail(
userEmail: string,
type: 'bug' | 'feedback' | 'feature_request' | 'other', type: 'bug' | 'feedback' | 'feature_request' | 'other',
attachmentCount = 0 attachmentCount = 0
): Promise<string> { ): Promise<string> {
return await render( return await render(
HelpConfirmationEmail({ HelpConfirmationEmail({
userEmail,
type, type,
attachmentCount, attachmentCount,
submittedDate: new Date(), submittedDate: new Date(),
@@ -88,19 +87,14 @@ export async function renderHelpConfirmationEmail(
) )
} }
export async function renderEnterpriseSubscriptionEmail( export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
userName: string,
userEmail: string
): Promise<string> {
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()
const loginLink = `${baseUrl}/login` const loginLink = `${baseUrl}/login`
return await render( return await render(
EnterpriseSubscriptionEmail({ EnterpriseSubscriptionEmail({
userName, userName,
userEmail,
loginLink, loginLink,
createdDate: new Date(),
}) })
) )
} }
@@ -121,7 +115,6 @@ export async function renderUsageThresholdEmail(params: {
currentUsage: params.currentUsage, currentUsage: params.currentUsage,
limit: params.limit, limit: params.limit,
ctaLink: params.ctaLink, ctaLink: params.ctaLink,
updatedDate: new Date(),
}) })
) )
} }
@@ -140,61 +133,10 @@ export async function renderFreeTierUpgradeEmail(params: {
currentUsage: params.currentUsage, currentUsage: params.currentUsage,
limit: params.limit, limit: params.limit,
upgradeLink: params.upgradeLink, 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: { export async function renderPlanWelcomeEmail(params: {
planName: 'Pro' | 'Team' planName: 'Pro' | 'Team'
userName?: string userName?: string
@@ -205,11 +147,14 @@ export async function renderPlanWelcomeEmail(params: {
planName: params.planName, planName: params.planName,
userName: params.userName, userName: params.userName,
loginLink: params.loginLink, loginLink: params.loginLink,
createdDate: new Date(),
}) })
) )
} }
export async function renderWelcomeEmail(userName?: string): Promise<string> {
return await render(WelcomeEmail({ userName }))
}
export async function renderCreditPurchaseEmail(params: { export async function renderCreditPurchaseEmail(params: {
userName?: string userName?: string
amount: number 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, getEmailSubject,
renderOTPEmail, renderOTPEmail,
renderPasswordResetEmail, renderPasswordResetEmail,
} from '@/components/emails/render-email' renderWelcomeEmail,
} from '@/components/emails'
import { sendPlanWelcomeEmail } from '@/lib/billing' import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { handleNewUser } from '@/lib/billing/core/usage' import { handleNewUser } from '@/lib/billing/core/usage'
@@ -47,19 +48,18 @@ import {
isAuthDisabled, isAuthDisabled,
isBillingEnabled, isBillingEnabled,
isEmailVerificationEnabled, isEmailVerificationEnabled,
isHosted,
isRegistrationDisabled, isRegistrationDisabled,
} from '@/lib/core/config/feature-flags' } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' 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 { quickValidateEmail } from '@/lib/messaging/email/validation'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants' import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth') 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 const validStripeKey = env.STRIPE_SECRET_KEY
let stripeClient = null let stripeClient = null
@@ -104,6 +104,31 @@ export const auth = betterAuth({
error, 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: { emailAndPassword: {
enabled: true, enabled: true,
requireEmailVerification: isEmailVerificationEnabled, requireEmailVerification: isEmailVerificationEnabled,

View File

@@ -298,9 +298,7 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
.limit(1) .limit(1)
if (users.length > 0 && users[0].email) { if (users.length > 0 && users[0].email) {
const { getEmailSubject, renderPlanWelcomeEmail } = await import( const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails')
'@/components/emails/render-email'
)
const { sendEmail } = await import('@/lib/messaging/email/mailer') const { sendEmail } = await import('@/lib/messaging/email/mailer')
const baseUrl = getBaseUrl() const baseUrl = getBaseUrl()

View File

@@ -6,7 +6,7 @@ import {
getEmailSubject, getEmailSubject,
renderFreeTierUpgradeEmail, renderFreeTierUpgradeEmail,
renderUsageThresholdEmail, renderUsageThresholdEmail,
} from '@/components/emails/render-email' } from '@/components/emails'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { import {
canEditUsageLimit, canEditUsageLimit,

View File

@@ -3,10 +3,7 @@ import { organization, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type Stripe from 'stripe' import type Stripe from 'stripe'
import { import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
getEmailSubject,
renderEnterpriseSubscriptionEmail,
} from '@/components/emails/render-email'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import type { EnterpriseSubscriptionMetadata } from '../types' import type { EnterpriseSubscriptionMetadata } from '../types'
@@ -208,7 +205,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
const user = userDetails[0] const user = userDetails[0]
const org = orgDetails[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({ const emailResult = await sendEmail({
to: user.email, to: user.email,

View File

@@ -10,14 +10,14 @@ import {
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm' import { and, eq, inArray } from 'drizzle-orm'
import type Stripe from 'stripe' import type Stripe from 'stripe'
import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email' import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
import { getEmailSubject, renderCreditPurchaseEmail } from '@/components/emails/render-email'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance' import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
const logger = createLogger('StripeInvoiceWebhooks') const logger = createLogger('StripeInvoiceWebhooks')
@@ -139,6 +139,7 @@ async function getPaymentMethodDetails(
/** /**
* Send payment failure notification emails to affected users * Send payment failure notification emails to affected users
* Note: This is only called when billing is enabled (Stripe plugin loaded)
*/ */
async function sendPaymentFailureEmails( async function sendPaymentFailureEmails(
sub: { plan: string | null; referenceId: string }, sub: { plan: string | null; referenceId: string },
@@ -203,10 +204,13 @@ async function sendPaymentFailureEmails(
}) })
) )
const { from, replyTo } = getPersonalEmailFrom()
await sendEmail({ await sendEmail({
to: userToNotify.email, to: userToNotify.email,
subject: 'Payment Failed - Action Required', subject: 'Payment Failed - Action Required',
html: emailHtml, html: emailHtml,
from,
replyTo,
emailType: 'transactional', 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) 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 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") 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) 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 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 // Fallback to constructing from EMAIL_DOMAIN
return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}` 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', toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test',
}) })
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
AZURE_ACCOUNT_NAME: 'testaccount', AZURE_ACCOUNT_NAME: 'testaccount',
AZURE_ACCOUNT_KEY: 'testkey', AZURE_ACCOUNT_KEY: 'testkey',
AZURE_CONNECTION_STRING: AZURE_CONNECTION_STRING:
'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net', 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net',
AZURE_STORAGE_CONTAINER_NAME: 'testcontainer', AZURE_STORAGE_CONTAINER_NAME: 'testcontainer',
}, })
})) })
vi.doMock('@sim/logger', () => ({ vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({ createLogger: vi.fn().mockReturnValue({

View File

@@ -31,14 +31,15 @@ describe('S3 Client', () => {
getSignedUrl: mockGetSignedUrl, getSignedUrl: mockGetSignedUrl,
})) }))
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
S3_BUCKET_NAME: 'test-bucket', S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region', AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: 'test-access-key', AWS_ACCESS_KEY_ID: 'test-access-key',
AWS_SECRET_ACCESS_KEY: 'test-secret-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key',
}, })
})) })
vi.doMock('@sim/logger', () => ({ vi.doMock('@sim/logger', () => ({
createLogger: vi.fn().mockReturnValue({ createLogger: vi.fn().mockReturnValue({
@@ -298,14 +299,15 @@ describe('S3 Client', () => {
describe('s3Client initialization', () => { describe('s3Client initialization', () => {
it('should initialize with correct configuration when credentials are available', async () => { it('should initialize with correct configuration when credentials are available', async () => {
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
S3_BUCKET_NAME: 'test-bucket', S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region', AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: 'test-access-key', AWS_ACCESS_KEY_ID: 'test-access-key',
AWS_SECRET_ACCESS_KEY: 'test-secret-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key',
}, })
})) })
vi.doMock('@/lib/uploads/setup', () => ({ vi.doMock('@/lib/uploads/setup', () => ({
S3_CONFIG: { S3_CONFIG: {
@@ -331,14 +333,15 @@ describe('S3 Client', () => {
}) })
it('should initialize without credentials when env vars are not available', async () => { it('should initialize without credentials when env vars are not available', async () => {
vi.doMock('@/lib/core/config/env', () => ({ vi.doMock('@/lib/core/config/env', async () => {
env: { const { createEnvMock } = await import('@sim/testing')
return createEnvMock({
S3_BUCKET_NAME: 'test-bucket', S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region', AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: undefined, AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined, AWS_SECRET_ACCESS_KEY: undefined,
}, })
})) })
vi.doMock('@/lib/uploads/setup', () => ({ vi.doMock('@/lib/uploads/setup', () => ({
S3_CONFIG: { 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 './builders'
export * from './factories' export * from './factories'
export { export {
createEnvMock,
createMockDb, createMockDb,
createMockFetch, createMockFetch,
createMockGetEnv,
createMockLogger, createMockLogger,
createMockResponse, createMockResponse,
createMockSocket, createMockSocket,
createMockStorage, createMockStorage,
databaseMock, databaseMock,
defaultMockEnv,
drizzleOrmMock, drizzleOrmMock,
envMock,
loggerMock, loggerMock,
type MockFetchResponse, type MockFetchResponse,
setupGlobalFetchMock, 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, databaseMock,
drizzleOrmMock, drizzleOrmMock,
} from './database.mock' } from './database.mock'
// Env mocks
export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock'
// Fetch mocks // Fetch mocks
export { export {
createMockFetch, createMockFetch,