diff --git a/apps/sim/app/api/careers/submit/route.ts b/apps/sim/app/api/careers/submit/route.ts index 0d6e0646d..2ce430972 100644 --- a/apps/sim/app/api/careers/submit/route.ts +++ b/apps/sim/app/api/careers/submit/route.ts @@ -2,8 +2,7 @@ import { render } from '@react-email/components' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email' -import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email' +import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails' import { generateRequestId } from '@/lib/core/utils/request' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 24526a80d..b16818f17 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -156,6 +156,11 @@ describe('Chat OTP API Route', () => { }), })) + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock() + }) + vi.doMock('zod', () => ({ z: { object: vi.fn().mockReturnValue({ diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 52948e2bf..de7b0ff4b 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger' import { and, eq, gt } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' -import { renderOTPEmail } from '@/components/emails/render-email' +import { renderOTPEmail } from '@/components/emails' import { getRedisClient } from '@/lib/core/config/redis' import { getStorageMethod } from '@/lib/core/storage' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 0eb628883..4fb96da4e 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -249,17 +249,13 @@ describe('Chat API Route', () => { }), })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ NODE_ENV: 'development', NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }, - isTruthy: (value: string | boolean | number | undefined) => - typeof value === 'string' - ? value.toLowerCase() === 'true' || value === '1' - : Boolean(value), - getEnv: (variable: string) => process.env[variable], - })) + }) + }) const validData = { workflowId: 'workflow-123', @@ -296,15 +292,13 @@ describe('Chat API Route', () => { }), })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ NODE_ENV: 'development', NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }, - isTruthy: (value: string | boolean | number | undefined) => - typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value), - getEnv: (variable: string) => process.env[variable], - })) + }) + }) const validData = { workflowId: 'workflow-123', diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index 2556a7e93..b5d27be6e 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -21,12 +21,13 @@ describe('Copilot API Keys API Route', () => { SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - SIM_AGENT_API_URL: null, + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ + SIM_AGENT_API_URL: undefined, COPILOT_API_KEY: 'test-api-key', - }, - })) + }) + }) }) afterEach(() => { diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts index e48dff016..0d06c5edd 100644 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ b/apps/sim/app/api/copilot/stats/route.test.ts @@ -46,12 +46,13 @@ describe('Copilot Stats API Route', () => { SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - SIM_AGENT_API_URL: null, + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ + SIM_AGENT_API_URL: undefined, COPILOT_API_KEY: 'test-api-key', - }, - })) + }) + }) }) afterEach(() => { diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts new file mode 100644 index 000000000..5047a3eaa --- /dev/null +++ b/apps/sim/app/api/emails/preview/route.ts @@ -0,0 +1,176 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + renderBatchInvitationEmail, + renderCareersConfirmationEmail, + renderCareersSubmissionEmail, + renderCreditPurchaseEmail, + renderEnterpriseSubscriptionEmail, + renderFreeTierUpgradeEmail, + renderHelpConfirmationEmail, + renderInvitationEmail, + renderOTPEmail, + renderPasswordResetEmail, + renderPaymentFailedEmail, + renderPlanWelcomeEmail, + renderUsageThresholdEmail, + renderWelcomeEmail, + renderWorkspaceInvitationEmail, +} from '@/components/emails' + +const emailTemplates = { + // Auth emails + otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'), + 'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'), + welcome: () => renderWelcomeEmail('John'), + + // Invitation emails + invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'), + 'batch-invitation': () => + renderBatchInvitationEmail( + 'Jane Doe', + 'Acme Corp', + 'admin', + [ + { workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' }, + { workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' }, + ], + 'https://sim.ai/invite/abc123' + ), + 'workspace-invitation': () => + renderWorkspaceInvitationEmail( + 'John Smith', + 'Engineering Team', + 'https://sim.ai/workspace/invite/abc123' + ), + + // Support emails + 'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2), + + // Billing emails + 'usage-threshold': () => + renderUsageThresholdEmail({ + userName: 'John', + planName: 'Pro', + percentUsed: 75, + currentUsage: 15, + limit: 20, + ctaLink: 'https://sim.ai/settings/billing', + }), + 'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'), + 'free-tier-upgrade': () => + renderFreeTierUpgradeEmail({ + userName: 'John', + percentUsed: 90, + currentUsage: 9, + limit: 10, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'plan-welcome-pro': () => + renderPlanWelcomeEmail({ + planName: 'Pro', + userName: 'John', + loginLink: 'https://sim.ai/login', + }), + 'plan-welcome-team': () => + renderPlanWelcomeEmail({ + planName: 'Team', + userName: 'John', + loginLink: 'https://sim.ai/login', + }), + 'credit-purchase': () => + renderCreditPurchaseEmail({ + userName: 'John', + amount: 50, + newBalance: 75, + }), + 'payment-failed': () => + renderPaymentFailedEmail({ + userName: 'John', + amountDue: 20, + lastFourDigits: '4242', + billingPortalUrl: 'https://sim.ai/settings/billing', + failureReason: 'Card declined', + }), + + // Careers emails + 'careers-confirmation': () => renderCareersConfirmationEmail('John Doe', 'Senior Engineer'), + 'careers-submission': () => + renderCareersSubmissionEmail({ + name: 'John Doe', + email: 'john@example.com', + phone: '+1 (555) 123-4567', + position: 'Senior Engineer', + linkedin: 'https://linkedin.com/in/johndoe', + portfolio: 'https://johndoe.dev', + experience: '5-10', + location: 'San Francisco, CA', + message: + 'I have 10 years of experience building scalable distributed systems. Most recently, I led a team at a Series B startup where we scaled from 100K to 10M users.', + }), +} as const + +type EmailTemplate = keyof typeof emailTemplates + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const template = searchParams.get('template') as EmailTemplate | null + + if (!template) { + const categories = { + Auth: ['otp', 'reset-password', 'welcome'], + Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'], + Support: ['help-confirmation'], + Billing: [ + 'usage-threshold', + 'enterprise-subscription', + 'free-tier-upgrade', + 'plan-welcome-pro', + 'plan-welcome-team', + 'credit-purchase', + 'payment-failed', + ], + Careers: ['careers-confirmation', 'careers-submission'], + } + + const categoryHtml = Object.entries(categories) + .map( + ([category, templates]) => ` +

${category}

+ + ` + ) + .join('') + + return new NextResponse( + ` + + + Email Previews + + + +

Email Templates

+ ${categoryHtml} + +`, + { headers: { 'Content-Type': 'text/html' } } + ) + } + + if (!(template in emailTemplates)) { + return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 }) + } + + const html = await emailTemplates[template]() + + return new NextResponse(html, { + headers: { 'Content-Type': 'text/html' }, + }) +} diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index ca3d040c2..676397d2d 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -118,7 +118,6 @@ ${message} // Send confirmation email to the user try { const confirmationHtml = await renderHelpConfirmationEmail( - email, type as 'bug' | 'feedback' | 'feature_request' | 'other', images.length ) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 46cdabea9..f72705e90 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -16,7 +16,7 @@ import { getEmailSubject, renderBatchInvitationEmail, renderInvitationEmail, -} from '@/components/emails/render-email' +} from '@/components/emails' import { getSession } from '@/lib/auth' import { validateBulkInvitations, @@ -376,8 +376,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const emailHtml = await renderInvitationEmail( inviter[0]?.name || 'Someone', organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/${orgInvitation.id}`, - email + `${getBaseUrl()}/invite/${orgInvitation.id}` ) emailResult = await sendEmail({ diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 4ada7c2ba..eb3f4b0cd 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -4,7 +4,7 @@ import { invitation, member, organization, user, userStats } from '@sim/db/schem import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email' +import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { getSession } from '@/lib/auth' import { getUserUsageData } from '@/lib/billing/core/usage' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' @@ -260,8 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const emailHtml = await renderInvitationEmail( inviter[0]?.name || 'Someone', organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/organization?id=${invitationId}`, - normalizedEmail + `${getBaseUrl()}/invite/organization?id=${invitationId}` ) const emailResult = await sendEmail({ diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index 0d427f177..4d4ac7928 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -11,7 +11,7 @@ import { import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation' +import { WorkspaceInvitationEmail } from '@/components/emails' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 96370bf63..b47e7bf36 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -87,14 +87,10 @@ describe('Workspace Invitations API Route', () => { WorkspaceInvitationEmail: vi.fn(), })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { - RESEND_API_KEY: 'test-resend-key', - NEXT_PUBLIC_APP_URL: 'https://test.sim.ai', - FROM_EMAIL_ADDRESS: 'Sim ', - EMAIL_DOMAIN: 'test.sim.ai', - }, - })) + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock() + }) vi.doMock('@/lib/core/utils/urls', () => ({ getEmailDomain: vi.fn().mockReturnValue('sim.ai'), diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 6ad6285b3..19ee61087 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -12,7 +12,7 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation' +import { WorkspaceInvitationEmail } from '@/components/emails' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts new file mode 100644 index 000000000..844ac1c55 --- /dev/null +++ b/apps/sim/components/emails/_styles/base.ts @@ -0,0 +1,246 @@ +/** + * Base styles for all email templates. + * Colors are derived from globals.css light mode tokens. + */ + +/** Color tokens from globals.css (light mode) */ +export const colors = { + /** Main canvas background */ + bgOuter: '#F7F9FC', + /** Card/container background - pure white */ + bgCard: '#ffffff', + /** Primary text color */ + textPrimary: '#2d2d2d', + /** Secondary text color */ + textSecondary: '#404040', + /** Tertiary text color */ + textTertiary: '#5c5c5c', + /** Muted text (footer) */ + textMuted: '#737373', + /** Brand primary - purple */ + brandPrimary: '#6f3dfa', + /** Brand tertiary - green (matches Run/Deploy buttons) */ + brandTertiary: '#32bd7e', + /** Border/divider color */ + divider: '#ededed', + /** Footer background */ + footerBg: '#F7F9FC', +} + +/** Typography settings */ +export const typography = { + fontFamily: "-apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif", + fontSize: { + body: '16px', + small: '14px', + caption: '12px', + }, + lineHeight: { + body: '24px', + caption: '20px', + }, +} + +/** Spacing values */ +export const spacing = { + containerWidth: 600, + gutter: 40, + sectionGap: 20, + paragraphGap: 12, + /** Logo width in pixels */ + logoWidth: 90, +} + +export const baseStyles = { + fontFamily: typography.fontFamily, + + /** Main body wrapper with outer background */ + main: { + backgroundColor: colors.bgOuter, + fontFamily: typography.fontFamily, + padding: '32px 0', + }, + + /** Center wrapper for email content */ + wrapper: { + maxWidth: `${spacing.containerWidth}px`, + margin: '0 auto', + }, + + /** Main card container with rounded corners */ + container: { + maxWidth: `${spacing.containerWidth}px`, + margin: '0 auto', + backgroundColor: colors.bgCard, + borderRadius: '16px', + overflow: 'hidden', + }, + + /** Header section with logo */ + header: { + padding: `32px ${spacing.gutter}px 16px ${spacing.gutter}px`, + textAlign: 'left' as const, + }, + + /** Main content area with horizontal padding */ + content: { + padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`, + }, + + /** Standard paragraph text */ + paragraph: { + fontSize: typography.fontSize.body, + lineHeight: typography.lineHeight.body, + color: colors.textSecondary, + fontWeight: 400, + fontFamily: typography.fontFamily, + margin: `${spacing.paragraphGap}px 0`, + }, + + /** Bold label text (e.g., "Platform:", "Time:") */ + label: { + fontSize: typography.fontSize.body, + lineHeight: typography.lineHeight.body, + color: colors.textSecondary, + fontWeight: 'bold' as const, + fontFamily: typography.fontFamily, + margin: 0, + display: 'inline', + }, + + /** Primary CTA button - matches app tertiary button style */ + button: { + display: 'inline-block', + backgroundColor: colors.brandTertiary, + color: '#ffffff', + fontWeight: 500, + fontSize: '14px', + padding: '6px 12px', + borderRadius: '5px', + textDecoration: 'none', + textAlign: 'center' as const, + margin: '4px 0', + fontFamily: typography.fontFamily, + }, + + /** Link text style */ + link: { + color: colors.brandTertiary, + fontWeight: 'bold' as const, + textDecoration: 'none', + }, + + /** Horizontal divider */ + divider: { + borderTop: `1px solid ${colors.divider}`, + margin: `16px 0`, + }, + + /** Footer container (inside gray area below card) */ + footer: { + maxWidth: `${spacing.containerWidth}px`, + margin: '0 auto', + padding: `32px ${spacing.gutter}px`, + textAlign: 'left' as const, + }, + + /** Footer text style */ + footerText: { + fontSize: typography.fontSize.caption, + lineHeight: typography.lineHeight.caption, + color: colors.textMuted, + fontFamily: typography.fontFamily, + margin: '0 0 10px 0', + }, + + /** Code/OTP container */ + codeContainer: { + margin: '12px 0', + padding: '12px 16px', + backgroundColor: '#f8f9fa', + borderRadius: '6px', + border: `1px solid ${colors.divider}`, + textAlign: 'center' as const, + }, + + /** Code/OTP text */ + code: { + fontSize: '24px', + fontWeight: 'bold' as const, + letterSpacing: '3px', + color: colors.textPrimary, + fontFamily: typography.fontFamily, + margin: 0, + }, + + /** Highlighted info box (e.g., "What you get with Pro") */ + infoBox: { + backgroundColor: colors.bgOuter, + padding: '16px 18px', + borderRadius: '6px', + margin: '16px 0', + }, + + /** Info box title */ + infoBoxTitle: { + fontSize: typography.fontSize.body, + lineHeight: typography.lineHeight.body, + fontWeight: 600, + color: colors.textPrimary, + fontFamily: typography.fontFamily, + margin: '0 0 8px 0', + }, + + /** Info box list content */ + infoBoxList: { + fontSize: typography.fontSize.body, + lineHeight: '1.6', + color: colors.textSecondary, + fontFamily: typography.fontFamily, + margin: 0, + }, + + /** Section borders - decorative accent line */ + sectionsBorders: { + width: '100%', + display: 'flex', + }, + + sectionBorder: { + borderBottom: `1px solid ${colors.divider}`, + width: '249px', + }, + + sectionCenter: { + borderBottom: `1px solid ${colors.brandTertiary}`, + width: '102px', + }, + + /** Spacer row for vertical spacing in tables */ + spacer: { + border: 0, + margin: 0, + padding: 0, + fontSize: '1px', + lineHeight: '1px', + }, + + /** Gutter cell for horizontal padding in tables */ + gutter: { + border: 0, + margin: 0, + padding: 0, + fontSize: '1px', + lineHeight: '1px', + width: `${spacing.gutter}px`, + }, + + /** Info row (e.g., Platform, Device location, Time) */ + infoRow: { + fontSize: typography.fontSize.body, + lineHeight: typography.lineHeight.body, + color: colors.textSecondary, + fontFamily: typography.fontFamily, + margin: '8px 0', + }, +} diff --git a/apps/sim/components/emails/_styles/index.ts b/apps/sim/components/emails/_styles/index.ts new file mode 100644 index 000000000..dd1d961d5 --- /dev/null +++ b/apps/sim/components/emails/_styles/index.ts @@ -0,0 +1 @@ +export { baseStyles, colors, spacing, typography } from './base' diff --git a/apps/sim/components/emails/auth/index.ts b/apps/sim/components/emails/auth/index.ts new file mode 100644 index 000000000..b1d04f1dd --- /dev/null +++ b/apps/sim/components/emails/auth/index.ts @@ -0,0 +1,3 @@ +export { OTPVerificationEmail } from './otp-verification-email' +export { ResetPasswordEmail } from './reset-password-email' +export { WelcomeEmail } from './welcome-email' diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx new file mode 100644 index 000000000..41791adfb --- /dev/null +++ b/apps/sim/components/emails/auth/otp-verification-email.tsx @@ -0,0 +1,57 @@ +import { Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' + +interface OTPVerificationEmailProps { + otp: string + email?: string + type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access' + chatTitle?: string +} + +const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => { + switch (type) { + case 'sign-in': + return `Sign in to ${brandName}` + case 'email-verification': + return `Verify your email for ${brandName}` + case 'forget-password': + return `Reset your ${brandName} password` + case 'chat-access': + return `Verification code for ${chatTitle || 'Chat'}` + default: + return `Verification code for ${brandName}` + } +} + +export function OTPVerificationEmail({ + otp, + email = '', + type = 'email-verification', + chatTitle, +}: OTPVerificationEmailProps) { + const brand = getBrandConfig() + + return ( + + Your verification code: + +
+ {otp} +
+ + This code will expire in 15 minutes. + + {/* Divider */} +
+ + + Do not share this code with anyone. If you didn't request this code, you can safely ignore + this email. + + + ) +} + +export default OTPVerificationEmail diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx new file mode 100644 index 000000000..68f95c2ae --- /dev/null +++ b/apps/sim/components/emails/auth/reset-password-email.tsx @@ -0,0 +1,36 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' + +interface ResetPasswordEmailProps { + username?: string + resetLink?: string +} + +export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPasswordEmailProps) { + const brand = getBrandConfig() + + return ( + + Hello {username}, + + A password reset was requested for your {brand.name} account. Click below to set a new + password. + + + + Reset Password + + + {/* Divider */} +
+ + + If you didn't request this, you can ignore this email. Link expires in 24 hours. + + + ) +} + +export default ResetPasswordEmail diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx new file mode 100644 index 000000000..bbd196b08 --- /dev/null +++ b/apps/sim/components/emails/auth/welcome-email.tsx @@ -0,0 +1,45 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' +import { getBaseUrl } from '@/lib/core/utils/urls' + +interface WelcomeEmailProps { + userName?: string +} + +export function WelcomeEmail({ userName }: WelcomeEmailProps) { + const brand = getBrandConfig() + const baseUrl = getBaseUrl() + + return ( + + + {userName ? `Hey ${userName},` : 'Hey,'} + + + Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI + workflows in minutes. + + + + Get Started + + + + If you have any questions or feedback, just reply to this email. I read every message! + + + - Emir, co-founder of {brand.name} + + {/* Divider */} +
+ + + You're on the free plan with $10 in credits to get started. + + + ) +} + +export default WelcomeEmail diff --git a/apps/sim/components/emails/base-styles.ts b/apps/sim/components/emails/base-styles.ts deleted file mode 100644 index 633c762ee..000000000 --- a/apps/sim/components/emails/base-styles.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Base styles for all email templates - -export const baseStyles = { - fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif', - main: { - backgroundColor: '#f5f5f7', - fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif', - }, - container: { - maxWidth: '580px', - margin: '30px auto', - backgroundColor: '#ffffff', - borderRadius: '5px', - overflow: 'hidden', - }, - header: { - padding: '30px 0', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#ffffff', - }, - content: { - padding: '5px 30px 20px 30px', - }, - paragraph: { - fontSize: '16px', - lineHeight: '1.5', - color: '#333333', - margin: '16px 0', - }, - button: { - display: 'inline-block', - backgroundColor: '#6F3DFA', - color: '#ffffff', - fontWeight: 'bold', - fontSize: '16px', - padding: '12px 30px', - borderRadius: '5px', - textDecoration: 'none', - textAlign: 'center' as const, - margin: '20px 0', - }, - link: { - color: '#6F3DFA', - textDecoration: 'underline', - }, - footer: { - maxWidth: '580px', - margin: '0 auto', - padding: '20px 0', - textAlign: 'center' as const, - }, - footerText: { - fontSize: '12px', - color: '#666666', - margin: '0', - }, - codeContainer: { - margin: '20px 0', - padding: '16px', - backgroundColor: '#f8f9fa', - borderRadius: '5px', - border: '1px solid #eee', - textAlign: 'center' as const, - }, - code: { - fontSize: '28px', - fontWeight: 'bold', - letterSpacing: '4px', - color: '#333333', - }, - sectionsBorders: { - width: '100%', - display: 'flex', - }, - sectionBorder: { - borderBottom: '1px solid #eeeeee', - width: '249px', - }, - sectionCenter: { - borderBottom: '1px solid #6F3DFA', - width: '102px', - }, -} diff --git a/apps/sim/components/emails/batch-invitation-email.tsx b/apps/sim/components/emails/batch-invitation-email.tsx deleted file mode 100644 index fa67ae096..000000000 --- a/apps/sim/components/emails/batch-invitation-email.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' - -interface WorkspaceInvitation { - workspaceId: string - workspaceName: string - permission: 'admin' | 'write' | 'read' -} - -interface BatchInvitationEmailProps { - inviterName: string - organizationName: string - organizationRole: 'admin' | 'member' - workspaceInvitations: WorkspaceInvitation[] - acceptUrl: string -} - -const getPermissionLabel = (permission: string) => { - switch (permission) { - case 'admin': - return 'Admin (full access)' - case 'write': - return 'Editor (can edit workflows)' - case 'read': - return 'Viewer (read-only access)' - default: - return permission - } -} - -const getRoleLabel = (role: string) => { - switch (role) { - case 'admin': - return 'Admin' - case 'member': - return 'Member' - default: - return role - } -} - -export const BatchInvitationEmail = ({ - inviterName = 'Someone', - organizationName = 'the team', - organizationRole = 'member', - workspaceInvitations = [], - acceptUrl, -}: BatchInvitationEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - const hasWorkspaces = workspaceInvitations.length > 0 - - return ( - - - - - You've been invited to join {organizationName} - {hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''} - - -
- - - {brand.name} - - -
- -
- - - - - -
- -
- Hello, - - {inviterName} has invited you to join{' '} - {organizationName} on Sim. - - - {/* Team Role Information */} - - Team Role: {getRoleLabel(organizationRole)} - - - {organizationRole === 'admin' - ? "As a Team Admin, you'll be able to manage team members, billing, and workspace access." - : "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."} - - - {/* Workspace Invitations */} - {hasWorkspaces && ( - <> - - - Workspace Access ({workspaceInvitations.length} workspace - {workspaceInvitations.length !== 1 ? 's' : ''}): - - - {workspaceInvitations.map((ws) => ( - - • {ws.workspaceName} - {getPermissionLabel(ws.permission)} - - ))} - - )} - - - Accept Invitation - - - - By accepting this invitation, you'll join {organizationName} - {hasWorkspaces - ? ` and gain access to ${workspaceInvitations.length} workspace(s)` - : ''} - . - - - - This invitation will expire in 7 days. If you didn't expect this invitation, you can - safely ignore this email. - - - - Best regards, -
- The Sim Team -
-
-
- - - - - ) -} - -export default BatchInvitationEmail diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx index 8668ef9f4..b2c62a0a0 100644 --- a/apps/sim/components/emails/billing/credit-purchase-email.tsx +++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx @@ -1,19 +1,6 @@ -import { - Body, - Column, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { Link, Section, Text } from '@react-email/components' +import { baseStyles, colors } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -36,89 +23,74 @@ export function CreditPurchaseEmail({ const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account` return ( - - - {previewText} - - -
- - - {brand.name} - - -
+ + + {userName ? `Hi ${userName},` : 'Hi,'} + + + Your credit purchase of ${amount.toFixed(2)} has been confirmed. + -
- - - - - -
+
+ + Amount Added + + + ${amount.toFixed(2)} + + + New Balance + + + ${newBalance.toFixed(2)} + +
-
- - {userName ? `Hi ${userName},` : 'Hi,'} - - - Your credit purchase of ${amount.toFixed(2)} has been confirmed. - + + Credits are applied automatically to your workflow executions. + -
- Amount Added - - ${amount.toFixed(2)} - - New Balance - - ${newBalance.toFixed(2)} - -
+ + View Dashboard + - - These credits will be applied automatically to your workflow executions. Credits are - consumed before any overage charges apply. - + {/* Divider */} +
- - View Dashboard - - -
- - - You can view your credit balance and purchase history in Settings → Subscription. - - - - Best regards, -
- The Sim Team -
- - - Purchased on {purchaseDate.toLocaleDateString()} - -
-
- - - + + Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription. + + ) } diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx index f65930ad2..28afdcb72 100644 --- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx +++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx @@ -1,120 +1,50 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { format } from 'date-fns' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/core/utils/urls' interface EnterpriseSubscriptionEmailProps { userName?: string - userEmail?: string loginLink?: string - createdDate?: Date } -export const EnterpriseSubscriptionEmail = ({ +export function EnterpriseSubscriptionEmail({ userName = 'Valued User', - userEmail = '', loginLink, - createdDate = new Date(), -}: EnterpriseSubscriptionEmailProps) => { +}: EnterpriseSubscriptionEmailProps) { const brand = getBrandConfig() const baseUrl = getBaseUrl() const effectiveLoginLink = loginLink || `${baseUrl}/login` return ( - - - - Your Enterprise Plan is now active on Sim - -
- - - {brand.name} - - -
+ + Hello {userName}, + + Your Enterprise Plan is now active. You have full access to advanced + features and increased capacity for your workflows. + -
- - - - - -
+ + Open {brand.name} + -
- Hello {userName}, - - Great news! Your Enterprise Plan has been activated on Sim. You now - have access to advanced features and increased capacity for your workflows. - + + Next steps: +
• Invite team members to your organization +
• Start building your workflows +
- - Your account has been set up with full access to your organization. Click below to log - in and start exploring your new Enterprise features: - + {/* Divider */} +
- - Access Your Enterprise Account - - - - What's next? - - - • Invite team members to your organization -
• Begin building your workflows -
- - - If you have any questions or need assistance getting started, our support team is here - to help. - - - - Best regards, -
- The Sim Team -
- - - This email was sent on {format(createdDate, 'MMMM do, yyyy')} to {userEmail} - regarding your Enterprise plan activation on Sim. - -
-
- - - - + + Questions? Reply to this email or contact us at{' '} + + {brand.supportEmail} + + + ) } diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index 857a3e9a9..c18d7bc17 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -1,21 +1,7 @@ -import { - Body, - Column, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { Link, Section, Text } from '@react-email/components' +import { baseStyles, colors, typography } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' interface FreeTierUpgradeEmailProps { userName?: string @@ -23,119 +9,105 @@ interface FreeTierUpgradeEmailProps { currentUsage: number limit: number upgradeLink: string - updatedDate?: Date } +const proFeatures = [ + { label: '$20/month', desc: 'in credits included' }, + { label: '25 runs/min', desc: 'sync executions' }, + { label: '200 runs/min', desc: 'async executions' }, + { label: '50GB storage', desc: 'for files & assets' }, + { label: 'Unlimited', desc: 'workspaces & invites' }, +] + export function FreeTierUpgradeEmail({ userName, percentUsed, currentUsage, limit, upgradeLink, - updatedDate = new Date(), }: FreeTierUpgradeEmailProps) { const brand = getBrandConfig() - const baseUrl = getBaseUrl() const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits` return ( - - - {previewText} - - -
- - - {brand.name} + + {userName ? `Hi ${userName},` : 'Hi,'} + + + + You've used ${currentUsage.toFixed(2)} of your{' '} + ${limit.toFixed(2)} free credits ({percentUsed}%). Upgrade to Pro to keep + building without interruption. + + + {/* Pro Features */} +
+ + Pro includes + + + + {proFeatures.map((feature, i) => ( + + + + + ))} + +
- - - + > + {feature.label} + + {feature.desc} +
+
-
- - - - - -
+ + Upgrade to Pro + -
- - {userName ? `Hi ${userName},` : 'Hi,'} - + {/* Divider */} +
- - You've used ${currentUsage.toFixed(2)} of your{' '} - ${limit.toFixed(2)} free credits ({percentUsed}%). - - - - To ensure uninterrupted service and unlock the full power of {brand.name}, upgrade to - Pro today. - - -
- - What you get with Pro: - - - • $20/month in credits – 2x your free tier -
Priority support – Get help when you need it -
Advanced features – Access to premium blocks and - integrations -
No interruptions – Never worry about running out of credits -
-
- -
- - Upgrade now to keep building without limits. - - - Upgrade to Pro - - - - Questions? We're here to help. -
-
- Best regards, -
- The {brand.name} Team -
- - - Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 90%. - -
- - - - - + + One-time notification at 90% usage. + + ) } diff --git a/apps/sim/components/emails/billing/index.ts b/apps/sim/components/emails/billing/index.ts new file mode 100644 index 000000000..81f25ebfb --- /dev/null +++ b/apps/sim/components/emails/billing/index.ts @@ -0,0 +1,6 @@ +export { CreditPurchaseEmail } from './credit-purchase-email' +export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email' +export { FreeTierUpgradeEmail } from './free-tier-upgrade-email' +export { PaymentFailedEmail } from './payment-failed-email' +export { PlanWelcomeEmail } from './plan-welcome-email' +export { UsageThresholdEmail } from './usage-threshold-email' diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index 17d9087a2..eb982fe39 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,21 +1,7 @@ -import { - Body, - Column, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { Link, Section, Text } from '@react-email/components' +import { baseStyles, colors } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' interface PaymentFailedEmailProps { userName?: string @@ -35,132 +21,88 @@ export function PaymentFailedEmail({ sentDate = new Date(), }: PaymentFailedEmailProps) { const brand = getBrandConfig() - const baseUrl = getBaseUrl() const previewText = `${brand.name}: Payment Failed - Action Required` return ( - - - {previewText} - - -
- - - {brand.name} - - -
+ + + {userName ? `Hi ${userName},` : 'Hi,'} + -
- - - - - -
+ + We were unable to process your payment. + -
- - {userName ? `Hi ${userName},` : 'Hi,'} - + + Your {brand.name} account has been temporarily blocked to prevent service interruptions and + unexpected charges. To restore access immediately, please update your payment method. + - - We were unable to process your payment. - +
+ + Payment Details + + + Amount due: ${amountDue.toFixed(2)} + + {lastFourDigits && ( + + Payment method: •••• {lastFourDigits} + + )} + {failureReason && ( + Reason: {failureReason} + )} +
- - Your {brand.name} account has been temporarily blocked to prevent service - interruptions and unexpected charges. To restore access immediately, please update - your payment method. - + + Update Payment Method + -
- - - - Payment Details - - - Amount due: ${amountDue.toFixed(2)} - - {lastFourDigits && ( - - Payment method: •••• {lastFourDigits} - - )} - {failureReason && ( - - Reason: {failureReason} - - )} - - -
+ {/* Divider */} +
- - Update Payment Method - + What happens next? -
+ + • Your workflows and automations are currently paused +
• Update your payment method to restore service immediately +
• Stripe will automatically retry the charge once payment is updated +
- - What happens next? - + {/* Divider */} +
- - • Your workflows and automations are currently paused -
• Update your payment method to restore service immediately -
• Stripe will automatically retry the charge once payment is updated -
- -
- - - Need help? - - - - Common reasons for payment failures include expired cards, insufficient funds, or - incorrect billing information. If you continue to experience issues, please{' '} - - contact our support team - - . - - - - Best regards, -
- The Sim Team -
- - - Sent on {sentDate.toLocaleDateString()} • This is a critical transactional - notification. - -
-
- - - - + + Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '} + + {brand.supportEmail} + + + ) } diff --git a/apps/sim/components/emails/billing/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx index fbac398b8..253d4a39f 100644 --- a/apps/sim/components/emails/billing/plan-welcome-email.tsx +++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx @@ -1,19 +1,6 @@ -import { - Body, - Column, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -21,15 +8,9 @@ interface PlanWelcomeEmailProps { planName: 'Pro' | 'Team' userName?: string loginLink?: string - createdDate?: Date } -export function PlanWelcomeEmail({ - planName, - userName, - loginLink, - createdDate = new Date(), -}: PlanWelcomeEmailProps) { +export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) { const brand = getBrandConfig() const baseUrl = getBaseUrl() const cta = loginLink || `${baseUrl}/login` @@ -37,76 +18,34 @@ export function PlanWelcomeEmail({ const previewText = `${brand.name}: Your ${planName} plan is active` return ( - - - {previewText} - - -
- - - {brand.name} - - -
+ + + {userName ? `Hi ${userName},` : 'Hi,'} + + + Welcome to {planName}! You're all set to build, test, and scale your + workflows. + -
- - - - - -
+ + Open {brand.name} + -
- - {userName ? `Hi ${userName},` : 'Hi,'} - - - Welcome to the {planName} plan on {brand.name}. You're all set to - build, test, and scale your agentic workflows. - + + Want help getting started?{' '} + + Schedule a call + {' '} + with our team. + - - Open {brand.name} - + {/* Divider */} +
- - Want to discuss your plan or get personalized help getting started?{' '} - - Schedule a 15-minute call - {' '} - with our team. - - -
- - - Need to invite teammates, adjust usage limits, or manage billing? You can do that from - Settings → Subscription. - - - - Best regards, -
- The Sim Team -
- - - Sent on {createdDate.toLocaleDateString()} - -
-
- - - + + Manage your subscription in Settings → Subscription. + + ) } diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx index 1885e2e60..fdcb01c32 100644 --- a/apps/sim/components/emails/billing/usage-threshold-email.tsx +++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx @@ -1,21 +1,7 @@ -import { - Body, - Column, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { Link, Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' interface UsageThresholdEmailProps { userName?: string @@ -24,7 +10,6 @@ interface UsageThresholdEmailProps { currentUsage: number limit: number ctaLink: string - updatedDate?: Date } export function UsageThresholdEmail({ @@ -34,89 +19,46 @@ export function UsageThresholdEmail({ currentUsage, limit, ctaLink, - updatedDate = new Date(), }: UsageThresholdEmailProps) { const brand = getBrandConfig() - const baseUrl = getBaseUrl() const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget` return ( - - - {previewText} - - -
- - - {brand.name} - - -
+ + + {userName ? `Hi ${userName},` : 'Hi,'} + -
- - - - - -
+ + You're approaching your monthly budget on the {planName} plan. + -
- - {userName ? `Hi ${userName},` : 'Hi,'} - +
+ Usage + + ${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%) + +
- - You're approaching your monthly budget on the {planName} plan. - + {/* Divider */} +
-
- - - - Usage - - - ${currentUsage.toFixed(2)} of ${limit.toFixed(2)} used ({percentUsed}%) - - - -
+ + To avoid interruptions, consider increasing your monthly limit. + -
+ + Review Limits + - - To avoid interruptions, consider increasing your monthly limit. - + {/* Divider */} +
- - Review limits - - - - Best regards, -
- The Sim Team -
- - - Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 80%. - -
-
- - - - + + One-time notification at 80% usage. + + ) } diff --git a/apps/sim/components/emails/careers/careers-confirmation-email.tsx b/apps/sim/components/emails/careers/careers-confirmation-email.tsx index 6c15c1c09..beb07e1da 100644 --- a/apps/sim/components/emails/careers/careers-confirmation-email.tsx +++ b/apps/sim/components/emails/careers/careers-confirmation-email.tsx @@ -1,18 +1,7 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Preview, - Row, - Section, - Text, -} from '@react-email/components' +import { Text } from '@react-email/components' import { format } from 'date-fns' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -22,96 +11,46 @@ interface CareersConfirmationEmailProps { submittedDate?: Date } -export const CareersConfirmationEmail = ({ +export function CareersConfirmationEmail({ name, position, submittedDate = new Date(), -}: CareersConfirmationEmailProps) => { +}: CareersConfirmationEmailProps) { const brand = getBrandConfig() const baseUrl = getBaseUrl() return ( - - - - Your application to {brand.name} has been received - -
- - - {brand.name} - - -
+ + Hello {name}, + + We've received your application for {position}. Our team reviews every + application and will reach out if there's a match. + -
- - - - - -
+ + In the meantime, explore our{' '} + + docs + {' '} + or{' '} + + blog + {' '} + to learn more about what we're building. + -
- Hello {name}, - - Thank you for your interest in joining the {brand.name} team! We've received your - application for the {position} position. - + {/* Divider */} +
- - Our team carefully reviews every application and will get back to you within the next - few weeks. If your qualifications match what we're looking for, we'll reach out to - schedule an initial conversation. - - - - In the meantime, feel free to explore our{' '} - - documentation - {' '} - to learn more about what we're building, or check out our{' '} - - blog - {' '} - for the latest updates. - - - - Best regards, -
- The {brand.name} Team -
- - - This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} at{' '} - {format(submittedDate, 'h:mm a')}. - -
-
- - - - + + Submitted on {format(submittedDate, 'MMMM do, yyyy')}. + + ) } diff --git a/apps/sim/components/emails/careers/careers-submission-email.tsx b/apps/sim/components/emails/careers/careers-submission-email.tsx index 9beed5d3a..0d12664be 100644 --- a/apps/sim/components/emails/careers/careers-submission-email.tsx +++ b/apps/sim/components/emails/careers/careers-submission-email.tsx @@ -1,19 +1,7 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Preview, - Row, - Section, - Text, -} from '@react-email/components' +import { Section, Text } from '@react-email/components' import { format } from 'date-fns' -import { baseStyles } from '@/components/emails/base-styles' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { baseStyles, colors } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' interface CareersSubmissionEmailProps { name: string @@ -39,7 +27,7 @@ const getExperienceLabel = (experience: string) => { return labels[experience] || experience } -export const CareersSubmissionEmail = ({ +export function CareersSubmissionEmail({ name, email, phone, @@ -50,263 +38,299 @@ export const CareersSubmissionEmail = ({ location, message, submittedDate = new Date(), -}: CareersSubmissionEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - +}: CareersSubmissionEmailProps) { return ( - - - - New Career Application from {name} - -
- - - {brand.name} - - -
+ + + New Career Application + -
- - - - - -
+ + A new career application has been submitted on {format(submittedDate, 'MMMM do, yyyy')} at{' '} + {format(submittedDate, 'h:mm a')}. + -
- - New Career Application - + {/* Applicant Information */} +
+ + Applicant Information + - - A new career application has been submitted on{' '} - {format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}. - - - {/* Applicant Information */} -
- + + + - Applicant Information - - - - - - - - - - - - {phone && ( - - - - - )} - - - - - - - - - - - - - {linkedin && ( - - - - - )} - {portfolio && ( - - - - - )} -
- Name: - {name}
- Email: - - - {email} - -
- Phone: - - - {phone} - -
- Position: - - {position} -
- Experience: - - {getExperienceLabel(experience)} -
- Location: - - {location} -
- LinkedIn: - - - View Profile - -
- Portfolio: - - - View Portfolio - -
-
- - {/* Message */} -
- - About Themselves - - - {message} - -
+ Name: + + + {name} + + + + + Email: + + + + {email} + + + + {phone && ( + + + Phone: + + + + {phone} + + + + )} + + + Position: + + + {position} + + + + + Experience: + + + {getExperienceLabel(experience)} + + + + + Location: + + + {location} + + + {linkedin && ( + + + LinkedIn: + + + + View Profile + + + + )} + {portfolio && ( + + + Portfolio: + + + + View Portfolio + + + + )} + + +
- - Please review this application and reach out to the candidate at your earliest - convenience. - -
-
- - + {/* Message */} +
+ + About Themselves + + + {message} + +
+ ) } diff --git a/apps/sim/components/emails/careers/index.ts b/apps/sim/components/emails/careers/index.ts new file mode 100644 index 000000000..b4d335415 --- /dev/null +++ b/apps/sim/components/emails/careers/index.ts @@ -0,0 +1,2 @@ +export { CareersConfirmationEmail } from './careers-confirmation-email' +export { CareersSubmissionEmail } from './careers-submission-email' diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx new file mode 100644 index 000000000..76ef355ee --- /dev/null +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -0,0 +1,233 @@ +import { Container, Img, Link, Section } from '@react-email/components' +import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles' +import { getBrandConfig } from '@/lib/branding/branding' +import { isHosted } from '@/lib/core/config/feature-flags' +import { getBaseUrl } from '@/lib/core/utils/urls' + +interface UnsubscribeOptions { + unsubscribeToken?: string + email?: string +} + +interface EmailFooterProps { + baseUrl?: string + unsubscribe?: UnsubscribeOptions + messageId?: string +} + +/** + * Email footer component styled to match Stripe's email design. + * Sits in the gray area below the main white card. + */ +export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) { + const brand = getBrandConfig() + + const footerLinkStyle = { + color: colors.textMuted, + textDecoration: 'underline', + fontWeight: 'normal' as const, + fontFamily: typography.fontFamily, + } + + return ( +
+ + + + + + + + {/* Social links row */} + + + + + + + + + + + {/* Address row */} + + + + + + + + + + + {/* Contact row */} + + + + + + + + + + + {/* Message ID row (optional) */} + {messageId && ( + <> + + + + + + + + + + )} + + {/* Links row */} + + + + + + + {/* Copyright row */} + + + + + + + + + + + + + +
+   +
+   + + + + + + + + + +
+ + X + + + + Discord + + + + GitHub + +
+
+   +
+   +
+   + + {brand.name} + {isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA} + +   +
+   +
+   + + Questions?{' '} + + {brand.supportEmail} + + +   +
+   +
+   + + Need to refer to this message? Use this ID: {messageId} + +   +
+   +
+   + + + Privacy Policy + {' '} + •{' '} + + Terms of Service + {' '} + •{' '} + + Unsubscribe + + +   +
+   +
+   + + © {new Date().getFullYear()} {brand.name}, All Rights Reserved + +   +
+   +
+
+
+ ) +} + +export default EmailFooter diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx new file mode 100644 index 000000000..4f6f5395e --- /dev/null +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -0,0 +1,52 @@ +import { Body, Container, Head, Html, Img, Preview, Section } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailFooter } from '@/components/emails/components/email-footer' +import { getBrandConfig } from '@/lib/branding/branding' +import { getBaseUrl } from '@/lib/core/utils/urls' + +interface EmailLayoutProps { + /** Preview text shown in email client list view */ + preview: string + /** Email content to render inside the layout */ + children: React.ReactNode + /** Optional: hide footer for internal emails */ + hideFooter?: boolean +} + +/** + * Shared email layout wrapper providing consistent structure. + * Includes Html, Head, Body, Container with logo header, and Footer. + */ +export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) { + const brand = getBrandConfig() + const baseUrl = getBaseUrl() + + return ( + + + {preview} + + {/* Main card container */} + + {/* Header with logo */} +
+ {brand.name} +
+ + {/* Content */} +
{children}
+
+ + {/* Footer in gray section */} + {!hideFooter && } + + + ) +} + +export default EmailLayout diff --git a/apps/sim/components/emails/components/index.ts b/apps/sim/components/emails/components/index.ts new file mode 100644 index 000000000..d7c1d712a --- /dev/null +++ b/apps/sim/components/emails/components/index.ts @@ -0,0 +1,2 @@ +export { EmailFooter } from './email-footer' +export { EmailLayout } from './email-layout' diff --git a/apps/sim/components/emails/footer.tsx b/apps/sim/components/emails/footer.tsx deleted file mode 100644 index f6eb4044d..000000000 --- a/apps/sim/components/emails/footer.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Container, Img, Link, Section, Text } from '@react-email/components' -import { getBrandConfig } from '@/lib/branding/branding' -import { isHosted } from '@/lib/core/config/feature-flags' -import { getBaseUrl } from '@/lib/core/utils/urls' - -interface UnsubscribeOptions { - unsubscribeToken?: string - email?: string -} - -interface EmailFooterProps { - baseUrl?: string - unsubscribe?: UnsubscribeOptions -} - -export const EmailFooter = ({ baseUrl = getBaseUrl(), unsubscribe }: EmailFooterProps) => { - const brand = getBrandConfig() - - return ( - -
- - - - - - - -
- - - - - - -
- - X - - - - Discord - - - - GitHub - -
-
- - © {new Date().getFullYear()} {brand.name}, All Rights Reserved -
- If you have any questions, please contact us at{' '} - - {brand.supportEmail} - - {isHosted && ( - <> -
- Sim, 80 Langton St, San Francisco, CA 94133, USA - - )} -
- - - - -
-

- - Privacy Policy - {' '} - •{' '} - - Terms of Service - {' '} - •{' '} - - Unsubscribe - -

-
-
-
-
- ) -} - -export default EmailFooter diff --git a/apps/sim/components/emails/help-confirmation-email.tsx b/apps/sim/components/emails/help-confirmation-email.tsx deleted file mode 100644 index 834fd233e..000000000 --- a/apps/sim/components/emails/help-confirmation-email.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { format } from 'date-fns' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' - -interface HelpConfirmationEmailProps { - userEmail?: string - type?: 'bug' | 'feedback' | 'feature_request' | 'other' - attachmentCount?: number - submittedDate?: Date -} - -const getTypeLabel = (type: string) => { - switch (type) { - case 'bug': - return 'Bug Report' - case 'feedback': - return 'Feedback' - case 'feature_request': - return 'Feature Request' - case 'other': - return 'General Inquiry' - default: - return 'Request' - } -} - -export const HelpConfirmationEmail = ({ - userEmail = '', - type = 'other', - attachmentCount = 0, - submittedDate = new Date(), -}: HelpConfirmationEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - const typeLabel = getTypeLabel(type) - - return ( - - - - Your {typeLabel.toLowerCase()} has been received - -
- - - {brand.name} - - -
- -
- - - - - -
- -
- Hello, - - Thank you for your {typeLabel.toLowerCase()} submission. We've - received your request and will get back to you as soon as possible. - - - {attachmentCount > 0 && ( - - You attached{' '} - - {attachmentCount} image{attachmentCount > 1 ? 's' : ''} - {' '} - with your request. - - )} - - - We typically respond to{' '} - {type === 'bug' - ? 'bug reports' - : type === 'feature_request' - ? 'feature requests' - : 'inquiries'}{' '} - within a few hours. If you need immediate assistance, please don't hesitate to reach - out to us directly. - - - - Best regards, -
- The {brand.name} Team -
- - - This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} for your{' '} - {typeLabel.toLowerCase()} submission from {userEmail}. - -
-
- - - - - ) -} - -export default HelpConfirmationEmail diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index d2d7d70d0..6cd1fea0d 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -1,12 +1,17 @@ -export * from './base-styles' -export { BatchInvitationEmail } from './batch-invitation-email' -export { EnterpriseSubscriptionEmail } from './billing/enterprise-subscription-email' -export { PlanWelcomeEmail } from './billing/plan-welcome-email' -export { UsageThresholdEmail } from './billing/usage-threshold-email' -export { default as EmailFooter } from './footer' -export { HelpConfirmationEmail } from './help-confirmation-email' -export { InvitationEmail } from './invitation-email' -export { OTPVerificationEmail } from './otp-verification-email' -export * from './render-email' -export { ResetPasswordEmail } from './reset-password-email' -export { WorkspaceInvitationEmail } from './workspace-invitation' +// Styles +export * from './_styles' +// Auth emails +export * from './auth' +// Billing emails +export * from './billing' +// Careers emails +export * from './careers' +// Shared components +export * from './components' +// Invitation emails +export * from './invitations' +// Render functions and subjects +export * from './render' +export * from './subjects' +// Support emails +export * from './support' diff --git a/apps/sim/components/emails/invitation-email.tsx b/apps/sim/components/emails/invitation-email.tsx deleted file mode 100644 index 42c769354..000000000 --- a/apps/sim/components/emails/invitation-email.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { createLogger } from '@sim/logger' -import { format } from 'date-fns' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' - -interface InvitationEmailProps { - inviterName?: string - organizationName?: string - inviteLink?: string - invitedEmail?: string - updatedDate?: Date -} - -const logger = createLogger('InvitationEmail') - -export const InvitationEmail = ({ - inviterName = 'A team member', - organizationName = 'an organization', - inviteLink = '', - invitedEmail = '', - updatedDate = new Date(), -}: InvitationEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - - // Extract invitation ID or token from inviteLink if present - let enhancedLink = inviteLink - - // Check if link contains an ID (old format) and append token parameter if needed - if (inviteLink && !inviteLink.includes('token=')) { - try { - const url = new URL(inviteLink) - const invitationId = url.pathname.split('/').pop() - if (invitationId) { - enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}` - } - } catch (e) { - logger.error('Error parsing invite link:', e) - } - } - - return ( - - - - You've been invited to join {organizationName} on Sim - -
- - - {brand.name} - - -
- -
- - - - - -
- -
- Hello, - - {inviterName} has invited you to join{' '} - {organizationName} on Sim. Sim is a powerful, user-friendly platform - for building, testing, and optimizing agentic workflows. - - - Accept Invitation - - - This invitation will expire in 48 hours. If you believe this invitation was sent in - error, please ignore this email. - - - Best regards, -
- The Sim Team -
- - This email was sent on {format(updatedDate, 'MMMM do, yyyy')} to {invitedEmail} with - an invitation to join {organizationName} on Sim. - -
-
- - - - - ) -} - -export default InvitationEmail diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx new file mode 100644 index 000000000..b955af326 --- /dev/null +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -0,0 +1,105 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' + +interface WorkspaceInvitation { + workspaceId: string + workspaceName: string + permission: 'admin' | 'write' | 'read' +} + +interface BatchInvitationEmailProps { + inviterName: string + organizationName: string + organizationRole: 'admin' | 'member' + workspaceInvitations: WorkspaceInvitation[] + acceptUrl: string +} + +const getPermissionLabel = (permission: string) => { + switch (permission) { + case 'admin': + return 'Admin (full access)' + case 'write': + return 'Editor (can edit workflows)' + case 'read': + return 'Viewer (read-only access)' + default: + return permission + } +} + +const getRoleLabel = (role: string) => { + switch (role) { + case 'admin': + return 'Admin' + case 'member': + return 'Member' + default: + return role + } +} + +export function BatchInvitationEmail({ + inviterName = 'Someone', + organizationName = 'the team', + organizationRole = 'member', + workspaceInvitations = [], + acceptUrl, +}: BatchInvitationEmailProps) { + const brand = getBrandConfig() + const hasWorkspaces = workspaceInvitations.length > 0 + + return ( + + Hello, + + {inviterName} has invited you to join {organizationName}{' '} + on {brand.name}. + + + {/* Team Role Information */} + + Team Role: {getRoleLabel(organizationRole)} + + + {organizationRole === 'admin' + ? "As a Team Admin, you'll be able to manage team members, billing, and workspace access." + : "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."} + + + {/* Workspace Invitations */} + {hasWorkspaces && ( + <> + + + Workspace Access ({workspaceInvitations.length} workspace + {workspaceInvitations.length !== 1 ? 's' : ''}): + + + {workspaceInvitations.map((ws) => ( + + • {ws.workspaceName} - {getPermissionLabel(ws.permission)} + + ))} + + )} + + + Accept Invitation + + + {/* Divider */} +
+ + + Invitation expires in 7 days. If unexpected, you can ignore this email. + + + ) +} + +export default BatchInvitationEmail diff --git a/apps/sim/components/emails/invitations/index.ts b/apps/sim/components/emails/invitations/index.ts new file mode 100644 index 000000000..2133bad90 --- /dev/null +++ b/apps/sim/components/emails/invitations/index.ts @@ -0,0 +1,3 @@ +export { BatchInvitationEmail } from './batch-invitation-email' +export { InvitationEmail } from './invitation-email' +export { WorkspaceInvitationEmail } from './workspace-invitation-email' diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx new file mode 100644 index 000000000..fae1ec1c8 --- /dev/null +++ b/apps/sim/components/emails/invitations/invitation-email.tsx @@ -0,0 +1,60 @@ +import { Link, Text } from '@react-email/components' +import { createLogger } from '@sim/logger' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' +import { getBaseUrl } from '@/lib/core/utils/urls' + +interface InvitationEmailProps { + inviterName?: string + organizationName?: string + inviteLink?: string +} + +const logger = createLogger('InvitationEmail') + +export function InvitationEmail({ + inviterName = 'A team member', + organizationName = 'an organization', + inviteLink = '', +}: InvitationEmailProps) { + const brand = getBrandConfig() + const baseUrl = getBaseUrl() + + let enhancedLink = inviteLink + + if (inviteLink && !inviteLink.includes('token=')) { + try { + const url = new URL(inviteLink) + const invitationId = url.pathname.split('/').pop() + if (invitationId) { + enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}` + } + } catch (e) { + logger.error('Error parsing invite link:', e) + } + } + + return ( + + Hello, + + {inviterName} invited you to join {organizationName} on{' '} + {brand.name}. + + + + Accept Invitation + + + {/* Divider */} +
+ + + Invitation expires in 48 hours. If unexpected, you can ignore this email. + + + ) +} + +export default InvitationEmail diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx new file mode 100644 index 000000000..f9c2dca54 --- /dev/null +++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx @@ -0,0 +1,65 @@ +import { Link, Text } from '@react-email/components' +import { createLogger } from '@sim/logger' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' +import { getBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('WorkspaceInvitationEmail') + +interface WorkspaceInvitationEmailProps { + workspaceName?: string + inviterName?: string + invitationLink?: string +} + +export function WorkspaceInvitationEmail({ + workspaceName = 'Workspace', + inviterName = 'Someone', + invitationLink = '', +}: WorkspaceInvitationEmailProps) { + const brand = getBrandConfig() + const baseUrl = getBaseUrl() + + let enhancedLink = invitationLink + + try { + if ( + invitationLink.includes('/api/workspaces/invitations/accept') || + invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/) + ) { + const url = new URL(invitationLink) + const token = url.searchParams.get('token') + if (token) { + enhancedLink = `${baseUrl}/invite/${token}?token=${token}` + } + } + } catch (e) { + logger.error('Error enhancing invitation link:', e) + } + + return ( + + Hello, + + {inviterName} invited you to join the {workspaceName}{' '} + workspace on {brand.name}. + + + + Accept Invitation + + + {/* Divider */} +
+ + + Invitation expires in 7 days. If unexpected, you can ignore this email. + + + ) +} + +export default WorkspaceInvitationEmail diff --git a/apps/sim/components/emails/otp-verification-email.tsx b/apps/sim/components/emails/otp-verification-email.tsx deleted file mode 100644 index 1fdaa87ff..000000000 --- a/apps/sim/components/emails/otp-verification-email.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' - -interface OTPVerificationEmailProps { - otp: string - email?: string - type?: 'sign-in' | 'email-verification' | 'forget-password' | 'chat-access' - chatTitle?: string -} - -const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => { - switch (type) { - case 'sign-in': - return `Sign in to ${brandName}` - case 'email-verification': - return `Verify your email for ${brandName}` - case 'forget-password': - return `Reset your ${brandName} password` - case 'chat-access': - return `Verification code for ${chatTitle || 'Chat'}` - default: - return `Verification code for ${brandName}` - } -} - -export const OTPVerificationEmail = ({ - otp, - email = '', - type = 'email-verification', - chatTitle, -}: OTPVerificationEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - - // Get a message based on the type - const getMessage = () => { - switch (type) { - case 'sign-in': - return `Sign in to ${brand.name}` - case 'forget-password': - return `Reset your password for ${brand.name}` - case 'chat-access': - return `Access ${chatTitle || 'the chat'}` - default: - return `Welcome to ${brand.name}` - } - } - - return ( - - - - {getSubjectByType(type, brand.name, chatTitle)} - -
- - - {brand.name} - - -
-
- - - - - -
-
- {getMessage()} - Your verification code is: -
- {otp} -
- This code will expire in 15 minutes. - - If you didn't request this code, you can safely ignore this email. - - - Best regards, -
- The Sim Team -
-
-
- - - - - ) -} - -export default OTPVerificationEmail diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render.ts similarity index 59% rename from apps/sim/components/emails/render-email.ts rename to apps/sim/components/emails/render.ts index 15efb1cf1..bd18eeedc 100644 --- a/apps/sim/components/emails/render-email.ts +++ b/apps/sim/components/emails/render.ts @@ -1,19 +1,31 @@ import { render } from '@react-email/components' +import { OTPVerificationEmail, ResetPasswordEmail, WelcomeEmail } from '@/components/emails/auth' +import { + CreditPurchaseEmail, + EnterpriseSubscriptionEmail, + FreeTierUpgradeEmail, + PaymentFailedEmail, + PlanWelcomeEmail, + UsageThresholdEmail, +} from '@/components/emails/billing' +import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/emails/careers' import { BatchInvitationEmail, - EnterpriseSubscriptionEmail, - HelpConfirmationEmail, InvitationEmail, - OTPVerificationEmail, - PlanWelcomeEmail, - ResetPasswordEmail, - UsageThresholdEmail, -} from '@/components/emails' -import CreditPurchaseEmail from '@/components/emails/billing/credit-purchase-email' -import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email' -import { getBrandConfig } from '@/lib/branding/branding' + WorkspaceInvitationEmail, +} from '@/components/emails/invitations' +import { HelpConfirmationEmail } from '@/components/emails/support' import { getBaseUrl } from '@/lib/core/utils/urls' +export type { EmailSubjectType } from './subjects' +export { getEmailSubject } from './subjects' + +interface WorkspaceInvitation { + workspaceId: string + workspaceName: string + permission: 'admin' | 'write' | 'read' +} + export async function renderOTPEmail( otp: string, email: string, @@ -27,34 +39,23 @@ export async function renderPasswordResetEmail( username: string, resetLink: string ): Promise { - return await render( - ResetPasswordEmail({ username, resetLink: resetLink, updatedDate: new Date() }) - ) + return await render(ResetPasswordEmail({ username, resetLink })) } export async function renderInvitationEmail( inviterName: string, organizationName: string, - invitationUrl: string, - email: string + invitationUrl: string ): Promise { return await render( InvitationEmail({ inviterName, organizationName, inviteLink: invitationUrl, - invitedEmail: email, - updatedDate: new Date(), }) ) } -interface WorkspaceInvitation { - workspaceId: string - workspaceName: string - permission: 'admin' | 'write' | 'read' -} - export async function renderBatchInvitationEmail( inviterName: string, organizationName: string, @@ -74,13 +75,11 @@ export async function renderBatchInvitationEmail( } export async function renderHelpConfirmationEmail( - userEmail: string, type: 'bug' | 'feedback' | 'feature_request' | 'other', attachmentCount = 0 ): Promise { return await render( HelpConfirmationEmail({ - userEmail, type, attachmentCount, submittedDate: new Date(), @@ -88,19 +87,14 @@ export async function renderHelpConfirmationEmail( ) } -export async function renderEnterpriseSubscriptionEmail( - userName: string, - userEmail: string -): Promise { +export async function renderEnterpriseSubscriptionEmail(userName: string): Promise { const baseUrl = getBaseUrl() const loginLink = `${baseUrl}/login` return await render( EnterpriseSubscriptionEmail({ userName, - userEmail, loginLink, - createdDate: new Date(), }) ) } @@ -121,7 +115,6 @@ export async function renderUsageThresholdEmail(params: { currentUsage: params.currentUsage, limit: params.limit, ctaLink: params.ctaLink, - updatedDate: new Date(), }) ) } @@ -140,61 +133,10 @@ export async function renderFreeTierUpgradeEmail(params: { currentUsage: params.currentUsage, limit: params.limit, upgradeLink: params.upgradeLink, - updatedDate: new Date(), }) ) } -export function getEmailSubject( - type: - | 'sign-in' - | 'email-verification' - | 'forget-password' - | 'reset-password' - | 'invitation' - | 'batch-invitation' - | 'help-confirmation' - | 'enterprise-subscription' - | 'usage-threshold' - | 'free-tier-upgrade' - | 'plan-welcome-pro' - | 'plan-welcome-team' - | 'credit-purchase' -): string { - const brandName = getBrandConfig().name - - switch (type) { - case 'sign-in': - return `Sign in to ${brandName}` - case 'email-verification': - return `Verify your email for ${brandName}` - case 'forget-password': - return `Reset your ${brandName} password` - case 'reset-password': - return `Reset your ${brandName} password` - case 'invitation': - return `You've been invited to join a team on ${brandName}` - case 'batch-invitation': - return `You've been invited to join a team and workspaces on ${brandName}` - case 'help-confirmation': - return 'Your request has been received' - case 'enterprise-subscription': - return `Your Enterprise Plan is now active on ${brandName}` - case 'usage-threshold': - return `You're nearing your monthly budget on ${brandName}` - case 'free-tier-upgrade': - return `You're at 90% of your free credits on ${brandName}` - case 'plan-welcome-pro': - return `Your Pro plan is now active on ${brandName}` - case 'plan-welcome-team': - return `Your Team plan is now active on ${brandName}` - case 'credit-purchase': - return `Credits added to your ${brandName} account` - default: - return brandName - } -} - export async function renderPlanWelcomeEmail(params: { planName: 'Pro' | 'Team' userName?: string @@ -205,11 +147,14 @@ export async function renderPlanWelcomeEmail(params: { planName: params.planName, userName: params.userName, loginLink: params.loginLink, - createdDate: new Date(), }) ) } +export async function renderWelcomeEmail(userName?: string): Promise { + return await render(WelcomeEmail({ userName })) +} + export async function renderCreditPurchaseEmail(params: { userName?: string amount: number @@ -224,3 +169,73 @@ export async function renderCreditPurchaseEmail(params: { }) ) } + +export async function renderWorkspaceInvitationEmail( + inviterName: string, + workspaceName: string, + invitationLink: string +): Promise { + return await render( + WorkspaceInvitationEmail({ + inviterName, + workspaceName, + invitationLink, + }) + ) +} + +export async function renderPaymentFailedEmail(params: { + userName?: string + amountDue: number + lastFourDigits?: string + billingPortalUrl: string + failureReason?: string +}): Promise { + return await render( + PaymentFailedEmail({ + userName: params.userName, + amountDue: params.amountDue, + lastFourDigits: params.lastFourDigits, + billingPortalUrl: params.billingPortalUrl, + failureReason: params.failureReason, + }) + ) +} + +export async function renderCareersConfirmationEmail( + name: string, + position: string +): Promise { + return await render( + CareersConfirmationEmail({ + name, + position, + }) + ) +} + +export async function renderCareersSubmissionEmail(params: { + name: string + email: string + phone?: string + position: string + linkedin?: string + portfolio?: string + experience: string + location: string + message: string +}): Promise { + return await render( + CareersSubmissionEmail({ + name: params.name, + email: params.email, + phone: params.phone, + position: params.position, + linkedin: params.linkedin, + portfolio: params.portfolio, + experience: params.experience, + location: params.location, + message: params.message, + }) + ) +} diff --git a/apps/sim/components/emails/reset-password-email.tsx b/apps/sim/components/emails/reset-password-email.tsx deleted file mode 100644 index 92d86b1ca..000000000 --- a/apps/sim/components/emails/reset-password-email.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { format } from 'date-fns' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' - -interface ResetPasswordEmailProps { - username?: string - resetLink?: string - updatedDate?: Date -} - -export const ResetPasswordEmail = ({ - username = '', - resetLink = '', - updatedDate = new Date(), -}: ResetPasswordEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - - return ( - - - - Reset your {brand.name} password - -
- - - {brand.name} - - -
- -
- - - - - -
- -
- Hello {username}, - - You recently requested to reset your password for your {brand.name} account. Use the - button below to reset it. This password reset is only valid for the next 24 hours. - - - Reset Your Password - - - If you did not request a password reset, please ignore this email or contact support - if you have concerns. - - - Best regards, -
- The {brand.name} Team -
- - This email was sent on {format(updatedDate, 'MMMM do, yyyy')} because a password reset - was requested for your account. - -
-
- - - - - ) -} - -export default ResetPasswordEmail diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts new file mode 100644 index 000000000..26f451270 --- /dev/null +++ b/apps/sim/components/emails/subjects.ts @@ -0,0 +1,60 @@ +import { getBrandConfig } from '@/lib/branding/branding' + +/** Email subject type for all supported email templates */ +export type EmailSubjectType = + | 'sign-in' + | 'email-verification' + | 'forget-password' + | 'reset-password' + | 'invitation' + | 'batch-invitation' + | 'help-confirmation' + | 'enterprise-subscription' + | 'usage-threshold' + | 'free-tier-upgrade' + | 'plan-welcome-pro' + | 'plan-welcome-team' + | 'credit-purchase' + | 'welcome' + +/** + * Returns the email subject line for a given email type. + * @param type - The type of email being sent + * @returns The subject line for the email + */ +export function getEmailSubject(type: EmailSubjectType): string { + const brandName = getBrandConfig().name + + switch (type) { + case 'sign-in': + return `Sign in to ${brandName}` + case 'email-verification': + return `Verify your email for ${brandName}` + case 'forget-password': + return `Reset your ${brandName} password` + case 'reset-password': + return `Reset your ${brandName} password` + case 'invitation': + return `You've been invited to join a team on ${brandName}` + case 'batch-invitation': + return `You've been invited to join a team and workspaces on ${brandName}` + case 'help-confirmation': + return 'Your request has been received' + case 'enterprise-subscription': + return `Your Enterprise Plan is now active on ${brandName}` + case 'usage-threshold': + return `You're nearing your monthly budget on ${brandName}` + case 'free-tier-upgrade': + return `You're at 90% of your free credits on ${brandName}` + case 'plan-welcome-pro': + return `Your Pro plan is now active on ${brandName}` + case 'plan-welcome-team': + return `Your Team plan is now active on ${brandName}` + case 'credit-purchase': + return `Credits added to your ${brandName} account` + case 'welcome': + return `Welcome to ${brandName}` + default: + return brandName + } +} diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx new file mode 100644 index 000000000..354a50826 --- /dev/null +++ b/apps/sim/components/emails/support/help-confirmation-email.tsx @@ -0,0 +1,58 @@ +import { Text } from '@react-email/components' +import { format } from 'date-fns' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' + +interface HelpConfirmationEmailProps { + type?: 'bug' | 'feedback' | 'feature_request' | 'other' + attachmentCount?: number + submittedDate?: Date +} + +const getTypeLabel = (type: string) => { + switch (type) { + case 'bug': + return 'Bug Report' + case 'feedback': + return 'Feedback' + case 'feature_request': + return 'Feature Request' + case 'other': + return 'General Inquiry' + default: + return 'Request' + } +} + +export function HelpConfirmationEmail({ + type = 'other', + attachmentCount = 0, + submittedDate = new Date(), +}: HelpConfirmationEmailProps) { + const typeLabel = getTypeLabel(type) + + return ( + + Hello, + + We've received your {typeLabel.toLowerCase()} and will get back to you + shortly. + + + {attachmentCount > 0 && ( + + {attachmentCount} image{attachmentCount > 1 ? 's' : ''} attached. + + )} + + {/* Divider */} +
+ + + Submitted on {format(submittedDate, 'MMMM do, yyyy')}. + + + ) +} + +export default HelpConfirmationEmail diff --git a/apps/sim/components/emails/support/index.ts b/apps/sim/components/emails/support/index.ts new file mode 100644 index 000000000..5bea3567b --- /dev/null +++ b/apps/sim/components/emails/support/index.ts @@ -0,0 +1 @@ +export { HelpConfirmationEmail } from './help-confirmation-email' diff --git a/apps/sim/components/emails/workspace-invitation.tsx b/apps/sim/components/emails/workspace-invitation.tsx deleted file mode 100644 index dfad9f88a..000000000 --- a/apps/sim/components/emails/workspace-invitation.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Body, - Column, - Container, - Head, - Html, - Img, - Link, - Preview, - Row, - Section, - Text, -} from '@react-email/components' -import { createLogger } from '@sim/logger' -import { baseStyles } from '@/components/emails/base-styles' -import EmailFooter from '@/components/emails/footer' -import { getBrandConfig } from '@/lib/branding/branding' -import { getBaseUrl } from '@/lib/core/utils/urls' - -const logger = createLogger('WorkspaceInvitationEmail') - -interface WorkspaceInvitationEmailProps { - workspaceName?: string - inviterName?: string - invitationLink?: string -} - -export const WorkspaceInvitationEmail = ({ - workspaceName = 'Workspace', - inviterName = 'Someone', - invitationLink = '', -}: WorkspaceInvitationEmailProps) => { - const brand = getBrandConfig() - const baseUrl = getBaseUrl() - - // Extract token from the link to ensure we're using the correct format - let enhancedLink = invitationLink - - try { - // If the link is pointing to any API endpoint directly, update it to use the client route - if ( - invitationLink.includes('/api/workspaces/invitations/accept') || - invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/) - ) { - const url = new URL(invitationLink) - const token = url.searchParams.get('token') - if (token) { - enhancedLink = `${baseUrl}/invite/${token}?token=${token}` - } - } - } catch (e) { - logger.error('Error enhancing invitation link:', e) - } - - return ( - - - - You've been invited to join the "{workspaceName}" workspace on Sim! - -
- - - {brand.name} - - -
- -
- - - - - -
- -
- Hello, - - {inviterName} has invited you to join the "{workspaceName}" workspace on Sim! - - - Sim is a powerful platform for building, testing, and optimizing AI workflows. Join - this workspace to collaborate with your team. - - - Accept Invitation - - - This invitation link will expire in 7 days. If you have any questions or need - assistance, feel free to reach out to our support team. - - - Best regards, -
- The Sim Team -
-
-
- - - - - ) -} - -export default WorkspaceInvitationEmail diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 2708aa52a..3922c4f0b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -21,7 +21,8 @@ import { getEmailSubject, renderOTPEmail, renderPasswordResetEmail, -} from '@/components/emails/render-email' + renderWelcomeEmail, +} from '@/components/emails' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { handleNewUser } from '@/lib/billing/core/usage' @@ -47,19 +48,18 @@ import { isAuthDisabled, isBillingEnabled, isEmailVerificationEnabled, + isHosted, isRegistrationDisabled, } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' -import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { SSO_TRUSTED_PROVIDERS } from './sso/constants' const logger = createLogger('Auth') -// Only initialize Stripe if the key is provided -// This allows local development without a Stripe account const validStripeKey = env.STRIPE_SECRET_KEY let stripeClient = null @@ -104,6 +104,31 @@ export const auth = betterAuth({ error, }) } + + if (isHosted && user.email && user.emailVerified) { + try { + const html = await renderWelcomeEmail(user.name || undefined) + const { from, replyTo } = getPersonalEmailFrom() + + await sendEmail({ + to: user.email, + subject: getEmailSubject('welcome'), + html, + from, + replyTo, + emailType: 'transactional', + }) + + logger.info('[databaseHooks.user.create.after] Welcome email sent to OAuth user', { + userId: user.id, + }) + } catch (error) { + logger.error('[databaseHooks.user.create.after] Failed to send welcome email', { + userId: user.id, + error, + }) + } + } }, }, }, @@ -294,6 +319,35 @@ export const auth = betterAuth({ ], }, }, + emailVerification: { + autoSignInAfterVerification: true, + afterEmailVerification: async (user) => { + if (isHosted && user.email) { + try { + const html = await renderWelcomeEmail(user.name || undefined) + const { from, replyTo } = getPersonalEmailFrom() + + await sendEmail({ + to: user.email, + subject: getEmailSubject('welcome'), + html, + from, + replyTo, + emailType: 'transactional', + }) + + logger.info('[emailVerification.afterEmailVerification] Welcome email sent', { + userId: user.id, + }) + } catch (error) { + logger.error('[emailVerification.afterEmailVerification] Failed to send welcome email', { + userId: user.id, + error, + }) + } + } + }, + }, emailAndPassword: { enabled: true, requireEmailVerification: isEmailVerificationEnabled, diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 6ed490627..9b5f4047b 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -298,9 +298,7 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise { .limit(1) if (users.length > 0 && users[0].email) { - const { getEmailSubject, renderPlanWelcomeEmail } = await import( - '@/components/emails/render-email' - ) + const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails') const { sendEmail } = await import('@/lib/messaging/email/mailer') const baseUrl = getBaseUrl() diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 448351b70..f0c1f4b78 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -6,7 +6,7 @@ import { getEmailSubject, renderFreeTierUpgradeEmail, renderUsageThresholdEmail, -} from '@/components/emails/render-email' +} from '@/components/emails' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { canEditUsageLimit, diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index 83ddcb457..cf20b52b3 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -3,10 +3,7 @@ import { organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type Stripe from 'stripe' -import { - getEmailSubject, - renderEnterpriseSubscriptionEmail, -} from '@/components/emails/render-email' +import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' import type { EnterpriseSubscriptionMetadata } from '../types' @@ -208,7 +205,7 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) { const user = userDetails[0] const org = orgDetails[0] - const html = await renderEnterpriseSubscriptionEmail(user.name || user.email, user.email) + const html = await renderEnterpriseSubscriptionEmail(user.name || user.email) const emailResult = await sendEmail({ to: user.email, diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index a3cafeb6a..e06bbb8fe 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -10,14 +10,14 @@ import { import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import type Stripe from 'stripe' -import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email' -import { getEmailSubject, renderCreditPurchaseEmail } from '@/components/emails/render-email' +import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance' import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' +import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' const logger = createLogger('StripeInvoiceWebhooks') @@ -139,6 +139,7 @@ async function getPaymentMethodDetails( /** * Send payment failure notification emails to affected users + * Note: This is only called when billing is enabled (Stripe plugin loaded) */ async function sendPaymentFailureEmails( sub: { plan: string | null; referenceId: string }, @@ -203,10 +204,13 @@ async function sendPaymentFailureEmails( }) ) + const { from, replyTo } = getPersonalEmailFrom() await sendEmail({ to: userToNotify.email, subject: 'Payment Failed - Action Required', html: emailHtml, + from, + replyTo, emailType: 'transactional', }) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index dc627c01a..b42ae407a 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -60,6 +60,7 @@ export const env = createEnv({ EMAIL_VERIFICATION_ENABLED: z.boolean().optional(), // Enable email verification for user registration and login (defaults to false) RESEND_API_KEY: z.string().min(1).optional(), // Resend API key for transactional emails FROM_EMAIL_ADDRESS: z.string().min(1).optional(), // Complete from address (e.g., "Sim " or "noreply@domain.com") + PERSONAL_EMAIL_FROM: z.string().min(1).optional(), // From address for personalized emails EMAIL_DOMAIN: z.string().min(1).optional(), // Domain for sending emails (fallback when FROM_EMAIL_ADDRESS not set) AZURE_ACS_CONNECTION_STRING: z.string().optional(), // Azure Communication Services connection string diff --git a/apps/sim/lib/messaging/email/utils.ts b/apps/sim/lib/messaging/email/utils.ts index 4b342c5d4..2c26737e1 100644 --- a/apps/sim/lib/messaging/email/utils.ts +++ b/apps/sim/lib/messaging/email/utils.ts @@ -11,3 +11,34 @@ export function getFromEmailAddress(): string { // Fallback to constructing from EMAIL_DOMAIN return `noreply@${env.EMAIL_DOMAIN || getEmailDomain()}` } + +/** + * Extract the email address from a "Name " formatted string" + */ +export function extractEmailFromAddress(fromAddress: string): string | undefined { + const match = fromAddress.match(/<([^>]+)>/) + if (match) { + return match[1] + } + if (fromAddress.includes('@') && !fromAddress.includes('<')) { + return fromAddress.trim() + } + return undefined +} + +/** + * Get the personal email from address and reply-to + */ +export function getPersonalEmailFrom(): { from: string; replyTo: string | undefined } { + const personalFrom = env.PERSONAL_EMAIL_FROM + if (personalFrom) { + return { + from: personalFrom, + replyTo: extractEmailFromAddress(personalFrom), + } + } + return { + from: getFromEmailAddress(), + replyTo: undefined, + } +} diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index eea96cd00..7377b8901 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -54,15 +54,16 @@ describe('Azure Blob Storage Client', () => { toString: () => 'sv=2021-06-08&se=2023-01-01T00%3A00%3A00Z&sr=b&sp=r&sig=test', }) - vi.doMock('@/lib/core/config/env', () => ({ - env: { + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ AZURE_ACCOUNT_NAME: 'testaccount', AZURE_ACCOUNT_KEY: 'testkey', AZURE_CONNECTION_STRING: 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net', AZURE_STORAGE_CONTAINER_NAME: 'testcontainer', - }, - })) + }) + }) vi.doMock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue({ diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts index 8ea6321e7..b6ba4c1ad 100644 --- a/apps/sim/lib/uploads/providers/s3/client.test.ts +++ b/apps/sim/lib/uploads/providers/s3/client.test.ts @@ -31,14 +31,15 @@ describe('S3 Client', () => { getSignedUrl: mockGetSignedUrl, })) - vi.doMock('@/lib/core/config/env', () => ({ - env: { + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ S3_BUCKET_NAME: 'test-bucket', AWS_REGION: 'test-region', AWS_ACCESS_KEY_ID: 'test-access-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key', - }, - })) + }) + }) vi.doMock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue({ @@ -298,14 +299,15 @@ describe('S3 Client', () => { describe('s3Client initialization', () => { it('should initialize with correct configuration when credentials are available', async () => { - vi.doMock('@/lib/core/config/env', () => ({ - env: { + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ S3_BUCKET_NAME: 'test-bucket', AWS_REGION: 'test-region', AWS_ACCESS_KEY_ID: 'test-access-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key', - }, - })) + }) + }) vi.doMock('@/lib/uploads/setup', () => ({ S3_CONFIG: { @@ -331,14 +333,15 @@ describe('S3 Client', () => { }) it('should initialize without credentials when env vars are not available', async () => { - vi.doMock('@/lib/core/config/env', () => ({ - env: { + vi.doMock('@/lib/core/config/env', async () => { + const { createEnvMock } = await import('@sim/testing') + return createEnvMock({ S3_BUCKET_NAME: 'test-bucket', AWS_REGION: 'test-region', AWS_ACCESS_KEY_ID: undefined, AWS_SECRET_ACCESS_KEY: undefined, - }, - })) + }) + }) vi.doMock('@/lib/uploads/setup', () => ({ S3_CONFIG: { diff --git a/apps/sim/public/brand/color/email/type.png b/apps/sim/public/brand/color/email/type.png new file mode 100644 index 000000000..ec148e331 Binary files /dev/null and b/apps/sim/public/brand/color/email/type.png differ diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index cd57ad5e7..816fd496c 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -45,14 +45,18 @@ export * from './assertions' export * from './builders' export * from './factories' export { + createEnvMock, createMockDb, createMockFetch, + createMockGetEnv, createMockLogger, createMockResponse, createMockSocket, createMockStorage, databaseMock, + defaultMockEnv, drizzleOrmMock, + envMock, loggerMock, type MockFetchResponse, setupGlobalFetchMock, diff --git a/packages/testing/src/mocks/env.mock.ts b/packages/testing/src/mocks/env.mock.ts new file mode 100644 index 000000000..f216721af --- /dev/null +++ b/packages/testing/src/mocks/env.mock.ts @@ -0,0 +1,67 @@ +import { vi } from 'vitest' + +/** + * Default mock environment values for testing + */ +export const defaultMockEnv = { + // Core + DATABASE_URL: 'postgresql://test:test@localhost:5432/test', + BETTER_AUTH_URL: 'https://test.sim.ai', + BETTER_AUTH_SECRET: 'test-secret-that-is-at-least-32-chars-long', + ENCRYPTION_KEY: 'test-encryption-key-32-chars-long!', + INTERNAL_API_SECRET: 'test-internal-api-secret-32-chars!', + + // Email + RESEND_API_KEY: 'test-resend-key', + FROM_EMAIL_ADDRESS: 'Sim ', + EMAIL_DOMAIN: 'test.sim.ai', + PERSONAL_EMAIL_FROM: 'Test ', + + // URLs + NEXT_PUBLIC_APP_URL: 'https://test.sim.ai', +} + +/** + * Creates a mock getEnv function that returns values from the provided env object + */ +export function createMockGetEnv(envValues: Record = defaultMockEnv) { + return vi.fn((key: string) => envValues[key]) +} + +/** + * Creates a complete env mock object for use with vi.doMock + * + * @example + * ```ts + * vi.doMock('@/lib/core/config/env', () => createEnvMock()) + * + * // With custom values + * vi.doMock('@/lib/core/config/env', () => createEnvMock({ + * NEXT_PUBLIC_APP_URL: 'https://custom.example.com', + * })) + * ``` + */ +export function createEnvMock(overrides: Record = {}) { + const envValues = { ...defaultMockEnv, ...overrides } + + return { + env: envValues, + getEnv: createMockGetEnv(envValues), + isTruthy: (value: string | boolean | number | undefined) => + typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), + isFalsy: (value: string | boolean | number | undefined) => + typeof value === 'string' + ? value.toLowerCase() === 'false' || value === '0' + : value === false, + } +} + +/** + * Pre-configured env mock for direct use with vi.mock + * + * @example + * ```ts + * vi.mock('@/lib/core/config/env', () => envMock) + * ``` + */ +export const envMock = createEnvMock() diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index b08e250a5..52eec208c 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -24,6 +24,8 @@ export { databaseMock, drizzleOrmMock, } from './database.mock' +// Env mocks +export { createEnvMock, createMockGetEnv, defaultMockEnv, envMock } from './env.mock' // Fetch mocks export { createMockFetch,