diff --git a/apps/sim/app/api/chat/[subdomain]/otp/route.ts b/apps/sim/app/api/chat/[subdomain]/otp/route.ts
index 05eaa34509..78a7116e52 100644
--- a/apps/sim/app/api/chat/[subdomain]/otp/route.ts
+++ b/apps/sim/app/api/chat/[subdomain]/otp/route.ts
@@ -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'
diff --git a/apps/sim/app/api/user/settings/route.ts b/apps/sim/app/api/user/settings/route.ts
index d63851d8b8..5204aa730d 100644
--- a/apps/sim/app/api/user/settings/route.ts
+++ b/apps/sim/app/api/user/settings/route.ts
@@ -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 }
diff --git a/apps/sim/app/api/user/settings/unsubscribe/route.ts b/apps/sim/app/api/user/settings/unsubscribe/route.ts
new file mode 100644
index 0000000000..286938ebb3
--- /dev/null
+++ b/apps/sim/app/api/user/settings/unsubscribe/route.ts
@@ -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 })
+ }
+}
diff --git a/apps/sim/app/unsubscribe/page.tsx b/apps/sim/app/unsubscribe/page.tsx
new file mode 100644
index 0000000000..6a560865d3
--- /dev/null
+++ b/apps/sim/app/unsubscribe/page.tsx
@@ -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
+ Error: {error}
+ This could happen if:
+ Need immediate help? Email us at{' '}
+
+ help@simstudio.ai
+
+
+ Transactional emails like password resets, account confirmations,
+ and security alerts cannot be unsubscribed from as they contain essential
+ information for your account security and functionality.
+
+ If you no longer wish to receive these emails, you can:
+
+ If you change your mind, you can always update your email preferences in your account
+ settings or contact us at{' '}
+
+ help@simstudio.ai
+
+
+ Email: {data?.email}
+
+ Note: You'll continue receiving important account emails like
+ password resets and security alerts.
+
+ Questions? Contact us at{' '}
+
+ help@simstudio.ai
+
+
+
+
+
+