From 49a8a0551d7f7067774ca5336fbc2dfd4b4ffc9d Mon Sep 17 00:00:00 2001
From: Waleed Latif
Date: Mon, 9 Jun 2025 19:04:20 -0700
Subject: [PATCH] fix(email): added unsubscribe from email functionality (#468)
* added unsubscribe from email functionality
* added tests
* ack PR comments
---
.../sim/app/api/chat/[subdomain]/otp/route.ts | 2 +-
apps/sim/app/api/user/settings/route.ts | 10 +
.../api/user/settings/unsubscribe/route.ts | 147 +++++++
apps/sim/app/unsubscribe/page.tsx | 382 ++++++++++++++++++
apps/sim/components/emails/footer.tsx | 24 ++
apps/sim/components/emails/render-email.ts | 48 +--
.../emails/waitlist-approval-email.tsx | 16 +-
.../emails/waitlist-confirmation-email.tsx | 12 +-
.../db/migrations/0041_sparkling_ma_gnuci.sql | 1 +
.../sim/db/migrations/meta/0041_snapshot.json | 11 +-
apps/sim/db/migrations/meta/_journal.json | 7 +
apps/sim/db/schema.ts | 3 +
apps/sim/lib/email/mailer.test.ts | 187 +++++++++
apps/sim/lib/{ => email}/mailer.ts | 52 ++-
apps/sim/lib/email/unsubscribe.test.ts | 110 +++++
apps/sim/lib/email/unsubscribe.ts | 207 ++++++++++
apps/sim/lib/waitlist/service.ts | 5 +-
17 files changed, 1174 insertions(+), 50 deletions(-)
create mode 100644 apps/sim/app/api/user/settings/unsubscribe/route.ts
create mode 100644 apps/sim/app/unsubscribe/page.tsx
create mode 100644 apps/sim/db/migrations/0041_sparkling_ma_gnuci.sql
create mode 100644 apps/sim/lib/email/mailer.test.ts
rename apps/sim/lib/{ => email}/mailer.ts (81%)
create mode 100644 apps/sim/lib/email/unsubscribe.test.ts
create mode 100644 apps/sim/lib/email/unsubscribe.ts
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(null)
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Invalid Unsubscribe Link
+
+ This unsubscribe link is invalid or has expired
+
+
+
+
+
+
+
This could happen if:
+
+ The link is missing required parameters
+ The link has expired or been used already
+ The link was copied incorrectly
+
+
+
+
+
+ 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
+
+ window.history.back()} variant='outline' className='w-full'>
+ Go Back
+
+
+
+
+
+
+
+ )
+ }
+
+ // Handle transactional emails
+ if (data?.isTransactional) {
+ return (
+
+
+
+
+ Important Account Emails
+
+ This email contains important information about your account
+
+
+
+
+
+ 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:
+
+
+ Close your account entirely
+ Contact our support team for assistance
+
+
+
+
+
+ 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
+
+ window.close()} variant='outline' className='w-full'>
+ Close
+
+
+
+
+
+ )
+ }
+
+ if (unsubscribed) {
+ return (
+
+
+
+
+ Successfully Unsubscribed
+
+ You have been unsubscribed from our emails. You will stop receiving emails within 48
+ hours.
+
+
+
+
+ If you change your mind, you can always update your email preferences in your account
+ settings or contact us at{' '}
+
+ help@simstudio.ai
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ We're sorry to see you go!
+
+ We understand email preferences are personal. Choose which emails you'd like to
+ stop receiving from Sim Studio.
+
+
+
+ Email: {data?.email}
+
+
+
+
+
+
handleUnsubscribe('all')}
+ disabled={processing || data?.currentPreferences.unsubscribeAll}
+ variant='destructive'
+ className='w-full'
+ >
+ {processing ? (
+
+ ) : data?.currentPreferences.unsubscribeAll ? (
+
+ ) : null}
+ {data?.currentPreferences.unsubscribeAll
+ ? 'Unsubscribed from All Emails'
+ : 'Unsubscribe from All Marketing Emails'}
+
+
+
+ or choose specific types:
+
+
+
handleUnsubscribe('marketing')}
+ disabled={
+ processing ||
+ data?.currentPreferences.unsubscribeAll ||
+ data?.currentPreferences.unsubscribeMarketing
+ }
+ variant='outline'
+ className='w-full'
+ >
+ {data?.currentPreferences.unsubscribeMarketing ? (
+
+ ) : null}
+ {data?.currentPreferences.unsubscribeMarketing
+ ? 'Unsubscribed from Marketing'
+ : 'Unsubscribe from Marketing Emails'}
+
+
+
handleUnsubscribe('updates')}
+ disabled={
+ processing ||
+ data?.currentPreferences.unsubscribeAll ||
+ data?.currentPreferences.unsubscribeUpdates
+ }
+ variant='outline'
+ className='w-full'
+ >
+ {data?.currentPreferences.unsubscribeUpdates ? (
+
+ ) : null}
+ {data?.currentPreferences.unsubscribeUpdates
+ ? 'Unsubscribed from Updates'
+ : 'Unsubscribe from Product Updates'}
+
+
+
handleUnsubscribe('notifications')}
+ disabled={
+ processing ||
+ data?.currentPreferences.unsubscribeAll ||
+ data?.currentPreferences.unsubscribeNotifications
+ }
+ variant='outline'
+ className='w-full'
+ >
+ {data?.currentPreferences.unsubscribeNotifications ? (
+
+ ) : null}
+ {data?.currentPreferences.unsubscribeNotifications
+ ? 'Unsubscribed from Notifications'
+ : 'Unsubscribe from Notifications'}
+
+
+
+
+
+
+ Note: You'll continue receiving important account emails like
+ password resets and security alerts.
+
+
+
+
+ Questions? Contact us at{' '}
+
+ help@simstudio.ai
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/components/emails/footer.tsx b/apps/sim/components/emails/footer.tsx
index 53cb2d9355..b6295913f0 100644
--- a/apps/sim/components/emails/footer.tsx
+++ b/apps/sim/components/emails/footer.tsx
@@ -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 (
@@ -104,6 +111,23 @@ export const EmailFooter = ({
rel='noopener noreferrer'
>
Terms of Service
+ {' '}
+ •{' '}
+
+ Unsubscribe
diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render-email.ts
index 50e26939b5..983e0410b3 100644
--- a/apps/sim/components/emails/render-email.ts
+++ b/apps/sim/components/emails/render-email.ts
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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'
diff --git a/apps/sim/components/emails/waitlist-approval-email.tsx b/apps/sim/components/emails/waitlist-approval-email.tsx
index 024d95b903..295acc2d66 100644
--- a/apps/sim/components/emails/waitlist-approval-email.tsx
+++ b/apps/sim/components/emails/waitlist-approval-email.tsx
@@ -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 (
@@ -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:
-
+
Create Your Account
@@ -80,7 +82,7 @@ export const WaitlistApprovalEmail = ({
-
+