chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Base styles for all email templates.
|
||||
* Colors are derived from globals.css light mode tokens.
|
||||
*/
|
||||
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
/** Color tokens from globals.css (light mode), brand-aware for whitelabeled instances */
|
||||
function buildColors() {
|
||||
const brand = getBrandConfig()
|
||||
const isWhitelabeled = brand.isWhitelabeled
|
||||
const accentColor =
|
||||
isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a'
|
||||
|
||||
return {
|
||||
/** Main canvas background — a hair off-white so the white card reads via contrast, not the border alone */
|
||||
bgOuter: '#f8f8f8',
|
||||
/** Card/container background — platform `--surface-2` */
|
||||
bgCard: '#ffffff',
|
||||
/** Primary text — platform `--text-primary` */
|
||||
textPrimary: '#1a1a1a',
|
||||
/** Secondary text — platform `--text-secondary` */
|
||||
textSecondary: '#525252',
|
||||
/** Tertiary text — platform `--text-tertiary` */
|
||||
textTertiary: '#5c5c5c',
|
||||
/** Muted text (footer) — platform `--text-muted` */
|
||||
textMuted: '#707070',
|
||||
/** Brand primary — neutral by default, brand color when whitelabeled */
|
||||
brandPrimary:
|
||||
isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a',
|
||||
/** Accent for buttons and links — neutral by default, brand color when whitelabeled */
|
||||
brandTertiary: accentColor,
|
||||
/** Border/divider — platform `--border` */
|
||||
divider: '#dedede',
|
||||
/** Subtle fill for info/code boxes on the white card */
|
||||
surfaceSubtle: '#f7f7f7',
|
||||
/** Error surface fill — platform `--terminal-status-error-bg` */
|
||||
errorBg: '#fef2f2',
|
||||
/** Error surface border — platform `--error-muted` */
|
||||
errorBorder: '#fecaca',
|
||||
/** Footer background — matches the canvas */
|
||||
footerBg: '#f8f8f8',
|
||||
}
|
||||
}
|
||||
|
||||
export const colors = buildColors()
|
||||
|
||||
/** Typography settings */
|
||||
export const typography = {
|
||||
fontFamily:
|
||||
"'Season Sans', -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 — white surface, chip-radius, hairline border on the near-white canvas */
|
||||
container: {
|
||||
maxWidth: `${spacing.containerWidth}px`,
|
||||
margin: '0 auto',
|
||||
backgroundColor: colors.bgCard,
|
||||
border: `1px solid ${colors.divider}`,
|
||||
borderRadius: '8px',
|
||||
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 the platform's primary Chip (inverse fill, rounded-lg, h-30, text-sm) */
|
||||
button: {
|
||||
display: 'inline-block',
|
||||
backgroundColor: colors.brandTertiary,
|
||||
color: '#ffffff',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
lineHeight: '30px',
|
||||
padding: '0 12px',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
margin: '4px 0',
|
||||
fontFamily: typography.fontFamily,
|
||||
},
|
||||
|
||||
/** Link text style - neutral color, so it carries an underline to read as a link */
|
||||
link: {
|
||||
color: colors.brandTertiary,
|
||||
fontWeight: 400,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
/** 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: colors.surfaceSubtle,
|
||||
borderRadius: '8px',
|
||||
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,
|
||||
},
|
||||
|
||||
/** Code block text (for JSON/code display) */
|
||||
codeBlock: {
|
||||
fontSize: typography.fontSize.caption,
|
||||
lineHeight: typography.lineHeight.caption,
|
||||
color: colors.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
wordWrap: 'break-word' as const,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
/** Highlighted info box (e.g., "What you get with Pro") */
|
||||
infoBox: {
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
padding: '16px 18px',
|
||||
borderRadius: '8px',
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
/** Styles for plain personal emails (no branding, no EmailLayout) */
|
||||
export const plainEmailStyles = {
|
||||
body: {
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
},
|
||||
container: {
|
||||
maxWidth: '560px',
|
||||
margin: '40px auto',
|
||||
padding: '0 24px',
|
||||
},
|
||||
p: {
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
color: '#1a1a1a',
|
||||
margin: '0 0 16px',
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1 @@
|
||||
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface ExistingAccountEmailProps {
|
||||
username?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sent out-of-band when someone attempts to sign up with an email that already
|
||||
* has an account. The sign-up endpoint itself returns a generic success
|
||||
* response to avoid account enumeration, so this email is how the real account
|
||||
* owner learns of the attempt.
|
||||
*/
|
||||
export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const loginLink = `${getBaseUrl()}/login`
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`Someone tried to sign up with your ${brand.name} email`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Someone just tried to create a {brand.name} account using this email address, but an account
|
||||
already exists. If this was you, sign in instead — or reset your password if you've
|
||||
forgotten it.
|
||||
</Text>
|
||||
|
||||
<Link href={loginLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Sign In</Text>
|
||||
</Link>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
If this wasn't you, no action is needed — no account was created or changed.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExistingAccountEmail
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ExistingAccountEmail } from './existing-account-email'
|
||||
export { OnboardingFollowupEmail } from './onboarding-followup-email'
|
||||
export { OTPVerificationEmail } from './otp-verification-email'
|
||||
export { ResetPasswordEmail } from './reset-password-email'
|
||||
export { WelcomeEmail } from './welcome-email'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Body, Head, Html, Preview, Text } from '@react-email/components'
|
||||
import { plainEmailStyles as styles } from '@/components/emails/_styles'
|
||||
|
||||
interface OnboardingFollowupEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Quick question</Preview>
|
||||
<Body style={styles.body}>
|
||||
<div style={styles.container}>
|
||||
<Text style={styles.p}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
|
||||
<Text style={styles.p}>
|
||||
It's been a few days since you signed up. I hope you're enjoying Sim!
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
I'd love to know — what did you expect when you signed up vs. what did you get?
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
A reply with your thoughts would really help us improve the product for everyone.
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
Thanks,
|
||||
<br />
|
||||
Emir
|
||||
<br />
|
||||
Founder, Sim
|
||||
</Text>
|
||||
</div>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnboardingFollowupEmail
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface OTPVerificationEmailProps {
|
||||
otp: string
|
||||
email?: string
|
||||
type?: 'sign-in' | 'email-verification' | 'change-email' | '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 'change-email':
|
||||
return `Verify your new email for ${brandName}`
|
||||
case 'forget-password':
|
||||
return `Reset your ${brandName} password`
|
||||
case 'chat-access':
|
||||
return `Verification code for ${chatTitle || 'Chat'}`
|
||||
default:
|
||||
return `Verification code for ${brandName}`
|
||||
}
|
||||
}
|
||||
|
||||
export function OTPVerificationEmail({
|
||||
otp,
|
||||
email = '',
|
||||
type = 'email-verification',
|
||||
chatTitle,
|
||||
}: OTPVerificationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Your verification code:</Text>
|
||||
|
||||
<Section style={baseStyles.codeContainer}>
|
||||
<Text style={baseStyles.code}>{otp}</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Do not share this code with anyone. If you didn't request this code, you can safely ignore
|
||||
this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default OTPVerificationEmail
|
||||
@@ -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 '@/ee/whitelabeling'
|
||||
|
||||
interface ResetPasswordEmailProps {
|
||||
username?: string
|
||||
resetLink?: string
|
||||
}
|
||||
|
||||
export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPasswordEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello {username},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
A password reset was requested for your {brand.name} account. Click below to set a new
|
||||
password.
|
||||
</Text>
|
||||
|
||||
<Link href={resetLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Reset Password</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
If you didn't request this, you can ignore this email. Link expires in 24 hours.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPasswordEmail
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WelcomeEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export function WelcomeEmail({ userName }: WelcomeEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return (
|
||||
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hey ${userName},` : 'Hey,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI
|
||||
workflows in minutes.
|
||||
</Text>
|
||||
|
||||
<Link href={`${baseUrl}/login`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Get Started</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
If you have any questions or feedback, just reply to this email. I read every message!
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Want to chat?{' '}
|
||||
<Link href={`${baseUrl}/team`} style={baseStyles.link}>
|
||||
Schedule a call
|
||||
</Link>{' '}
|
||||
with our team.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
You're on the Community plan with 1,000 credits to get started.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeEmail
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Head, Html, Preview, Text } from '@react-email/components'
|
||||
import { plainEmailStyles as styles } from '@/components/emails/_styles'
|
||||
|
||||
interface AbandonedCheckoutEmailProps {
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Did you run into an issue with your upgrade?</Preview>
|
||||
<Body style={styles.body}>
|
||||
<div style={styles.container}>
|
||||
<Text style={styles.p}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
|
||||
<Text style={styles.p}>
|
||||
I saw that you tried to upgrade your Sim plan but didn't end up completing it.
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
Did you run into an issue, or did you have a question? Here to help.
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
— Emir
|
||||
<br />
|
||||
Founder, Sim
|
||||
</Text>
|
||||
</div>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbandonedCheckoutEmail
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Pro plan features shown in billing upgrade emails */
|
||||
export const proFeatures = [
|
||||
{ label: '6,000 credits/month', desc: 'included' },
|
||||
{ label: '+50 daily refresh', desc: 'credits per day' },
|
||||
{ label: '150 runs/min', desc: 'sync executions' },
|
||||
{ label: '50GB storage', desc: 'for files & assets' },
|
||||
] as const
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface CreditPurchaseEmailProps {
|
||||
userName?: string
|
||||
amount: number
|
||||
newBalance: number
|
||||
purchaseDate?: Date
|
||||
}
|
||||
|
||||
export function CreditPurchaseEmail({
|
||||
userName,
|
||||
amount,
|
||||
newBalance,
|
||||
purchaseDate = new Date(),
|
||||
}: CreditPurchaseEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const previewText = `${brand.name}: ${dollarsToCredits(amount).toLocaleString()} credits added to your account`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your credit purchase of <strong>{dollarsToCredits(amount).toLocaleString()} credits</strong>{' '}
|
||||
has been confirmed.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
Amount Added
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '4px 0 16px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{dollarsToCredits(amount).toLocaleString()} credits
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
New Balance
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: colors.textPrimary,
|
||||
fontFamily: baseStyles.fontFamily,
|
||||
}}
|
||||
>
|
||||
{dollarsToCredits(newBalance).toLocaleString()} credits
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Credits are applied automatically to your workflow executions.
|
||||
</Text>
|
||||
|
||||
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>View Dashboard</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditPurchaseEmail
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface CreditsExhaustedEmailProps {
|
||||
userName?: string
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
}
|
||||
|
||||
export function CreditsExhaustedEmail({
|
||||
userName,
|
||||
limit,
|
||||
upgradeLink,
|
||||
}: CreditsExhaustedEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've used all ${dollarsToCredits(limit).toLocaleString()} of your free ${brand.name} credits`}
|
||||
showUnsubscribe={true}
|
||||
>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used all <strong>{dollarsToCredits(limit).toLocaleString()}</strong> of your
|
||||
free credits on {brand.name}. Your workflows are paused until you upgrade.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
border: `1px solid ${colors.brandTertiary}20`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px 20px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: colors.brandTertiary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Pro includes
|
||||
</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature, i) => (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
width: '45%',
|
||||
}}
|
||||
>
|
||||
{feature.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
}}
|
||||
>
|
||||
{feature.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification when free credits are exhausted.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsExhaustedEmail
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface EnterpriseSubscriptionEmailProps {
|
||||
userName?: string
|
||||
loginLink?: string
|
||||
}
|
||||
|
||||
export function EnterpriseSubscriptionEmail({
|
||||
userName = 'Valued User',
|
||||
loginLink,
|
||||
}: EnterpriseSubscriptionEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
const effectiveLoginLink = loginLink || `${baseUrl}/login`
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`Your Enterprise Plan is now active on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
|
||||
features and increased capacity for your workflows.
|
||||
</Text>
|
||||
|
||||
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Next steps:</strong>
|
||||
<br />• Invite teammates to your organization
|
||||
<br />• Start building your workflows
|
||||
</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Questions? Reply to this email or contact us at{' '}
|
||||
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
|
||||
{brand.supportEmail}
|
||||
</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default EnterpriseSubscriptionEmail
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors, typography } from '@/components/emails/_styles'
|
||||
import { proFeatures } from '@/components/emails/billing/constants'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface FreeTierUpgradeEmailProps {
|
||||
userName?: string
|
||||
percentUsed: number
|
||||
currentUsage: number
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
}
|
||||
|
||||
export function FreeTierUpgradeEmail({
|
||||
userName,
|
||||
percentUsed,
|
||||
currentUsage,
|
||||
limit,
|
||||
upgradeLink,
|
||||
}: FreeTierUpgradeEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You've used <strong>{dollarsToCredits(currentUsage).toLocaleString()}</strong> of your{' '}
|
||||
<strong>{dollarsToCredits(limit).toLocaleString()}</strong> free credits ({percentUsed}%).
|
||||
Upgrade to Pro to keep building without interruption.
|
||||
</Text>
|
||||
|
||||
{/* Pro Features */}
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.surfaceSubtle,
|
||||
border: `1px solid ${colors.brandTertiary}20`,
|
||||
borderRadius: '8px',
|
||||
padding: '16px 20px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: colors.brandTertiary,
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: '0 0 12px 0',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Pro includes
|
||||
</Text>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{proFeatures.map((feature, i) => (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.fontFamily,
|
||||
width: '45%',
|
||||
}}
|
||||
>
|
||||
{feature.label}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '6px 0',
|
||||
fontSize: '14px',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.fontFamily,
|
||||
}}
|
||||
>
|
||||
{feature.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade to Pro</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 80% usage.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default FreeTierUpgradeEmail
|
||||
@@ -0,0 +1,9 @@
|
||||
export { AbandonedCheckoutEmail } from './abandoned-checkout-email'
|
||||
export { CreditPurchaseEmail } from './credit-purchase-email'
|
||||
export { CreditsExhaustedEmail } from './credits-exhausted-email'
|
||||
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
|
||||
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
|
||||
export { LimitThresholdEmail } from './limit-threshold-email'
|
||||
export { PaymentFailedEmail } from './payment-failed-email'
|
||||
export { PlanWelcomeEmail } from './plan-welcome-email'
|
||||
export { UsageThresholdEmail } from './usage-threshold-email'
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface LimitThresholdEmailProps {
|
||||
/** `warning` = approaching the limit (~80%); `reached` = at/over the limit. */
|
||||
kind: 'warning' | 'reached'
|
||||
/** Limit category, drives the shared copy. */
|
||||
reason: UpgradeReason
|
||||
userName?: string
|
||||
/** Pre-formatted current usage, e.g. "4.2 GB", "48,000 rows", "9 seats". */
|
||||
usageLabel: string
|
||||
/** Pre-formatted limit, e.g. "5 GB", "50,000 rows", "10 seats". */
|
||||
limitLabel: string
|
||||
percentUsed: number
|
||||
upgradeLink: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Single template for the per-category usage-limit emails (storage, tables,
|
||||
* seats). Copy comes from {@link UPGRADE_REASON_COPY} so the email language
|
||||
* matches the upgrade-page header the user lands on.
|
||||
*/
|
||||
export function LimitThresholdEmail({
|
||||
kind,
|
||||
reason,
|
||||
userName,
|
||||
usageLabel,
|
||||
limitLabel,
|
||||
percentUsed,
|
||||
upgradeLink,
|
||||
}: LimitThresholdEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const copy = UPGRADE_REASON_COPY[reason]
|
||||
const lead = kind === 'reached' ? copy.reachedLead : copy.warningLead
|
||||
const previewText = `${brand.name}: ${lead}`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{lead} Upgrade your plan for more {copy.noun}.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
{usageLabel} of {limitLabel} used ({percentUsed}%)
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Upgrade</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
{kind === 'reached'
|
||||
? 'One-time notification at 100% usage.'
|
||||
: 'One-time notification at 80% usage.'}
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default LimitThresholdEmail
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles, colors } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface PaymentFailedEmailProps {
|
||||
userName?: string
|
||||
amountDue: number
|
||||
lastFourDigits?: string
|
||||
billingPortalUrl: string
|
||||
failureReason?: string
|
||||
sentDate?: Date
|
||||
}
|
||||
|
||||
export function PaymentFailedEmail({
|
||||
userName,
|
||||
amountDue,
|
||||
lastFourDigits,
|
||||
billingPortalUrl,
|
||||
failureReason,
|
||||
sentDate = new Date(),
|
||||
}: PaymentFailedEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const previewText = `${brand.name}: Payment Failed - Action Required`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={false}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: colors.textPrimary,
|
||||
}}
|
||||
>
|
||||
We were unable to process your payment.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your {brand.name} account has been temporarily blocked to prevent service interruptions and
|
||||
unexpected charges. To restore access immediately, please update your payment method.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.errorBg,
|
||||
border: `1px solid ${colors.errorBorder}`,
|
||||
borderRadius: '6px',
|
||||
padding: '16px 18px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.paragraph,
|
||||
marginBottom: 8,
|
||||
marginTop: 0,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Payment Details
|
||||
</Text>
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Amount due: ${amountDue.toFixed(2)}
|
||||
</Text>
|
||||
{lastFourDigits && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
|
||||
Payment method: •••• {lastFourDigits}
|
||||
</Text>
|
||||
)}
|
||||
{failureReason && (
|
||||
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>Reason: {failureReason}</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Update Payment Method</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
• Your workflows and automations are currently paused
|
||||
<br />• Update your payment method to restore service immediately
|
||||
<br />• Stripe will automatically retry the charge once payment is updated
|
||||
</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
|
||||
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
|
||||
{brand.supportEmail}
|
||||
</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaymentFailedEmail
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface PlanWelcomeEmailProps {
|
||||
planName: string
|
||||
userName?: string
|
||||
loginLink?: string
|
||||
}
|
||||
|
||||
export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
const cta = loginLink || `${baseUrl}/login`
|
||||
|
||||
const previewText = `${brand.name}: Your ${planName} plan is active`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
|
||||
workflows.
|
||||
</Text>
|
||||
|
||||
<Link href={cta} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open {brand.name}</Text>
|
||||
</Link>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Want help getting started?{' '}
|
||||
<Link href={`${baseUrl}/team`} style={baseStyles.link}>
|
||||
Schedule a call
|
||||
</Link>{' '}
|
||||
with our team.
|
||||
</Text>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Manage your subscription in Settings → Subscription.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanWelcomeEmail
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Link, Section, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface UsageThresholdEmailProps {
|
||||
userName?: string
|
||||
planName: string
|
||||
percentUsed: number
|
||||
currentUsage: number
|
||||
limit: number
|
||||
ctaLink: string
|
||||
}
|
||||
|
||||
export function UsageThresholdEmail({
|
||||
userName,
|
||||
planName,
|
||||
percentUsed,
|
||||
currentUsage,
|
||||
limit,
|
||||
ctaLink,
|
||||
}: UsageThresholdEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
|
||||
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={previewText} showUnsubscribe={true}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You're approaching your monthly budget on the {planName} plan.
|
||||
</Text>
|
||||
|
||||
<Section style={baseStyles.infoBox}>
|
||||
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
|
||||
<Text style={baseStyles.infoBoxList}>
|
||||
{dollarsToCredits(currentUsage).toLocaleString()} of{' '}
|
||||
{dollarsToCredits(limit).toLocaleString()} credits used ({percentUsed}%)
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
To avoid interruptions, consider increasing your monthly limit.
|
||||
</Text>
|
||||
|
||||
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Review Limits</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
One-time notification at 80% usage.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsageThresholdEmail
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Container, Img, Link, Section } from '@react-email/components'
|
||||
import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface EmailFooterProps {
|
||||
baseUrl?: string
|
||||
messageId?: string
|
||||
/**
|
||||
* Whether to show unsubscribe link. Defaults to true.
|
||||
* Set to false for transactional emails where unsubscribe doesn't apply.
|
||||
*/
|
||||
showUnsubscribe?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Email footer component styled to match Stripe's email design.
|
||||
* Sits in the gray area below the main white card.
|
||||
*
|
||||
* For non-transactional emails, the unsubscribe link uses placeholders
|
||||
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
|
||||
* by the mailer when sending.
|
||||
*/
|
||||
export function EmailFooter({
|
||||
baseUrl = getBaseUrl(),
|
||||
messageId,
|
||||
showUnsubscribe = true,
|
||||
}: EmailFooterProps) {
|
||||
const brand = getBrandConfig()
|
||||
const isWhitelabeled = brand.isWhitelabeled
|
||||
|
||||
const footerLinkStyle = {
|
||||
color: colors.textMuted,
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 'normal' as const,
|
||||
fontFamily: typography.fontFamily,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: colors.footerBg,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Container style={{ maxWidth: `${spacing.containerWidth}px`, margin: '0 auto' }}>
|
||||
<table
|
||||
cellPadding={0}
|
||||
cellSpacing={0}
|
||||
border={0}
|
||||
width='100%'
|
||||
style={{ minWidth: `${spacing.containerWidth}px` }}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={32}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Social links row — hidden for whitelabeled instances */}
|
||||
{!isWhitelabeled && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align='left' style={{ padding: '0 8px 0 0' }}>
|
||||
<Link href={`${baseUrl}/x`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/x-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='X'
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td align='left' style={{ padding: '0 8px' }}>
|
||||
<Link
|
||||
href='https://www.linkedin.com/company/simdotai'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<Img
|
||||
src={`${baseUrl}/static/linkedin-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='LinkedIn'
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
<td align='left' style={{ padding: '0 8px' }}>
|
||||
<Link href={`${baseUrl}/github`} rel='noopener noreferrer'>
|
||||
<Img
|
||||
src={`${baseUrl}/static/github-icon.png`}
|
||||
width='20'
|
||||
height='20'
|
||||
alt='GitHub'
|
||||
/>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Address row — hidden for whitelabeled instances */}
|
||||
{!isWhitelabeled && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
{brand.name}
|
||||
{isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA</>}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contact row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Questions?{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Message ID row (optional) */}
|
||||
{messageId && (
|
||||
<>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
Need to refer to this message? Use this ID: {messageId}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={8}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
<a href={`${baseUrl}/privacy`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||
Privacy Policy
|
||||
</a>{' '}
|
||||
•{' '}
|
||||
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
|
||||
Terms of Service
|
||||
</a>
|
||||
{showUnsubscribe && (
|
||||
<>
|
||||
{' '}
|
||||
•{' '}
|
||||
<a
|
||||
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
|
||||
style={footerLinkStyle}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Unsubscribe
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Copyright row */}
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={16}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
<td style={baseStyles.footerText}>
|
||||
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
|
||||
</td>
|
||||
<td style={baseStyles.gutter} width={spacing.gutter}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style={baseStyles.spacer} height={32}>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailFooter
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Body, Container, Font, Head, Html, Img, Preview, Section } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailFooter } from '@/components/emails/components/email-footer'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
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
|
||||
/**
|
||||
* Whether to show unsubscribe link in footer.
|
||||
* Set to false for transactional emails where unsubscribe doesn't apply.
|
||||
*/
|
||||
showUnsubscribe: 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,
|
||||
showUnsubscribe,
|
||||
}: EmailLayoutProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
const hasCustomLogo = Boolean(brand.logoUrl)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
<Font
|
||||
fontFamily='Season Sans'
|
||||
fallbackFontFamily={['Helvetica', 'sans-serif']}
|
||||
webFont={{
|
||||
url: `${baseUrl}/brand/fonts/SeasonSansUprightsVF.woff2`,
|
||||
format: 'woff2',
|
||||
}}
|
||||
fontWeight='300 800'
|
||||
fontStyle='normal'
|
||||
/>
|
||||
</Head>
|
||||
<Preview>{preview}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
{/* Main card container */}
|
||||
<Container style={baseStyles.container}>
|
||||
{/* Header with logo */}
|
||||
<Section style={baseStyles.header}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/brand/color/email/wordmark.png`}
|
||||
height='34'
|
||||
{...(hasCustomLogo ? {} : { width: '70' })}
|
||||
alt={brand.name}
|
||||
style={hasCustomLogo ? { display: 'block', width: 'auto' } : { display: 'block' }}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Content */}
|
||||
<Section style={baseStyles.content}>{children}</Section>
|
||||
</Container>
|
||||
|
||||
{/* Footer in gray section */}
|
||||
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailLayout
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EmailFooter } from './email-footer'
|
||||
export { EmailLayout } from './email-layout'
|
||||
@@ -0,0 +1,15 @@
|
||||
// Styles
|
||||
export * from './_styles'
|
||||
// Auth emails
|
||||
export * from './auth'
|
||||
// Billing emails
|
||||
export * from './billing'
|
||||
// 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'
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_INVITATIONS: WorkspaceInvitation[] = []
|
||||
|
||||
export function BatchInvitationEmail({
|
||||
inviterName = 'Someone',
|
||||
organizationName = 'the team',
|
||||
organizationRole = 'member',
|
||||
workspaceInvitations = EMPTY_INVITATIONS,
|
||||
acceptUrl,
|
||||
}: BatchInvitationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const hasWorkspaces = workspaceInvitations.length > 0
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
|
||||
on {brand.name}.
|
||||
</Text>
|
||||
|
||||
{/* Team Role Information */}
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{organizationRole === 'admin'
|
||||
? "As a Team Admin, you'll be able to manage team members, billing, and workspace access."
|
||||
: "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."}
|
||||
</Text>
|
||||
|
||||
{/* Workspace Invitations */}
|
||||
{hasWorkspaces && (
|
||||
<>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>
|
||||
Workspace Access ({workspaceInvitations.length} workspace
|
||||
{workspaceInvitations.length !== 1 ? 's' : ''}):
|
||||
</strong>
|
||||
</Text>
|
||||
{workspaceInvitations.map((ws) => (
|
||||
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
|
||||
• <strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Invitation expires in 7 days. If unexpected, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchInvitationEmail
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BatchInvitationEmail } from './batch-invitation-email'
|
||||
export { InvitationEmail } from './invitation-email'
|
||||
export { WorkspaceAddedEmail } from './workspace-added-email'
|
||||
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
|
||||
@@ -0,0 +1,63 @@
|
||||
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 { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface InvitationEmailProps {
|
||||
inviterName?: string
|
||||
organizationName?: string
|
||||
inviteLink?: string
|
||||
}
|
||||
|
||||
const logger = createLogger('InvitationEmail')
|
||||
|
||||
export function InvitationEmail({
|
||||
inviterName = 'A team member',
|
||||
organizationName = 'an organization',
|
||||
inviteLink = '',
|
||||
}: InvitationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
let enhancedLink = inviteLink
|
||||
|
||||
if (inviteLink && !inviteLink.includes('token=')) {
|
||||
try {
|
||||
const url = new URL(inviteLink)
|
||||
const invitationId = url.pathname.split('/').pop()
|
||||
if (invitationId) {
|
||||
enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error parsing invite link:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
|
||||
{brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Invitation expires in 48 hours. If unexpected, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default InvitationEmail
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WorkspaceAddedEmailProps {
|
||||
/** Name of the workspace the recipient was added to. */
|
||||
workspaceName?: string
|
||||
/** Name of the person who added the recipient. */
|
||||
inviterName?: string
|
||||
/** Direct link to the workspace (no acceptance required). */
|
||||
workspaceLink?: string
|
||||
}
|
||||
|
||||
export function WorkspaceAddedEmail({
|
||||
workspaceName = 'Workspace',
|
||||
inviterName = 'Someone',
|
||||
workspaceLink = '',
|
||||
}: WorkspaceAddedEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const preview = `You've been added to the "${workspaceName}" workspace on ${brand.name}`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={preview} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> added you to the <strong>{workspaceName}</strong> workspace
|
||||
on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={workspaceLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Open workspace</Text>
|
||||
</Link>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
If this was unexpected, contact a workspace admin.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceAddedEmail
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Link, Text } from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WorkspaceInvitationEmailProps {
|
||||
/** Workspaces this invitation grants access to (one entry per workspace). */
|
||||
workspaceNames?: string[]
|
||||
inviterName?: string
|
||||
invitationLink?: string
|
||||
}
|
||||
|
||||
export function WorkspaceInvitationEmail({
|
||||
workspaceNames = ['Workspace'],
|
||||
inviterName = 'Someone',
|
||||
invitationLink = '',
|
||||
}: WorkspaceInvitationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const isMultiple = workspaceNames.length > 1
|
||||
const preview = isMultiple
|
||||
? `You've been invited to join ${workspaceNames.length} workspaces on ${brand.name}!`
|
||||
: `You've been invited to join the "${workspaceNames[0]}" workspace on ${brand.name}!`
|
||||
|
||||
return (
|
||||
<EmailLayout preview={preview} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited you to join the{' '}
|
||||
{workspaceNames.map((name, index) => (
|
||||
<span key={`${name}-${index}`}>
|
||||
{index > 0 &&
|
||||
(index === workspaceNames.length - 1
|
||||
? workspaceNames.length > 2
|
||||
? ', and '
|
||||
: ' and '
|
||||
: ', ')}
|
||||
<strong>{name}</strong>
|
||||
</span>
|
||||
))}{' '}
|
||||
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>Accept Invitation</Text>
|
||||
</Link>
|
||||
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Invitation expires in 7 days. If unexpected, you can ignore this email.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkspaceInvitationEmail
|
||||
@@ -0,0 +1,262 @@
|
||||
import { render } from '@react-email/render'
|
||||
import {
|
||||
ExistingAccountEmail,
|
||||
OnboardingFollowupEmail,
|
||||
OTPVerificationEmail,
|
||||
ResetPasswordEmail,
|
||||
WelcomeEmail,
|
||||
} from '@/components/emails/auth'
|
||||
import {
|
||||
AbandonedCheckoutEmail,
|
||||
CreditPurchaseEmail,
|
||||
CreditsExhaustedEmail,
|
||||
EnterpriseSubscriptionEmail,
|
||||
FreeTierUpgradeEmail,
|
||||
LimitThresholdEmail,
|
||||
PaymentFailedEmail,
|
||||
PlanWelcomeEmail,
|
||||
UsageThresholdEmail,
|
||||
} from '@/components/emails/billing'
|
||||
import {
|
||||
BatchInvitationEmail,
|
||||
InvitationEmail,
|
||||
WorkspaceAddedEmail,
|
||||
WorkspaceInvitationEmail,
|
||||
} from '@/components/emails/invitations'
|
||||
import { HelpConfirmationEmail } from '@/components/emails/support'
|
||||
import type { UpgradeReason } from '@/lib/billing/upgrade-reasons'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export { getEmailSubject, getLimitEmailSubject } from './subjects'
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export async function renderOTPEmail(
|
||||
otp: string,
|
||||
email: string,
|
||||
type:
|
||||
| 'sign-in'
|
||||
| 'email-verification'
|
||||
| 'change-email'
|
||||
| 'forget-password' = 'email-verification',
|
||||
chatTitle?: string
|
||||
): Promise<string> {
|
||||
return await render(OTPVerificationEmail({ otp, email, type, chatTitle }))
|
||||
}
|
||||
|
||||
export async function renderExistingAccountEmail(username: string): Promise<string> {
|
||||
return await render(ExistingAccountEmail({ username }))
|
||||
}
|
||||
|
||||
export async function renderPasswordResetEmail(
|
||||
username: string,
|
||||
resetLink: string
|
||||
): Promise<string> {
|
||||
return await render(ResetPasswordEmail({ username, resetLink }))
|
||||
}
|
||||
|
||||
export async function renderInvitationEmail(
|
||||
inviterName: string,
|
||||
organizationName: string,
|
||||
invitationUrl: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
InvitationEmail({
|
||||
inviterName,
|
||||
organizationName,
|
||||
inviteLink: invitationUrl,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderBatchInvitationEmail(
|
||||
inviterName: string,
|
||||
organizationName: string,
|
||||
organizationRole: 'admin' | 'member',
|
||||
workspaceInvitations: WorkspaceInvitation[],
|
||||
acceptUrl: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
BatchInvitationEmail({
|
||||
inviterName,
|
||||
organizationName,
|
||||
organizationRole,
|
||||
workspaceInvitations,
|
||||
acceptUrl,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderHelpConfirmationEmail(
|
||||
type: 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
attachmentCount = 0
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
HelpConfirmationEmail({
|
||||
type,
|
||||
attachmentCount,
|
||||
submittedDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
|
||||
const baseUrl = getBaseUrl()
|
||||
const loginLink = `${baseUrl}/login`
|
||||
|
||||
return await render(
|
||||
EnterpriseSubscriptionEmail({
|
||||
userName,
|
||||
loginLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderUsageThresholdEmail(params: {
|
||||
userName?: string
|
||||
planName: string
|
||||
percentUsed: number
|
||||
currentUsage: number
|
||||
limit: number
|
||||
ctaLink: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
UsageThresholdEmail({
|
||||
userName: params.userName,
|
||||
planName: params.planName,
|
||||
percentUsed: params.percentUsed,
|
||||
currentUsage: params.currentUsage,
|
||||
limit: params.limit,
|
||||
ctaLink: params.ctaLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderFreeTierUpgradeEmail(params: {
|
||||
userName?: string
|
||||
percentUsed: number
|
||||
currentUsage: number
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
FreeTierUpgradeEmail({
|
||||
userName: params.userName,
|
||||
percentUsed: params.percentUsed,
|
||||
currentUsage: params.currentUsage,
|
||||
limit: params.limit,
|
||||
upgradeLink: params.upgradeLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderLimitThresholdEmail(params: {
|
||||
kind: 'warning' | 'reached'
|
||||
reason: UpgradeReason
|
||||
userName?: string
|
||||
usageLabel: string
|
||||
limitLabel: string
|
||||
percentUsed: number
|
||||
upgradeLink: string
|
||||
}): Promise<string> {
|
||||
return await render(LimitThresholdEmail(params))
|
||||
}
|
||||
|
||||
export async function renderPlanWelcomeEmail(params: {
|
||||
planName: string
|
||||
userName?: string
|
||||
loginLink?: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
PlanWelcomeEmail({
|
||||
planName: params.planName,
|
||||
userName: params.userName,
|
||||
loginLink: params.loginLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderWelcomeEmail(userName?: string): Promise<string> {
|
||||
return await render(WelcomeEmail({ userName }))
|
||||
}
|
||||
|
||||
export async function renderOnboardingFollowupEmail(userName?: string): Promise<string> {
|
||||
return await render(OnboardingFollowupEmail({ userName }))
|
||||
}
|
||||
|
||||
export async function renderAbandonedCheckoutEmail(userName?: string): Promise<string> {
|
||||
return await render(AbandonedCheckoutEmail({ userName }))
|
||||
}
|
||||
|
||||
export async function renderCreditsExhaustedEmail(params: {
|
||||
userName?: string
|
||||
limit: number
|
||||
upgradeLink: string
|
||||
}): Promise<string> {
|
||||
return await render(CreditsExhaustedEmail(params))
|
||||
}
|
||||
|
||||
export async function renderCreditPurchaseEmail(params: {
|
||||
userName?: string
|
||||
amount: number
|
||||
newBalance: number
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
CreditPurchaseEmail({
|
||||
userName: params.userName,
|
||||
amount: params.amount,
|
||||
newBalance: params.newBalance,
|
||||
purchaseDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderWorkspaceInvitationEmail(
|
||||
inviterName: string,
|
||||
workspaceNames: string[],
|
||||
invitationLink: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
WorkspaceInvitationEmail({
|
||||
inviterName,
|
||||
workspaceNames,
|
||||
invitationLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderWorkspaceAddedEmail(
|
||||
inviterName: string,
|
||||
workspaceName: string,
|
||||
workspaceLink: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
WorkspaceAddedEmail({
|
||||
inviterName,
|
||||
workspaceName,
|
||||
workspaceLink,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderPaymentFailedEmail(params: {
|
||||
userName?: string
|
||||
amountDue: number
|
||||
lastFourDigits?: string
|
||||
billingPortalUrl: string
|
||||
failureReason?: string
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
PaymentFailedEmail({
|
||||
userName: params.userName,
|
||||
amountDue: params.amountDue,
|
||||
lastFourDigits: params.lastFourDigits,
|
||||
billingPortalUrl: params.billingPortalUrl,
|
||||
failureReason: params.failureReason,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
/** Email subject type for all supported email templates */
|
||||
export type EmailSubjectType =
|
||||
| 'sign-in'
|
||||
| 'email-verification'
|
||||
| 'change-email'
|
||||
| 'forget-password'
|
||||
| 'reset-password'
|
||||
| 'existing-account'
|
||||
| 'invitation'
|
||||
| 'batch-invitation'
|
||||
| 'workspace-added'
|
||||
| 'help-confirmation'
|
||||
| 'enterprise-subscription'
|
||||
| 'usage-threshold'
|
||||
| 'free-tier-upgrade'
|
||||
| 'plan-welcome-pro'
|
||||
| 'plan-welcome-team'
|
||||
| 'credit-purchase'
|
||||
| 'abandoned-checkout'
|
||||
| 'free-tier-exhausted'
|
||||
| 'onboarding-followup'
|
||||
| '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 'change-email':
|
||||
return `Verify your new email for ${brandName}`
|
||||
case 'forget-password':
|
||||
return `Reset your ${brandName} password`
|
||||
case 'reset-password':
|
||||
return `Reset your ${brandName} password`
|
||||
case 'existing-account':
|
||||
return `Sign-up attempt with your ${brandName} email`
|
||||
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 'workspace-added':
|
||||
return `You've been added to a workspace 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 80% 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 'abandoned-checkout':
|
||||
return `Quick question`
|
||||
case 'free-tier-exhausted':
|
||||
return `You've run out of free credits on ${brandName}`
|
||||
case 'onboarding-followup':
|
||||
return `Quick question about ${brandName}`
|
||||
case 'welcome':
|
||||
return `Welcome to ${brandName}`
|
||||
default:
|
||||
return brandName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject line for a per-category usage-limit email. Reuses the shared
|
||||
* {@link UPGRADE_REASON_COPY} so the subject matches the email body and the
|
||||
* upgrade-page header the user lands on.
|
||||
*/
|
||||
export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'reached'): string {
|
||||
const brandName = getBrandConfig().name
|
||||
const copy = UPGRADE_REASON_COPY[reason]
|
||||
const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject
|
||||
return `${subject} on ${brandName}`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Text } from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { baseStyles } from '@/components/emails/_styles'
|
||||
import { EmailLayout } from '@/components/emails/components'
|
||||
|
||||
interface HelpConfirmationEmailProps {
|
||||
type?: 'bug' | 'feedback' | 'feature_request' | 'other'
|
||||
attachmentCount?: number
|
||||
submittedDate?: Date
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'bug':
|
||||
return 'Bug Report'
|
||||
case 'feedback':
|
||||
return 'Feedback'
|
||||
case 'feature_request':
|
||||
return 'Feature Request'
|
||||
case 'other':
|
||||
return 'General Inquiry'
|
||||
default:
|
||||
return 'Request'
|
||||
}
|
||||
}
|
||||
|
||||
export function HelpConfirmationEmail({
|
||||
type = 'other',
|
||||
attachmentCount = 0,
|
||||
submittedDate = new Date(),
|
||||
}: HelpConfirmationEmailProps) {
|
||||
const typeLabel = getTypeLabel(type)
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`Your ${typeLabel.toLowerCase()} has been received`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
|
||||
shortly.
|
||||
</Text>
|
||||
|
||||
{attachmentCount > 0 && (
|
||||
<Text style={baseStyles.paragraph}>
|
||||
{attachmentCount} image{attachmentCount > 1 ? 's' : ''} attached.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div style={baseStyles.divider} />
|
||||
|
||||
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
|
||||
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpConfirmationEmail
|
||||
@@ -0,0 +1 @@
|
||||
export { HelpConfirmationEmail } from './help-confirmation-email'
|
||||
Reference in New Issue
Block a user