mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 06:33:52 -05:00
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:
@@ -2,8 +2,7 @@ import { render } from '@react-email/components'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email'
|
||||
import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email'
|
||||
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
|
||||
@@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock()
|
||||
})
|
||||
|
||||
vi.doMock('zod', () => ({
|
||||
z: {
|
||||
object: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { renderOTPEmail } from '@/components/emails/render-email'
|
||||
import { renderOTPEmail } from '@/components/emails'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getStorageMethod } from '@/lib/core/storage'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
@@ -249,17 +249,13 @@ describe('Chat API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string'
|
||||
? value.toLowerCase() === 'true' || value === '1'
|
||||
: Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
@@ -296,15 +292,13 @@ describe('Chat API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
|
||||
},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
getEnv: (variable: string) => process.env[variable],
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
|
||||
@@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => {
|
||||
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: null,
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
SIM_AGENT_API_URL: undefined,
|
||||
COPILOT_API_KEY: 'test-api-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => {
|
||||
SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com',
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
SIM_AGENT_API_URL: null,
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
SIM_AGENT_API_URL: undefined,
|
||||
COPILOT_API_KEY: 'test-api-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
176
apps/sim/app/api/emails/preview/route.ts
Normal file
176
apps/sim/app/api/emails/preview/route.ts
Normal 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' },
|
||||
})
|
||||
}
|
||||
@@ -118,7 +118,6 @@ ${message}
|
||||
// Send confirmation email to the user
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
email,
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
images.length
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getEmailSubject,
|
||||
renderBatchInvitationEmail,
|
||||
renderInvitationEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
} from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
validateBulkInvitations,
|
||||
@@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${getBaseUrl()}/invite/${orgInvitation.id}`,
|
||||
email
|
||||
`${getBaseUrl()}/invite/${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
@@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${getBaseUrl()}/invite/organization?id=${invitationId}`,
|
||||
normalizedEmail
|
||||
`${getBaseUrl()}/invite/organization?id=${invitationId}`
|
||||
)
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => {
|
||||
WorkspaceInvitationEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
RESEND_API_KEY: 'test-resend-key',
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
|
||||
EMAIL_DOMAIN: 'test.sim.ai',
|
||||
},
|
||||
}))
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock()
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
246
apps/sim/components/emails/_styles/base.ts
Normal file
246
apps/sim/components/emails/_styles/base.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
1
apps/sim/components/emails/_styles/index.ts
Normal file
1
apps/sim/components/emails/_styles/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { baseStyles, colors, spacing, typography } from './base'
|
||||
3
apps/sim/components/emails/auth/index.ts
Normal file
3
apps/sim/components/emails/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OTPVerificationEmail } from './otp-verification-email'
|
||||
export { ResetPasswordEmail } from './reset-password-email'
|
||||
export { WelcomeEmail } from './welcome-email'
|
||||
57
apps/sim/components/emails/auth/otp-verification-email.tsx
Normal file
57
apps/sim/components/emails/auth/otp-verification-email.tsx
Normal 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
|
||||
36
apps/sim/components/emails/auth/reset-password-email.tsx
Normal file
36
apps/sim/components/emails/auth/reset-password-email.tsx
Normal 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
|
||||
45
apps/sim/components/emails/auth/welcome-email.tsx
Normal file
45
apps/sim/components/emails/auth/welcome-email.tsx
Normal 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
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,19 +1,6 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -36,89 +23,74 @@ export function CreditPurchaseEmail({
|
||||
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={previewText}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Amount Added
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '4px 0 16px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
${amount.toFixed(2)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
New Balance
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
${newBalance.toFixed(2)}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Credits are applied automatically to your workflow executions.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
background: '#f4f4f5',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
margin: '24px 0',
|
||||
}}
|
||||
>
|
||||
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>Amount Added</Text>
|
||||
<Text style={{ margin: '4px 0 16px', fontSize: '24px', fontWeight: 'bold' }}>
|
||||
${amount.toFixed(2)}
|
||||
</Text>
|
||||
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>New Balance</Text>
|
||||
<Text style={{ margin: '4px 0 0', fontSize: '24px', fontWeight: 'bold' }}>
|
||||
${newBalance.toFixed(2)}
|
||||
</Text>
|
||||
</Section>
|
||||
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>View Dashboard</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
These credits will be applied automatically to your workflow executions. Credits are
|
||||
consumed before any overage charges apply.
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>View Dashboard</Text>
|
||||
</Link>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You can view your credit balance and purchase history in Settings → Subscription.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Purchased on {purchaseDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +1,50 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface EnterpriseSubscriptionEmailProps {
|
||||
userName?: string
|
||||
userEmail?: string
|
||||
loginLink?: string
|
||||
createdDate?: Date
|
||||
}
|
||||
|
||||
export const EnterpriseSubscriptionEmail = ({
|
||||
export function EnterpriseSubscriptionEmail({
|
||||
userName = 'Valued User',
|
||||
userEmail = '',
|
||||
loginLink,
|
||||
createdDate = new Date(),
|
||||
}: EnterpriseSubscriptionEmailProps) => {
|
||||
}: EnterpriseSubscriptionEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Your Enterprise Plan is now active on Sim</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
|
||||
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
||||
features and increased capacity for your workflows.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Great news! Your <strong>Enterprise Plan</strong> has been activated on Sim. You now
|
||||
have access to advanced features and increased capacity for your workflows.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Next steps:</strong>
|
||||
<br />• Invite team members to your organization
|
||||
<br />• Start building your workflows
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your account has been set up with full access to your organization. Click below to log
|
||||
in and start exploring your new Enterprise features:
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Access Your Enterprise Account</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>What's next?</strong>
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
• Invite team members to your organization
|
||||
<br />• Begin building your workflows
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
If you have any questions or need assistance getting started, our support team is here
|
||||
to help.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
marginTop: '40px',
|
||||
textAlign: 'left',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail}
|
||||
regarding your Enterprise plan activation on Sim.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Questions? Reply to this email or contact us at{' '}
|
||||
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
|
||||
{brand.supportEmail}
|
||||
</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface FreeTierUpgradeEmailProps {
|
||||
userName?: string
|
||||
@@ -23,119 +9,105 @@ interface FreeTierUpgradeEmailProps {
|
||||
currentUsage: number
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
updatedDate?: Date
|
||||
}
|
||||
|
||||
const proFeatures = [
|
||||
{ label: '$20/month', desc: 'in credits included' },
|
||||
{ label: '25 runs/min', desc: 'sync executions' },
|
||||
{ label: '200 runs/min', desc: 'async executions' },
|
||||
{ label: '50GB storage', desc: 'for files & assets' },
|
||||
{ label: 'Unlimited', desc: 'workspaces & invites' },
|
||||
]
|
||||
|
||||
export function FreeTierUpgradeEmail({
|
||||
userName,
|
||||
percentUsed,
|
||||
currentUsage,
|
||||
limit,
|
||||
upgradeLink,
|
||||
updatedDate = new Date(),
|
||||
}: FreeTierUpgradeEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
<EmailLayout preview={previewText}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
|
||||
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%). Upgrade to Pro to keep
|
||||
building without interruption.
|
||||
</Text>
|
||||
|
||||
{/* Pro Features */}
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: '#f8faf9',
|
||||
border: `1px solid ${colors.brandTertiary}20`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px 20px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: colors.brandTertiary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Pro includes
|
||||
</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature, i) => (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
padding: '6px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
width: '45%',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
>
|
||||
{feature.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
}}
|
||||
>
|
||||
{feature.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used <strong>${currentUsage.toFixed(2)}</strong> of your{' '}
|
||||
<strong>${limit.toFixed(2)}</strong> free credits ({percentUsed}%).
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
To ensure uninterrupted service and unlock the full power of {brand.name}, upgrade to
|
||||
Pro today.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '5px',
|
||||
margin: '20px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
marginTop: 0,
|
||||
marginBottom: 12,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
What you get with Pro:
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '8px 0', lineHeight: 1.6 }}>
|
||||
• <strong>$20/month in credits</strong> – 2x your free tier
|
||||
<br />• <strong>Priority support</strong> – Get help when you need it
|
||||
<br />• <strong>Advanced features</strong> – Access to premium blocks and
|
||||
integrations
|
||||
<br />• <strong>No interruptions</strong> – Never worry about running out of credits
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={baseStyles.paragraph}>Upgrade now to keep building without limits.</Text>
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Questions? We're here to help.
|
||||
<br />
|
||||
<br />
|
||||
Best regards,
|
||||
<br />
|
||||
The {brand.name} Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 90%.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 90% usage.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
6
apps/sim/components/emails/billing/index.ts
Normal file
6
apps/sim/components/emails/billing/index.ts
Normal 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'
|
||||
@@ -1,21 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface PaymentFailedEmailProps {
|
||||
userName?: string
|
||||
@@ -35,132 +21,88 @@ export function PaymentFailedEmail({
|
||||
sentDate = new Date(),
|
||||
}: PaymentFailedEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const previewText = `${brand.name}: Payment Failed - Action Required`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={previewText}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
}}
|
||||
>
|
||||
We were unable to process your payment.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your {brand.name} account has been temporarily blocked to prevent service interruptions and
|
||||
unexpected charges. To restore access immediately, please update your payment method.
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
We were unable to process your payment.
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: '#fff5f5',
|
||||
border: '1px solid #fed7d7',
|
||||
borderRadius: '6px',
|
||||
padding: '16px 18px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
marginBottom: 8,
|
||||
marginTop: 0,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Payment Details
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Amount due: ${amountDue.toFixed(2)}
|
||||
</Text>
|
||||
{lastFourDigits && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Payment method: •••• {lastFourDigits}
|
||||
</Text>
|
||||
)}
|
||||
{failureReason && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>Reason: {failureReason}</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your {brand.name} account has been temporarily blocked to prevent service
|
||||
interruptions and unexpected charges. To restore access immediately, please update
|
||||
your payment method.
|
||||
</Text>
|
||||
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Update Payment Method</Text>
|
||||
</Link>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: '#fff5f5',
|
||||
border: '1px solid #fed7d7',
|
||||
borderRadius: '5px',
|
||||
padding: '16px',
|
||||
margin: '20px 0',
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
<Column>
|
||||
<Text style={{ ...baseStyles.paragraph, marginBottom: 8, marginTop: 0 }}>
|
||||
<strong>Payment Details</strong>
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Amount due: ${amountDue.toFixed(2)}
|
||||
</Text>
|
||||
{lastFourDigits && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Payment method: •••• {lastFourDigits}
|
||||
</Text>
|
||||
)}
|
||||
{failureReason && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Reason: {failureReason}
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Update Payment Method</Text>
|
||||
</Link>
|
||||
<Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
|
||||
|
||||
<Hr />
|
||||
<Text style={baseStyles.paragraph}>
|
||||
• Your workflows and automations are currently paused
|
||||
<br />• Update your payment method to restore service immediately
|
||||
<br />• Stripe will automatically retry the charge once payment is updated
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>What happens next?</strong>
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
• Your workflows and automations are currently paused
|
||||
<br />• Update your payment method to restore service immediately
|
||||
<br />• Stripe will automatically retry the charge once payment is updated
|
||||
</Text>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Need help?</strong>
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Common reasons for payment failures include expired cards, insufficient funds, or
|
||||
incorrect billing information. If you continue to experience issues, please{' '}
|
||||
<Link href={`${baseUrl}/support`} style={baseStyles.link}>
|
||||
contact our support team
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Sent on {sentDate.toLocaleDateString()} • This is a critical transactional
|
||||
notification.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
|
||||
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
|
||||
{brand.supportEmail}
|
||||
</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -21,15 +8,9 @@ interface PlanWelcomeEmailProps {
|
||||
planName: 'Pro' | 'Team'
|
||||
userName?: string
|
||||
loginLink?: string
|
||||
createdDate?: Date
|
||||
}
|
||||
|
||||
export function PlanWelcomeEmail({
|
||||
planName,
|
||||
userName,
|
||||
loginLink,
|
||||
createdDate = new Date(),
|
||||
}: PlanWelcomeEmailProps) {
|
||||
export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
const cta = loginLink || `${baseUrl}/login`
|
||||
@@ -37,76 +18,34 @@ export function PlanWelcomeEmail({
|
||||
const previewText = `${brand.name}: Your ${planName} plan is active`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={previewText}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
|
||||
workflows.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Link href={cta} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to the <strong>{planName}</strong> plan on {brand.name}. You're all set to
|
||||
build, test, and scale your agentic workflows.
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Want help getting started?{' '}
|
||||
<Link href='https://cal.com/emirkarabeg/sim-team' style={baseStyles.link}>
|
||||
Schedule a call
|
||||
</Link>{' '}
|
||||
with our team.
|
||||
</Text>
|
||||
|
||||
<Link href={cta} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Want to discuss your plan or get personalized help getting started?{' '}
|
||||
<Link href='https://cal.com/waleedlatif/15min' style={baseStyles.link}>
|
||||
Schedule a 15-minute call
|
||||
</Link>{' '}
|
||||
with our team.
|
||||
</Text>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Need to invite teammates, adjust usage limits, or manage billing? You can do that from
|
||||
Settings → Subscription.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Sent on {createdDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Manage your subscription in Settings → Subscription.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface UsageThresholdEmailProps {
|
||||
userName?: string
|
||||
@@ -24,7 +10,6 @@ interface UsageThresholdEmailProps {
|
||||
currentUsage: number
|
||||
limit: number
|
||||
ctaLink: string
|
||||
updatedDate?: Date
|
||||
}
|
||||
|
||||
export function UsageThresholdEmail({
|
||||
@@ -34,89 +19,46 @@ export function UsageThresholdEmail({
|
||||
currentUsage,
|
||||
limit,
|
||||
ctaLink,
|
||||
updatedDate = new Date(),
|
||||
}: UsageThresholdEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={previewText}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You're approaching your monthly budget on the {planName} plan.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You're approaching your monthly budget on the {planName} plan.
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Section>
|
||||
<Row>
|
||||
<Column>
|
||||
<Text style={{ ...baseStyles.paragraph, marginBottom: 8 }}>
|
||||
<strong>Usage</strong>
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%)
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
To avoid interruptions, consider increasing your monthly limit.
|
||||
</Text>
|
||||
|
||||
<Hr />
|
||||
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Review Limits</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph }}>
|
||||
To avoid interruptions, consider increasing your monthly limit.
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Review limits</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 80%.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 80% usage.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { Text } from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -22,96 +11,46 @@ interface CareersConfirmationEmailProps {
|
||||
submittedDate?: Date
|
||||
}
|
||||
|
||||
export const CareersConfirmationEmail = ({
|
||||
export function CareersConfirmationEmail({
|
||||
name,
|
||||
position,
|
||||
submittedDate = new Date(),
|
||||
}: CareersConfirmationEmailProps) => {
|
||||
}: CareersConfirmationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Your application to {brand.name} has been received</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
|
||||
<Text style={baseStyles.paragraph}>Hello {name},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We've received your application for <strong>{position}</strong>. Our team reviews every
|
||||
application and will reach out if there's a match.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
In the meantime, explore our{' '}
|
||||
<a
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={baseStyles.link}
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href={`${baseUrl}/studio`} style={baseStyles.link}>
|
||||
blog
|
||||
</a>{' '}
|
||||
to learn more about what we're building.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Hello {name},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Thank you for your interest in joining the {brand.name} team! We've received your
|
||||
application for the <strong>{position}</strong> position.
|
||||
</Text>
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Our team carefully reviews every application and will get back to you within the next
|
||||
few weeks. If your qualifications match what we're looking for, we'll reach out to
|
||||
schedule an initial conversation.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
In the meantime, feel free to explore our{' '}
|
||||
<a
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ color: '#6F3DFA', textDecoration: 'none' }}
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
to learn more about what we're building, or check out our{' '}
|
||||
<a href={`${baseUrl}/studio`} style={{ color: '#6F3DFA', textDecoration: 'none' }}>
|
||||
blog
|
||||
</a>{' '}
|
||||
for the latest updates.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The {brand.name} Team
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
marginTop: '40px',
|
||||
textAlign: 'left',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
|
||||
{format(submittedDate, 'h:mm a')}.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
|
||||
interface CareersSubmissionEmailProps {
|
||||
name: string
|
||||
@@ -39,7 +27,7 @@ const getExperienceLabel = (experience: string) => {
|
||||
return labels[experience] || experience
|
||||
}
|
||||
|
||||
export const CareersSubmissionEmail = ({
|
||||
export function CareersSubmissionEmail({
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
@@ -50,263 +38,299 @@ export const CareersSubmissionEmail = ({
|
||||
location,
|
||||
message,
|
||||
submittedDate = new Date(),
|
||||
}: CareersSubmissionEmailProps) => {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
}: CareersSubmissionEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>New Career Application from {name}</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
}}
|
||||
>
|
||||
New Career Application
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
A new career application has been submitted on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
|
||||
{format(submittedDate, 'h:mm a')}.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
New Career Application
|
||||
</Text>
|
||||
{/* Applicant Information */}
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: colors.bgOuter,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${colors.divider}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Applicant Information
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
A new career application has been submitted on{' '}
|
||||
{format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}.
|
||||
</Text>
|
||||
|
||||
{/* Applicant Information */}
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
}}
|
||||
>
|
||||
Applicant Information
|
||||
</Text>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
width: '40%',
|
||||
}}
|
||||
>
|
||||
Name:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>{name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Email:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
style={{ color: '#6F3DFA', textDecoration: 'none' }}
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{phone && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Phone:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a href={`tel:${phone}`} style={{ color: '#6F3DFA', textDecoration: 'none' }}>
|
||||
{phone}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Position:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
{position}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Experience:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
{getExperienceLabel(experience)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Location:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
{location}
|
||||
</td>
|
||||
</tr>
|
||||
{linkedin && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
LinkedIn:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a
|
||||
href={linkedin}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ color: '#6F3DFA', textDecoration: 'none' }}
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{portfolio && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Portfolio:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a
|
||||
href={portfolio}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ color: '#6F3DFA', textDecoration: 'none' }}
|
||||
>
|
||||
View Portfolio
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
{/* Message */}
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
}}
|
||||
>
|
||||
About Themselves
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0',
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: '#333333',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
width: '40%',
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Section>
|
||||
Name:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Email:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
<a href={`mailto:${email}`} style={baseStyles.link}>
|
||||
{email}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{phone && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Phone:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
<a href={`tel:${phone}`} style={baseStyles.link}>
|
||||
{phone}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Position:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{position}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Experience:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{getExperienceLabel(experience)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Location:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{location}
|
||||
</td>
|
||||
</tr>
|
||||
{linkedin && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
LinkedIn:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={linkedin}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={baseStyles.link}
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{portfolio && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Portfolio:
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={portfolio}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={baseStyles.link}
|
||||
>
|
||||
View Portfolio
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Please review this application and reach out to the candidate at your earliest
|
||||
convenience.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
{/* Message */}
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: colors.bgOuter,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${colors.divider}`,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
About Themselves
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0',
|
||||
fontSize: '14px',
|
||||
color: colors.textPrimary,
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
2
apps/sim/components/emails/careers/index.ts
Normal file
2
apps/sim/components/emails/careers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CareersConfirmationEmail } from './careers-confirmation-email'
|
||||
export { CareersSubmissionEmail } from './careers-submission-email'
|
||||
233
apps/sim/components/emails/components/email-footer.tsx
Normal file
233
apps/sim/components/emails/components/email-footer.tsx
Normal 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}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Social links row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</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}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Address row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
{brand.name}
|
||||
{isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA</>}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Contact row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Questions?{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Message ID row (optional) */}
|
||||
{messageId && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Need to refer to this message? Use this ID: {messageId}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</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}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Copyright row */}
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={32}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailFooter
|
||||
52
apps/sim/components/emails/components/email-layout.tsx
Normal file
52
apps/sim/components/emails/components/email-layout.tsx
Normal 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
|
||||
2
apps/sim/components/emails/components/index.ts
Normal file
2
apps/sim/components/emails/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { EmailFooter } from './email-footer'
|
||||
export { EmailLayout } from './email-layout'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,12 +1,17 @@
|
||||
export * from './base-styles'
|
||||
export { BatchInvitationEmail } from './batch-invitation-email'
|
||||
export { EnterpriseSubscriptionEmail } from './billing/enterprise-subscription-email'
|
||||
export { PlanWelcomeEmail } from './billing/plan-welcome-email'
|
||||
export { UsageThresholdEmail } from './billing/usage-threshold-email'
|
||||
export { default as EmailFooter } from './footer'
|
||||
export { HelpConfirmationEmail } from './help-confirmation-email'
|
||||
export { InvitationEmail } from './invitation-email'
|
||||
export { OTPVerificationEmail } from './otp-verification-email'
|
||||
export * from './render-email'
|
||||
export { ResetPasswordEmail } from './reset-password-email'
|
||||
export { WorkspaceInvitationEmail } from './workspace-invitation'
|
||||
// Styles
|
||||
export * from './_styles'
|
||||
// Auth emails
|
||||
export * from './auth'
|
||||
// Billing emails
|
||||
export * from './billing'
|
||||
// Careers emails
|
||||
export * from './careers'
|
||||
// Shared components
|
||||
export * from './components'
|
||||
// Invitation emails
|
||||
export * from './invitations'
|
||||
// Render functions and subjects
|
||||
export * from './render'
|
||||
export * from './subjects'
|
||||
// Support emails
|
||||
export * from './support'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
3
apps/sim/components/emails/invitations/index.ts
Normal file
3
apps/sim/components/emails/invitations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BatchInvitationEmail } from './batch-invitation-email'
|
||||
export { InvitationEmail } from './invitation-email'
|
||||
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
|
||||
60
apps/sim/components/emails/invitations/invitation-email.tsx
Normal file
60
apps/sim/components/emails/invitations/invitation-email.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,19 +1,31 @@
|
||||
import { render } from '@react-email/components'
|
||||
import { OTPVerificationEmail, ResetPasswordEmail, WelcomeEmail } from '@/components/emails/auth'
|
||||
import {
|
||||
CreditPurchaseEmail,
|
||||
EnterpriseSubscriptionEmail,
|
||||
FreeTierUpgradeEmail,
|
||||
PaymentFailedEmail,
|
||||
PlanWelcomeEmail,
|
||||
UsageThresholdEmail,
|
||||
} from '@/components/emails/billing'
|
||||
import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails/careers'
|
||||
import {
|
||||
BatchInvitationEmail,
|
||||
EnterpriseSubscriptionEmail,
|
||||
HelpConfirmationEmail,
|
||||
InvitationEmail,
|
||||
OTPVerificationEmail,
|
||||
PlanWelcomeEmail,
|
||||
ResetPasswordEmail,
|
||||
UsageThresholdEmail,
|
||||
} from '@/components/emails'
|
||||
import CreditPurchaseEmail from '@/components/emails/billing/credit-purchase-email'
|
||||
import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
WorkspaceInvitationEmail,
|
||||
} from '@/components/emails/invitations'
|
||||
import { HelpConfirmationEmail } from '@/components/emails/support'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export type { EmailSubjectType } from './subjects'
|
||||
export { getEmailSubject } from './subjects'
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export async function renderOTPEmail(
|
||||
otp: string,
|
||||
email: string,
|
||||
@@ -27,34 +39,23 @@ export async function renderPasswordResetEmail(
|
||||
username: string,
|
||||
resetLink: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
ResetPasswordEmail({ username, resetLink: resetLink, updatedDate: new Date() })
|
||||
)
|
||||
return await render(ResetPasswordEmail({ username, resetLink }))
|
||||
}
|
||||
|
||||
export async function renderInvitationEmail(
|
||||
inviterName: string,
|
||||
organizationName: string,
|
||||
invitationUrl: string,
|
||||
email: string
|
||||
invitationUrl: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
InvitationEmail({
|
||||
inviterName,
|
||||
organizationName,
|
||||
inviteLink: invitationUrl,
|
||||
invitedEmail: email,
|
||||
updatedDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export async function renderBatchInvitationEmail(
|
||||
inviterName: string,
|
||||
organizationName: string,
|
||||
@@ -74,13 +75,11 @@ export async function renderBatchInvitationEmail(
|
||||
}
|
||||
|
||||
export async function renderHelpConfirmationEmail(
|
||||
userEmail: string,
|
||||
type: 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
attachmentCount = 0
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
HelpConfirmationEmail({
|
||||
userEmail,
|
||||
type,
|
||||
attachmentCount,
|
||||
submittedDate: new Date(),
|
||||
@@ -88,19 +87,14 @@ export async function renderHelpConfirmationEmail(
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderEnterpriseSubscriptionEmail(
|
||||
userName: string,
|
||||
userEmail: string
|
||||
): Promise<string> {
|
||||
export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
|
||||
const baseUrl = getBaseUrl()
|
||||
const loginLink = `${baseUrl}/login`
|
||||
|
||||
return await render(
|
||||
EnterpriseSubscriptionEmail({
|
||||
userName,
|
||||
userEmail,
|
||||
loginLink,
|
||||
createdDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -121,7 +115,6 @@ export async function renderUsageThresholdEmail(params: {
|
||||
currentUsage: params.currentUsage,
|
||||
limit: params.limit,
|
||||
ctaLink: params.ctaLink,
|
||||
updatedDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -140,61 +133,10 @@ export async function renderFreeTierUpgradeEmail(params: {
|
||||
currentUsage: params.currentUsage,
|
||||
limit: params.limit,
|
||||
upgradeLink: params.upgradeLink,
|
||||
updatedDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function getEmailSubject(
|
||||
type:
|
||||
| 'sign-in'
|
||||
| 'email-verification'
|
||||
| 'forget-password'
|
||||
| 'reset-password'
|
||||
| 'invitation'
|
||||
| 'batch-invitation'
|
||||
| 'help-confirmation'
|
||||
| 'enterprise-subscription'
|
||||
| 'usage-threshold'
|
||||
| 'free-tier-upgrade'
|
||||
| 'plan-welcome-pro'
|
||||
| 'plan-welcome-team'
|
||||
| 'credit-purchase'
|
||||
): string {
|
||||
const brandName = getBrandConfig().name
|
||||
|
||||
switch (type) {
|
||||
case 'sign-in':
|
||||
return `Sign in to ${brandName}`
|
||||
case 'email-verification':
|
||||
return `Verify your email for ${brandName}`
|
||||
case 'forget-password':
|
||||
return `Reset your ${brandName} password`
|
||||
case 'reset-password':
|
||||
return `Reset your ${brandName} password`
|
||||
case 'invitation':
|
||||
return `You've been invited to join a team on ${brandName}`
|
||||
case 'batch-invitation':
|
||||
return `You've been invited to join a team and workspaces on ${brandName}`
|
||||
case 'help-confirmation':
|
||||
return 'Your request has been received'
|
||||
case 'enterprise-subscription':
|
||||
return `Your Enterprise Plan is now active on ${brandName}`
|
||||
case 'usage-threshold':
|
||||
return `You're nearing your monthly budget on ${brandName}`
|
||||
case 'free-tier-upgrade':
|
||||
return `You're at 90% of your free credits on ${brandName}`
|
||||
case 'plan-welcome-pro':
|
||||
return `Your Pro plan is now active on ${brandName}`
|
||||
case 'plan-welcome-team':
|
||||
return `Your Team plan is now active on ${brandName}`
|
||||
case 'credit-purchase':
|
||||
return `Credits added to your ${brandName} account`
|
||||
default:
|
||||
return brandName
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderPlanWelcomeEmail(params: {
|
||||
planName: 'Pro' | 'Team'
|
||||
userName?: string
|
||||
@@ -205,11 +147,14 @@ export async function renderPlanWelcomeEmail(params: {
|
||||
planName: params.planName,
|
||||
userName: params.userName,
|
||||
loginLink: params.loginLink,
|
||||
createdDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderWelcomeEmail(userName?: string): Promise<string> {
|
||||
return await render(WelcomeEmail({ userName }))
|
||||
}
|
||||
|
||||
export async function renderCreditPurchaseEmail(params: {
|
||||
userName?: string
|
||||
amount: number
|
||||
@@ -224,3 +169,73 @@ export async function renderCreditPurchaseEmail(params: {
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderWorkspaceInvitationEmail(
|
||||
inviterName: string,
|
||||
workspaceName: string,
|
||||
invitationLink: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
WorkspaceInvitationEmail({
|
||||
inviterName,
|
||||
workspaceName,
|
||||
invitationLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderPaymentFailedEmail(params: {
|
||||
userName?: string
|
||||
amountDue: number
|
||||
lastFourDigits?: string
|
||||
billingPortalUrl: string
|
||||
failureReason?: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
PaymentFailedEmail({
|
||||
userName: params.userName,
|
||||
amountDue: params.amountDue,
|
||||
lastFourDigits: params.lastFourDigits,
|
||||
billingPortalUrl: params.billingPortalUrl,
|
||||
failureReason: params.failureReason,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderCareersConfirmationEmail(
|
||||
name: string,
|
||||
position: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
CareersConfirmationEmail({
|
||||
name,
|
||||
position,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderCareersSubmissionEmail(params: {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
position: string
|
||||
linkedin?: string
|
||||
portfolio?: string
|
||||
experience: string
|
||||
location: string
|
||||
message: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
CareersSubmissionEmail({
|
||||
name: params.name,
|
||||
email: params.email,
|
||||
phone: params.phone,
|
||||
position: params.position,
|
||||
linkedin: params.linkedin,
|
||||
portfolio: params.portfolio,
|
||||
experience: params.experience,
|
||||
location: params.location,
|
||||
message: params.message,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
60
apps/sim/components/emails/subjects.ts
Normal file
60
apps/sim/components/emails/subjects.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
1
apps/sim/components/emails/support/index.ts
Normal file
1
apps/sim/components/emails/support/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HelpConfirmationEmail } from './help-confirmation-email'
|
||||
@@ -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
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
getEmailSubject,
|
||||
renderOTPEmail,
|
||||
renderPasswordResetEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
renderWelcomeEmail,
|
||||
} from '@/components/emails'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import { handleNewUser } from '@/lib/billing/core/usage'
|
||||
@@ -47,19 +48,18 @@ import {
|
||||
isAuthDisabled,
|
||||
isBillingEnabled,
|
||||
isEmailVerificationEnabled,
|
||||
isHosted,
|
||||
isRegistrationDisabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
|
||||
// Only initialize Stripe if the key is provided
|
||||
// This allows local development without a Stripe account
|
||||
const validStripeKey = env.STRIPE_SECRET_KEY
|
||||
|
||||
let stripeClient = null
|
||||
@@ -104,6 +104,31 @@ export const auth = betterAuth({
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
if (isHosted && user.email && user.emailVerified) {
|
||||
try {
|
||||
const html = await renderWelcomeEmail(user.name || undefined)
|
||||
const { from, replyTo } = getPersonalEmailFrom()
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: getEmailSubject('welcome'),
|
||||
html,
|
||||
from,
|
||||
replyTo,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
logger.info('[databaseHooks.user.create.after] Welcome email sent to OAuth user', {
|
||||
userId: user.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[databaseHooks.user.create.after] Failed to send welcome email', {
|
||||
userId: user.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -294,6 +319,35 @@ export const auth = betterAuth({
|
||||
],
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
autoSignInAfterVerification: true,
|
||||
afterEmailVerification: async (user) => {
|
||||
if (isHosted && user.email) {
|
||||
try {
|
||||
const html = await renderWelcomeEmail(user.name || undefined)
|
||||
const { from, replyTo } = getPersonalEmailFrom()
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: getEmailSubject('welcome'),
|
||||
html,
|
||||
from,
|
||||
replyTo,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
logger.info('[emailVerification.afterEmailVerification] Welcome email sent', {
|
||||
userId: user.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[emailVerification.afterEmailVerification] Failed to send welcome email', {
|
||||
userId: user.id,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: isEmailVerificationEnabled,
|
||||
|
||||
@@ -298,9 +298,7 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise<void> {
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0 && users[0].email) {
|
||||
const { getEmailSubject, renderPlanWelcomeEmail } = await import(
|
||||
'@/components/emails/render-email'
|
||||
)
|
||||
const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails')
|
||||
const { sendEmail } = await import('@/lib/messaging/email/mailer')
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getEmailSubject,
|
||||
renderFreeTierUpgradeEmail,
|
||||
renderUsageThresholdEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
} from '@/components/emails'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import {
|
||||
canEditUsageLimit,
|
||||
|
||||
@@ -3,10 +3,7 @@ import { organization, subscription, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import {
|
||||
getEmailSubject,
|
||||
renderEnterpriseSubscriptionEmail,
|
||||
} from '@/components/emails/render-email'
|
||||
import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import type { EnterpriseSubscriptionMetadata } from '../types'
|
||||
@@ -208,7 +205,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
|
||||
const user = userDetails[0]
|
||||
const org = orgDetails[0]
|
||||
|
||||
const html = await renderEnterpriseSubscriptionEmail(user.name || user.email, user.email)
|
||||
const html = await renderEnterpriseSubscriptionEmail(user.name || user.email)
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: user.email,
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email'
|
||||
import { getEmailSubject, renderCreditPurchaseEmail } from '@/components/emails/render-email'
|
||||
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
const logger = createLogger('StripeInvoiceWebhooks')
|
||||
@@ -139,6 +139,7 @@ async function getPaymentMethodDetails(
|
||||
|
||||
/**
|
||||
* Send payment failure notification emails to affected users
|
||||
* Note: This is only called when billing is enabled (Stripe plugin loaded)
|
||||
*/
|
||||
async function sendPaymentFailureEmails(
|
||||
sub: { plan: string | null; referenceId: string },
|
||||
@@ -203,10 +204,13 @@ async function sendPaymentFailureEmails(
|
||||
})
|
||||
)
|
||||
|
||||
const { from, replyTo } = getPersonalEmailFrom()
|
||||
await sendEmail({
|
||||
to: userToNotify.email,
|
||||
subject: 'Payment Failed - Action Required',
|
||||
html: emailHtml,
|
||||
from,
|
||||
replyTo,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export const env = createEnv({
|
||||
EMAIL_VERIFICATION_ENABLED: z.boolean().optional(), // Enable email verification for user registration and login (defaults to false)
|
||||
RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails
|
||||
FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim <noreply@domain.com>" or "noreply@domain.com")
|
||||
PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails
|
||||
EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set)
|
||||
AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string
|
||||
|
||||
|
||||
@@ -11,3 +11,34 @@ export function getFromEmailAddress(): string {
|
||||
// Fallback to constructing from EMAIL_DOMAIN
|
||||
return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the email address from a "Name <email>" formatted string"
|
||||
*/
|
||||
export function extractEmailFromAddress(fromAddress: string): string | undefined {
|
||||
const match = fromAddress.match(/<([^>]+)>/)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
if (fromAddress.includes('@') && !fromAddress.includes('<')) {
|
||||
return fromAddress.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the personal email from address and reply-to
|
||||
*/
|
||||
export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } {
|
||||
const personalFrom = env.PERSONAL_EMAIL_FROM
|
||||
if (personalFrom) {
|
||||
return {
|
||||
from: personalFrom,
|
||||
replyTo: extractEmailFromAddress(personalFrom),
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: getFromEmailAddress(),
|
||||
replyTo: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,15 +54,16 @@ describe('Azure Blob Storage Client', () => {
|
||||
toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test',
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
AZURE_ACCOUNT_NAME: 'testaccount',
|
||||
AZURE_ACCOUNT_KEY: 'testkey',
|
||||
AZURE_CONNECTION_STRING:
|
||||
'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net',
|
||||
AZURE_STORAGE_CONTAINER_NAME: 'testcontainer',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -31,14 +31,15 @@ describe('S3 Client', () => {
|
||||
getSignedUrl: mockGetSignedUrl,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
S3_BUCKET_NAME: 'test-bucket',
|
||||
AWS_REGION: 'test-region',
|
||||
AWS_ACCESS_KEY_ID: 'test-access-key',
|
||||
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
@@ -298,14 +299,15 @@ describe('S3 Client', () => {
|
||||
|
||||
describe('s3Client initialization', () => {
|
||||
it('should initialize with correct configuration when credentials are available', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
S3_BUCKET_NAME: 'test-bucket',
|
||||
AWS_REGION: 'test-region',
|
||||
AWS_ACCESS_KEY_ID: 'test-access-key',
|
||||
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/uploads/setup', () => ({
|
||||
S3_CONFIG: {
|
||||
@@ -331,14 +333,15 @@ describe('S3 Client', () => {
|
||||
})
|
||||
|
||||
it('should initialize without credentials when env vars are not available', async () => {
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
vi.doMock('@/lib/core/config/env', async () => {
|
||||
const { createEnvMock } = await import('@sim/testing')
|
||||
return createEnvMock({
|
||||
S3_BUCKET_NAME: 'test-bucket',
|
||||
AWS_REGION: 'test-region',
|
||||
AWS_ACCESS_KEY_ID: undefined,
|
||||
AWS_SECRET_ACCESS_KEY: undefined,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/uploads/setup', () => ({
|
||||
S3_CONFIG: {
|
||||
|
||||
BIN
apps/sim/public/brand/color/email/type.png
Normal file
BIN
apps/sim/public/brand/color/email/type.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
@@ -45,14 +45,18 @@ export * from './assertions'
|
||||
export * from './builders'
|
||||
export * from './factories'
|
||||
export {
|
||||
createEnvMock,
|
||||
createMockDb,
|
||||
createMockFetch,
|
||||
createMockGetEnv,
|
||||
createMockLogger,
|
||||
createMockResponse,
|
||||
createMockSocket,
|
||||
createMockStorage,
|
||||
databaseMock,
|
||||
defaultMockEnv,
|
||||
drizzleOrmMock,
|
||||
envMock,
|
||||
loggerMock,
|
||||
type MockFetchResponse,
|
||||
setupGlobalFetchMock,
|
||||
|
||||
67
packages/testing/src/mocks/env.mock.ts
Normal file
67
packages/testing/src/mocks/env.mock.ts
Normal 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()
|
||||
@@ -24,6 +24,8 @@ export {
|
||||
databaseMock,
|
||||
drizzleOrmMock,
|
||||
} from './database.mock'
|
||||
// Env mocks
|
||||
export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock'
|
||||
// Fetch mocks
|
||||
export {
|
||||
createMockFetch,
|
||||
|
||||
Reference in New Issue
Block a user