mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 06:58:07 -05:00
fix(email): added unsubscribe from email functionality (#468)
* added unsubscribe from email functionality * added tests * ack PR comments
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
147
apps/sim/app/api/user/settings/unsubscribe/route.ts
Normal file
147
apps/sim/app/api/user/settings/unsubscribe/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
382
apps/sim/app/unsubscribe/page.tsx
Normal file
382
apps/sim/app/unsubscribe/page.tsx
Normal 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're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
1
apps/sim/db/migrations/0041_sparkling_ma_gnuci.sql
Normal file
1
apps/sim/db/migrations/0041_sparkling_ma_gnuci.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "settings" ADD COLUMN "email_preferences" json DEFAULT '{}' NOT NULL;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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('{}'),
|
||||
|
||||
|
||||
187
apps/sim/lib/email/mailer.test.ts
Normal file
187
apps/sim/lib/email/mailer.test.ts
Normal 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>',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
110
apps/sim/lib/email/unsubscribe.test.ts
Normal file
110
apps/sim/lib/email/unsubscribe.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
207
apps/sim/lib/email/unsubscribe.ts
Normal file
207
apps/sim/lib/email/unsubscribe.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user