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