fix(email): added unsubscribe from email functionality (#468)

* added unsubscribe from email functionality

* added tests

* ack PR comments
This commit is contained in:
Waleed Latif
2025-06-09 19:04:20 -07:00
committed by GitHub
parent 59b68d21be
commit 49a8a0551d
17 changed files with 1174 additions and 50 deletions

View File

@@ -3,8 +3,8 @@ import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import OTPVerificationEmail from '@/components/emails/otp-verification-email'
import { sendEmail } from '@/lib/email/mailer'
import { createLogger } from '@/lib/logs/console-logger'
import { sendEmail } from '@/lib/mailer'
import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'

View File

@@ -16,6 +16,14 @@ const SettingsSchema = z.object({
autoFillEnvVars: z.boolean().optional(),
telemetryEnabled: z.boolean().optional(),
telemetryNotifiedUser: z.boolean().optional(),
emailPreferences: z
.object({
unsubscribeAll: z.boolean().optional(),
unsubscribeMarketing: z.boolean().optional(),
unsubscribeUpdates: z.boolean().optional(),
unsubscribeNotifications: z.boolean().optional(),
})
.optional(),
})
// Default settings values
@@ -26,6 +34,7 @@ const defaultSettings = {
autoFillEnvVars: true,
telemetryEnabled: true,
telemetryNotifiedUser: false,
emailPreferences: {},
}
export async function GET() {
@@ -58,6 +67,7 @@ export async function GET() {
autoFillEnvVars: userSettings.autoFillEnvVars,
telemetryEnabled: userSettings.telemetryEnabled,
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
emailPreferences: userSettings.emailPreferences ?? {},
},
},
{ status: 200 }

View File

@@ -0,0 +1,147 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import type { EmailType } from '@/lib/email/mailer'
import {
getEmailPreferences,
isTransactionalEmail,
unsubscribeFromAll,
updateEmailPreferences,
verifyUnsubscribeToken,
} from '@/lib/email/unsubscribe'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('UnsubscribeAPI')
const unsubscribeSchema = z.object({
email: z.string().email('Invalid email address'),
token: z.string().min(1, 'Token is required'),
type: z.enum(['all', 'marketing', 'updates', 'notifications']).optional().default('all'),
})
export async function GET(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const { searchParams } = new URL(req.url)
const email = searchParams.get('email')
const token = searchParams.get('token')
if (!email || !token) {
logger.warn(`[${requestId}] Missing email or token in GET request`)
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
}
// Verify token and get email type
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
}
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
// Get current preferences
const preferences = await getEmailPreferences(email)
logger.info(
`[${requestId}] Valid unsubscribe GET request for email: ${email}, type: ${emailType}`
)
return NextResponse.json({
success: true,
email,
token,
emailType,
isTransactional,
currentPreferences: preferences || {},
})
} catch (error) {
logger.error(`[${requestId}] Error processing unsubscribe GET request:`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body = await req.json()
const result = unsubscribeSchema.safeParse(body)
if (!result.success) {
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
errors: result.error.format(),
})
return NextResponse.json(
{ error: 'Invalid request data', details: result.error.format() },
{ status: 400 }
)
}
const { email, token, type } = result.data
// Verify token and get email type
const tokenVerification = verifyUnsubscribeToken(email, token)
if (!tokenVerification.valid) {
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
}
const emailType = tokenVerification.emailType as EmailType
const isTransactional = isTransactionalEmail(emailType)
// Prevent unsubscribing from transactional emails
if (isTransactional) {
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
return NextResponse.json(
{
error: 'Cannot unsubscribe from transactional emails',
isTransactional: true,
message:
'Transactional emails cannot be unsubscribed from as they contain important account information.',
},
{ status: 400 }
)
}
// Process unsubscribe based on type
let success = false
switch (type) {
case 'all':
success = await unsubscribeFromAll(email)
break
case 'marketing':
success = await updateEmailPreferences(email, { unsubscribeMarketing: true })
break
case 'updates':
success = await updateEmailPreferences(email, { unsubscribeUpdates: true })
break
case 'notifications':
success = await updateEmailPreferences(email, { unsubscribeNotifications: true })
break
}
if (!success) {
logger.error(`[${requestId}] Failed to update unsubscribe preferences for: ${email}`)
return NextResponse.json({ error: 'Failed to process unsubscribe request' }, { status: 500 })
}
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
// Return 200 for one-click unsubscribe compliance
return NextResponse.json(
{
success: true,
message: `Successfully unsubscribed from ${type} emails`,
email,
type,
emailType,
},
{ status: 200 }
)
} catch (error) {
logger.error(`[${requestId}] Error processing unsubscribe POST request:`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,382 @@
'use client'
import { useEffect, useState } from 'react'
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface UnsubscribeData {
success: boolean
email: string
token: string
emailType: string
isTransactional: boolean
currentPreferences: {
unsubscribeAll?: boolean
unsubscribeMarketing?: boolean
unsubscribeUpdates?: boolean
unsubscribeNotifications?: boolean
}
}
export default function UnsubscribePage() {
const searchParams = useSearchParams()
const [loading, setLoading] = useState(true)
const [data, setData] = useState<UnsubscribeData | null>(null)
const [error, setError] = useState<string | null>(null)
const [processing, setProcessing] = useState(false)
const [unsubscribed, setUnsubscribed] = useState(false)
const email = searchParams.get('email')
const token = searchParams.get('token')
useEffect(() => {
if (!email || !token) {
setError('Missing email or token in URL')
setLoading(false)
return
}
// Validate the unsubscribe link
fetch(
`/api/user/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
)
.then((res) => res.json())
.then((data) => {
if (data.success) {
setData(data)
} else {
setError(data.error || 'Invalid unsubscribe link')
}
})
.catch(() => {
setError('Failed to validate unsubscribe link')
})
.finally(() => {
setLoading(false)
})
}, [email, token])
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
if (!email || !token) return
setProcessing(true)
try {
const response = await fetch('/api/user/settings/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
token,
type,
}),
})
const result = await response.json()
if (result.success) {
setUnsubscribed(true)
// Update the data to reflect the change
if (data) {
// Type-safe property construction with validation
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
if (validTypes.includes(type)) {
if (type === 'all') {
setData({
...data,
currentPreferences: {
...data.currentPreferences,
unsubscribeAll: true,
},
})
} else {
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
| 'unsubscribeMarketing'
| 'unsubscribeUpdates'
| 'unsubscribeNotifications'
setData({
...data,
currentPreferences: {
...data.currentPreferences,
[propertyKey]: true,
},
})
}
}
}
} else {
setError(result.error || 'Failed to unsubscribe')
}
} catch (error) {
setError('Failed to process unsubscribe request')
} finally {
setProcessing(false)
}
}
if (loading) {
return (
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardContent className='flex items-center justify-center p-8'>
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
<CardDescription className='text-muted-foreground'>
This unsubscribe link is invalid or has expired
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-red-50 p-4'>
<p className='text-red-800 text-sm'>
<strong>Error:</strong> {error}
</p>
</div>
<div className='space-y-3'>
<p className='text-muted-foreground text-sm'>This could happen if:</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>The link is missing required parameters</li>
<li>The link has expired or been used already</li>
<li>The link was copied incorrectly</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
'mailto:help@simstudio.ai?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.',
'_blank'
)
}
className='w-full bg-[#701ffc] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[#802FFF]'
>
Contact Support
</Button>
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
Go Back
</Button>
</div>
<div className='mt-4 text-center'>
<p className='text-muted-foreground text-xs'>
Need immediate help? Email us at{' '}
<a href='mailto:help@simstudio.ai' className='text-primary hover:underline'>
help@simstudio.ai
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}
// Handle transactional emails
if (data?.isTransactional) {
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
<CardDescription className='text-muted-foreground'>
This email contains important information about your account
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border bg-blue-50 p-4'>
<p className='text-blue-800 text-sm'>
<strong>Transactional emails</strong> like password resets, account confirmations,
and security alerts cannot be unsubscribed from as they contain essential
information for your account security and functionality.
</p>
</div>
<div className='space-y-3'>
<p className='text-foreground text-sm'>
If you no longer wish to receive these emails, you can:
</p>
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
<li>Close your account entirely</li>
<li>Contact our support team for assistance</li>
</ul>
</div>
<div className='mt-6 flex flex-col gap-3'>
<Button
onClick={() =>
window.open(
'mailto:help@simstudio.ai?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.',
'_blank'
)
}
className='w-full bg-blue-600 text-white hover:bg-blue-700'
>
Contact Support
</Button>
<Button onClick={() => window.close()} variant='outline' className='w-full'>
Close
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
if (unsubscribed) {
return (
<div className='flex min-h-screen items-center justify-center bg-background'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
<CardDescription className='text-muted-foreground'>
You have been unsubscribed from our emails. You will stop receiving emails within 48
hours.
</CardDescription>
</CardHeader>
<CardContent className='text-center'>
<p className='text-muted-foreground text-sm'>
If you change your mind, you can always update your email preferences in your account
settings or contact us at{' '}
<a href='mailto:help@simstudio.ai' className='text-primary hover:underline'>
help@simstudio.ai
</a>
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
<Card className='w-full max-w-md border shadow-sm'>
<CardHeader className='text-center'>
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
<CardTitle className='text-foreground'>We&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;d like to
stop receiving from Sim Studio.
</CardDescription>
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
Email: <span className='font-medium text-foreground'>{data?.email}</span>
</p>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-3'>
<Button
onClick={() => handleUnsubscribe('all')}
disabled={processing || data?.currentPreferences.unsubscribeAll}
variant='destructive'
className='w-full'
>
{processing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : data?.currentPreferences.unsubscribeAll ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeAll
? 'Unsubscribed from All Emails'
: 'Unsubscribe from All Marketing Emails'}
</Button>
<div className='text-center text-muted-foreground text-sm'>
or choose specific types:
</div>
<Button
onClick={() => handleUnsubscribe('marketing')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeMarketing
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeMarketing ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeMarketing
? 'Unsubscribed from Marketing'
: 'Unsubscribe from Marketing Emails'}
</Button>
<Button
onClick={() => handleUnsubscribe('updates')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeUpdates
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeUpdates ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeUpdates
? 'Unsubscribed from Updates'
: 'Unsubscribe from Product Updates'}
</Button>
<Button
onClick={() => handleUnsubscribe('notifications')}
disabled={
processing ||
data?.currentPreferences.unsubscribeAll ||
data?.currentPreferences.unsubscribeNotifications
}
variant='outline'
className='w-full'
>
{data?.currentPreferences.unsubscribeNotifications ? (
<CheckCircle className='mr-2 h-4 w-4' />
) : null}
{data?.currentPreferences.unsubscribeNotifications
? 'Unsubscribed from Notifications'
: 'Unsubscribe from Notifications'}
</Button>
</div>
<div className='mt-6 space-y-3'>
<div className='rounded-lg border bg-muted/50 p-3'>
<p className='text-center text-muted-foreground text-xs'>
<strong>Note:</strong> You&apos;ll continue receiving important account emails like
password resets and security alerts.
</p>
</div>
<p className='text-center text-muted-foreground text-xs'>
Questions? Contact us at{' '}
<a href='mailto:help@simstudio.ai' className='text-primary hover:underline'>
help@simstudio.ai
</a>
</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,12 +1,19 @@
import { Container, Img, Link, Section, Text } from '@react-email/components'
import { env } from '@/lib/env'
interface UnsubscribeOptions {
unsubscribeToken?: string
email?: string
}
interface EmailFooterProps {
baseUrl?: string
unsubscribe?: UnsubscribeOptions
}
export const EmailFooter = ({
baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai',
unsubscribe,
}: EmailFooterProps) => {
return (
<Container>
@@ -104,6 +111,23 @@ export const EmailFooter = ({
rel='noopener noreferrer'
>
Terms of Service
</a>{' '}
{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:help@simstudio.ai?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={{
color: '#706a7b !important',
textDecoration: 'underline',
fontWeight: 'normal',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
}}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</p>
</td>

View File

@@ -1,71 +1,59 @@
import { renderAsync } from '@react-email/components'
import { render } from '@react-email/components'
import { generateUnsubscribeToken } from '@/lib/email/unsubscribe'
import { InvitationEmail } from './invitation-email'
import { OTPVerificationEmail } from './otp-verification-email'
import { ResetPasswordEmail } from './reset-password-email'
import { WaitlistApprovalEmail } from './waitlist-approval-email'
import { WaitlistConfirmationEmail } from './waitlist-confirmation-email'
/**
* Renders the OTP verification email to HTML
*/
export async function renderOTPEmail(
otp: string,
email: string,
type: 'sign-in' | 'email-verification' | 'forget-password' = 'email-verification'
type: 'sign-in' | 'email-verification' | 'forget-password' = 'email-verification',
chatTitle?: string
): Promise<string> {
return await renderAsync(OTPVerificationEmail({ otp, email, type }))
return await render(OTPVerificationEmail({ otp, email, type, chatTitle }))
}
/**
* Renders the password reset email to HTML
*/
export async function renderPasswordResetEmail(
username: string,
resetLink: string
): Promise<string> {
return await renderAsync(ResetPasswordEmail({ username, resetLink, updatedDate: new Date() }))
return await render(
ResetPasswordEmail({ username, resetLink: resetLink, updatedDate: new Date() })
)
}
/**
* Renders the invitation email to HTML
*/
export async function renderInvitationEmail(
inviterName: string,
organizationName: string,
inviteLink: string,
invitedEmail: string
invitationUrl: string,
email: string
): Promise<string> {
return await renderAsync(
return await render(
InvitationEmail({
inviterName,
organizationName,
inviteLink,
invitedEmail,
inviteLink: invitationUrl,
invitedEmail: email,
updatedDate: new Date(),
})
)
}
/**
* Renders the waitlist confirmation email to HTML
*/
export async function renderWaitlistConfirmationEmail(email: string): Promise<string> {
return await renderAsync(WaitlistConfirmationEmail({ email }))
const unsubscribeToken = generateUnsubscribeToken(email, 'marketing')
return await render(WaitlistConfirmationEmail({ email, unsubscribeToken }))
}
/**
* Renders the waitlist approval email to HTML
*/
export async function renderWaitlistApprovalEmail(
email: string,
signupLink: string
signupUrl: string
): Promise<string> {
return await renderAsync(WaitlistApprovalEmail({ email, signupLink }))
const unsubscribeToken = generateUnsubscribeToken(email, 'updates')
return await render(WaitlistApprovalEmail({ email, signupUrl, unsubscribeToken }))
}
/**
* Gets the appropriate email subject based on email type
*/
export function getEmailSubject(
type:
| 'sign-in'

View File

@@ -13,18 +13,20 @@ import {
} from '@react-email/components'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
import { EmailFooter } from './footer'
interface WaitlistApprovalEmailProps {
email?: string
signupLink?: string
email: string
signupUrl: string
unsubscribeToken?: string
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
export const WaitlistApprovalEmail = ({
email = '',
signupLink = '',
email,
signupUrl,
unsubscribeToken,
}: WaitlistApprovalEmailProps) => {
return (
<Html>
@@ -65,7 +67,7 @@ export const WaitlistApprovalEmail = ({
Your email ({email}) has been approved. Click the button below to create your account
and start using Sim Studio today:
</Text>
<Link href={signupLink} style={{ textDecoration: 'none' }}>
<Link href={signupUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Create Your Account</Text>
</Link>
<Text style={baseStyles.paragraph}>
@@ -80,7 +82,7 @@ export const WaitlistApprovalEmail = ({
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
<EmailFooter baseUrl={baseUrl} unsubscribe={{ unsubscribeToken, email }} />
</Body>
</Html>
)

View File

@@ -13,16 +13,20 @@ import {
} from '@react-email/components'
import { env } from '@/lib/env'
import { baseStyles } from './base-styles'
import EmailFooter from './footer'
import { EmailFooter } from './footer'
interface WaitlistConfirmationEmailProps {
email?: string
email: string
unsubscribeToken?: string
}
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
const typeformLink = 'https://form.typeform.com/to/jqCO12pF'
export const WaitlistConfirmationEmail = ({ email = '' }: WaitlistConfirmationEmailProps) => {
export const WaitlistConfirmationEmail = ({
email,
unsubscribeToken,
}: WaitlistConfirmationEmailProps) => {
return (
<Html>
<Head />
@@ -76,7 +80,7 @@ export const WaitlistConfirmationEmail = ({ email = '' }: WaitlistConfirmationEm
</Section>
</Container>
<EmailFooter baseUrl={baseUrl} />
<EmailFooter baseUrl={baseUrl} unsubscribe={{ unsubscribeToken, email }} />
</Body>
</Html>
)

View File

@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "email_preferences" json DEFAULT '{}' NOT NULL;

View File

@@ -1,6 +1,6 @@
{
"id": "a022e5ae-e3ba-46c2-99f3-c335ac452538",
"prevId": "b85bdf30-35f9-40d4-a5a5-ba46dfe8a477",
"id": "01a747d8-d7e0-4f49-af52-b45e0f4343a9",
"prevId": "a022e5ae-e3ba-46c2-99f3-c335ac452538",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -1819,6 +1819,13 @@
"notNull": true,
"default": false
},
"email_preferences": {
"name": "email_preferences",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"general": {
"name": "general",
"type": "json",

View File

@@ -281,6 +281,13 @@
"when": 1748846439041,
"tag": "0040_silky_monster_badoon",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1749514555378,
"tag": "0041_sparkling_ma_gnuci",
"breakpoints": true
}
]
}

View File

@@ -154,6 +154,9 @@ export const settings = pgTable('settings', {
telemetryEnabled: boolean('telemetry_enabled').notNull().default(true),
telemetryNotifiedUser: boolean('telemetry_notified_user').notNull().default(false),
// Email preferences
emailPreferences: json('email_preferences').notNull().default('{}'),
// Keep general for future flexible settings
general: json('general').notNull().default('{}'),

View File

@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
const mockSend = vi.fn()
vi.mock('resend', () => {
return {
Resend: vi.fn().mockImplementation(() => ({
emails: {
send: (...args: any[]) => mockSend(...args),
},
})),
}
})
vi.mock('./unsubscribe', () => ({
isUnsubscribed: vi.fn(),
generateUnsubscribeToken: vi.fn(),
}))
vi.mock('../env', () => ({
env: {
RESEND_API_KEY: 'test-api-key',
NEXT_PUBLIC_APP_URL: 'https://test.simstudio.ai',
},
}))
vi.mock('../urls/utils', () => ({
getEmailDomain: vi.fn().mockReturnValue('simstudio.ai'),
}))
import { type EmailType, sendEmail } from './mailer'
import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe'
describe('mailer', () => {
const testEmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test email content</p>',
}
beforeEach(() => {
vi.clearAllMocks()
;(isUnsubscribed as Mock).mockResolvedValue(false)
;(generateUnsubscribeToken as Mock).mockReturnValue('mock-token-123')
mockSend.mockResolvedValue({
data: { id: 'test-email-id' },
error: null,
})
})
describe('sendEmail', () => {
it('should send a transactional email successfully', async () => {
const result = await sendEmail({
...testEmailOptions,
emailType: 'transactional',
})
expect(result.success).toBe(true)
expect(result.message).toBe('Email sent successfully')
expect(result.data).toEqual({ id: 'test-email-id' })
// Should not check unsubscribe status for transactional emails
expect(isUnsubscribed).not.toHaveBeenCalled()
// Should call Resend with correct parameters
expect(mockSend).toHaveBeenCalledWith({
from: 'Sim Studio <noreply@simstudio.ai>',
to: testEmailOptions.to,
subject: testEmailOptions.subject,
html: testEmailOptions.html,
headers: undefined, // No unsubscribe headers for transactional
})
})
it('should send a marketing email with unsubscribe headers', async () => {
const htmlWithToken = '<p>Test content</p><a href="{{UNSUBSCRIBE_TOKEN}}">Unsubscribe</a>'
const result = await sendEmail({
...testEmailOptions,
html: htmlWithToken,
emailType: 'marketing',
})
expect(result.success).toBe(true)
// Should check unsubscribe status
expect(isUnsubscribed).toHaveBeenCalledWith(testEmailOptions.to, 'marketing')
// Should generate unsubscribe token
expect(generateUnsubscribeToken).toHaveBeenCalledWith(testEmailOptions.to, 'marketing')
// Should call Resend with unsubscribe headers
expect(mockSend).toHaveBeenCalledWith({
from: 'Sim Studio <noreply@simstudio.ai>',
to: testEmailOptions.to,
subject: testEmailOptions.subject,
html: '<p>Test content</p><a href="mock-token-123">Unsubscribe</a>',
headers: {
'List-Unsubscribe':
'<https://test.simstudio.ai/unsubscribe?token=mock-token-123&email=test%40example.com>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
})
})
it('should skip sending if user has unsubscribed', async () => {
;(isUnsubscribed as Mock).mockResolvedValue(true)
const result = await sendEmail({
...testEmailOptions,
emailType: 'marketing',
})
expect(result.success).toBe(true)
expect(result.message).toBe('Email skipped (user unsubscribed)')
expect(result.data).toEqual({ id: 'skipped-unsubscribed' })
// Should not call Resend
expect(mockSend).not.toHaveBeenCalled()
})
it('should handle Resend API errors', async () => {
mockSend.mockResolvedValue({
data: null,
error: { message: 'API rate limit exceeded' },
})
const result = await sendEmail(testEmailOptions)
expect(result.success).toBe(false)
expect(result.message).toBe('API rate limit exceeded')
})
it('should handle unexpected errors', async () => {
mockSend.mockRejectedValue(new Error('Network error'))
const result = await sendEmail(testEmailOptions)
expect(result.success).toBe(false)
expect(result.message).toBe('Failed to send email')
})
it('should use custom from address when provided', async () => {
await sendEmail({
...testEmailOptions,
from: 'custom@example.com',
})
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
from: 'Sim Studio <custom@example.com>',
})
)
})
it('should not include unsubscribe when includeUnsubscribe is false', async () => {
await sendEmail({
...testEmailOptions,
emailType: 'marketing',
includeUnsubscribe: false,
})
expect(generateUnsubscribeToken).not.toHaveBeenCalled()
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
headers: undefined,
})
)
})
it('should replace unsubscribe token placeholders in HTML', async () => {
const htmlWithPlaceholder = '<p>Content</p><a href="{{UNSUBSCRIBE_TOKEN}}">Unsubscribe</a>'
await sendEmail({
...testEmailOptions,
html: htmlWithPlaceholder,
emailType: 'updates' as EmailType,
})
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
html: '<p>Content</p><a href="mock-token-123">Unsubscribe</a>',
})
)
})
})
})

View File

@@ -1,13 +1,20 @@
import { Resend } from 'resend'
import { createLogger } from '@/lib/logs/console-logger'
import { env } from './env'
import { getEmailDomain } from './urls/utils'
import { env } from '../env'
import { getEmailDomain } from '../urls/utils'
import { generateUnsubscribeToken, isUnsubscribed } from './unsubscribe'
const logger = createLogger('Mailer')
export type EmailType = 'transactional' | 'marketing' | 'updates' | 'notifications'
interface EmailOptions {
to: string
subject: string
html: string
from?: string
emailType?: EmailType
includeUnsubscribe?: boolean
}
interface BatchEmailOptions {
@@ -27,9 +34,8 @@ interface BatchSendEmailResult {
data?: any
}
const logger = createLogger('Mailer')
const resendApiKey = env.RESEND_API_KEY
const resend =
resendApiKey && resendApiKey !== 'placeholder' && resendApiKey.trim() !== ''
? new Resend(resendApiKey)
@@ -40,8 +46,28 @@ export async function sendEmail({
subject,
html,
from,
emailType = 'transactional',
includeUnsubscribe = true,
}: EmailOptions): Promise<SendEmailResult> {
try {
// Check if user has unsubscribed (skip for critical transactional emails)
if (emailType !== 'transactional') {
const unsubscribeType = emailType as 'marketing' | 'updates' | 'notifications'
const hasUnsubscribed = await isUnsubscribed(to, unsubscribeType)
if (hasUnsubscribed) {
logger.info('Email not sent (user unsubscribed):', {
to,
subject,
emailType,
})
return {
success: true,
message: 'Email skipped (user unsubscribed)',
data: { id: 'skipped-unsubscribed' },
}
}
}
const senderEmail = from || `noreply@${getEmailDomain()}`
if (!resend) {
@@ -57,11 +83,27 @@ export async function sendEmail({
}
}
// Generate unsubscribe token and add to HTML
let finalHtml = html
const headers: Record<string, string> = {}
if (includeUnsubscribe && emailType !== 'transactional') {
const unsubscribeToken = generateUnsubscribeToken(to, emailType)
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(to)}`
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
finalHtml = html.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
}
const { data, error } = await resend.emails.send({
from: `Sim Studio <${senderEmail}>`,
to,
subject,
html,
html: finalHtml,
headers: Object.keys(headers).length > 0 ? headers : undefined,
})
if (error) {

View File

@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest'
import type { EmailType } from './mailer'
import {
generateUnsubscribeToken,
isTransactionalEmail,
verifyUnsubscribeToken,
} from './unsubscribe'
vi.stubEnv('BETTER_AUTH_SECRET', 'test-secret-key')
describe('unsubscribe utilities', () => {
const testEmail = 'test@example.com'
const testEmailType = 'marketing'
describe('generateUnsubscribeToken', () => {
it('should generate a token with salt:hash:emailType format', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const parts = token.split(':')
expect(parts).toHaveLength(3)
expect(parts[0]).toHaveLength(32) // Salt should be 32 chars (16 bytes hex)
expect(parts[1]).toHaveLength(64) // SHA256 hash should be 64 chars
expect(parts[2]).toBe(testEmailType)
})
it('should generate different tokens for the same email (due to random salt)', () => {
const token1 = generateUnsubscribeToken(testEmail, testEmailType)
const token2 = generateUnsubscribeToken(testEmail, testEmailType)
expect(token1).not.toBe(token2)
})
it('should default to marketing email type', () => {
const token = generateUnsubscribeToken(testEmail)
const parts = token.split(':')
expect(parts[2]).toBe('marketing')
})
it('should generate different tokens for different email types', () => {
const marketingToken = generateUnsubscribeToken(testEmail, 'marketing')
const updatesToken = generateUnsubscribeToken(testEmail, 'updates')
expect(marketingToken).not.toBe(updatesToken)
})
})
describe('verifyUnsubscribeToken', () => {
it('should verify a valid token', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const result = verifyUnsubscribeToken(testEmail, token)
expect(result.valid).toBe(true)
expect(result.emailType).toBe(testEmailType)
})
it('should reject an invalid token', () => {
const invalidToken = 'invalid:token:format'
const result = verifyUnsubscribeToken(testEmail, invalidToken)
expect(result.valid).toBe(false)
expect(result.emailType).toBe('format')
})
it('should reject a token for wrong email', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const result = verifyUnsubscribeToken('wrong@example.com', token)
expect(result.valid).toBe(false)
})
it('should handle legacy tokens (2 parts) and default to marketing', () => {
// Generate a real legacy token using the actual hashing logic to ensure backward compatibility
const salt = 'abc123'
const { createHash } = require('crypto')
const hash = createHash('sha256')
.update(`${testEmail}:${salt}:${process.env.BETTER_AUTH_SECRET}`)
.digest('hex')
const legacyToken = `${salt}:${hash}`
// This should return valid since we're using the actual legacy format properly
const result = verifyUnsubscribeToken(testEmail, legacyToken)
expect(result.valid).toBe(true)
expect(result.emailType).toBe('marketing') // Should default to marketing for legacy tokens
})
it('should reject malformed tokens', () => {
const malformedTokens = ['', 'single-part', 'too:many:parts:here:invalid', ':empty:parts:']
malformedTokens.forEach((token) => {
const result = verifyUnsubscribeToken(testEmail, token)
expect(result.valid).toBe(false)
})
})
})
describe('isTransactionalEmail', () => {
it('should identify transactional emails correctly', () => {
expect(isTransactionalEmail('transactional')).toBe(true)
})
it('should identify non-transactional emails correctly', () => {
const nonTransactionalTypes: EmailType[] = ['marketing', 'updates', 'notifications']
nonTransactionalTypes.forEach((type) => {
expect(isTransactionalEmail(type)).toBe(false)
})
})
})
})

View File

@@ -0,0 +1,207 @@
import { createHash, randomBytes } from 'crypto'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { settings, user } from '@/db/schema'
import type { EmailType } from './mailer'
const logger = createLogger('Unsubscribe')
export interface EmailPreferences {
unsubscribeAll?: boolean
unsubscribeMarketing?: boolean
unsubscribeUpdates?: boolean
unsubscribeNotifications?: boolean
}
/**
* Generate a secure unsubscribe token for an email address
*/
export function generateUnsubscribeToken(email: string, emailType = 'marketing'): string {
const salt = randomBytes(16).toString('hex')
const hash = createHash('sha256')
.update(`${email}:${salt}:${emailType}:${process.env.BETTER_AUTH_SECRET}`)
.digest('hex')
return `${salt}:${hash}:${emailType}`
}
/**
* Verify an unsubscribe token for an email address and return email type
*/
export function verifyUnsubscribeToken(
email: string,
token: string
): { valid: boolean; emailType?: string } {
try {
const parts = token.split(':')
if (parts.length < 2) return { valid: false }
// Handle legacy tokens (without email type)
if (parts.length === 2) {
const [salt, expectedHash] = parts
const hash = createHash('sha256')
.update(`${email}:${salt}:${process.env.BETTER_AUTH_SECRET}`)
.digest('hex')
return { valid: hash === expectedHash, emailType: 'marketing' }
}
// Handle new tokens (with email type)
const [salt, expectedHash, emailType] = parts
if (!salt || !expectedHash || !emailType) return { valid: false }
const hash = createHash('sha256')
.update(`${email}:${salt}:${emailType}:${process.env.BETTER_AUTH_SECRET}`)
.digest('hex')
return { valid: hash === expectedHash, emailType }
} catch (error) {
logger.error('Error verifying unsubscribe token:', error)
return { valid: false }
}
}
/**
* Check if an email type is transactional
*/
export function isTransactionalEmail(emailType: EmailType): boolean {
return emailType === ('transactional' as EmailType)
}
/**
* Get user's email preferences
*/
export async function getEmailPreferences(email: string): Promise<EmailPreferences | null> {
try {
const result = await db
.select({
emailPreferences: settings.emailPreferences,
})
.from(user)
.leftJoin(settings, eq(settings.userId, user.id))
.where(eq(user.email, email))
.limit(1)
if (!result[0]) return null
return (result[0].emailPreferences as EmailPreferences) || {}
} catch (error) {
logger.error('Error getting email preferences:', error)
return null
}
}
/**
* Update user's email preferences
*/
export async function updateEmailPreferences(
email: string,
preferences: EmailPreferences
): Promise<boolean> {
try {
// First, find the user
const userResult = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1)
if (!userResult[0]) {
logger.warn(`User not found for email: ${email}`)
return false
}
const userId = userResult[0].id
// Get existing email preferences
const existingSettings = await db
.select({ emailPreferences: settings.emailPreferences })
.from(settings)
.where(eq(settings.userId, userId))
.limit(1)
let currentEmailPreferences = {}
if (existingSettings[0]) {
currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {}
}
// Merge email preferences
const updatedEmailPreferences = {
...currentEmailPreferences,
...preferences,
}
// Upsert settings
await db
.insert(settings)
.values({
id: userId,
userId,
emailPreferences: updatedEmailPreferences,
})
.onConflictDoUpdate({
target: settings.userId,
set: {
emailPreferences: updatedEmailPreferences,
updatedAt: new Date(),
},
})
logger.info(`Updated email preferences for user: ${email}`)
return true
} catch (error) {
logger.error('Error updating email preferences:', error)
return false
}
}
/**
* Check if user has unsubscribed from a specific email type
*/
export async function isUnsubscribed(
email: string,
emailType: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
): Promise<boolean> {
try {
const preferences = await getEmailPreferences(email)
if (!preferences) return false
// Check unsubscribe all first
if (preferences.unsubscribeAll) return true
// Check specific type
switch (emailType) {
case 'marketing':
return preferences.unsubscribeMarketing || false
case 'updates':
return preferences.unsubscribeUpdates || false
case 'notifications':
return preferences.unsubscribeNotifications || false
default:
return false
}
} catch (error) {
logger.error('Error checking unsubscribe status:', error)
return false
}
}
/**
* Unsubscribe user from all emails
*/
export async function unsubscribeFromAll(email: string): Promise<boolean> {
return updateEmailPreferences(email, { unsubscribeAll: true })
}
/**
* Resubscribe user (remove all unsubscribe flags)
*/
export async function resubscribe(email: string): Promise<boolean> {
return updateEmailPreferences(email, {
unsubscribeAll: false,
unsubscribeMarketing: false,
unsubscribeUpdates: false,
unsubscribeNotifications: false,
})
}

View File

@@ -5,7 +5,7 @@ import {
renderWaitlistApprovalEmail,
renderWaitlistConfirmationEmail,
} from '@/components/emails/render-email'
import { sendBatchEmails, sendEmail } from '@/lib/mailer'
import { type EmailType, sendBatchEmails, sendEmail } from '@/lib/email/mailer'
import { createToken, verifyToken } from '@/lib/waitlist/token'
import { db } from '@/db'
import { waitlist } from '@/db/schema'
@@ -197,6 +197,7 @@ export async function approveWaitlistUser(
to: normalizedEmail,
subject,
html: emailHtml,
emailType: 'updates',
})
// If email sending failed, don't update the user status
@@ -427,6 +428,7 @@ export async function resendApprovalEmail(
to: normalizedEmail,
subject,
html: emailHtml,
emailType: 'updates',
})
// Check for email sending failures
@@ -553,6 +555,7 @@ export async function approveBatchWaitlistUsers(emails: string[]): Promise<{
to: user.email,
subject,
html: emailHtml,
emailType: 'updates' as EmailType,
}
})
)