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 + + + +
+

+ Error: {error} +

+
+ +
+

This could happen if:

+
    +
  • The link is missing required parameters
  • +
  • The link has expired or been used already
  • +
  • The link was copied incorrectly
  • +
+
+ +
+ + +
+ +
+

+ Need immediate help? Email us at{' '} + + help@simstudio.ai + +

+
+
+
+
+ ) + } + + // 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
  • +
+
+ +
+ + +
+
+
+
+ ) + } + + 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} +

+
+
+ +
+ + +
+ or choose specific types: +
+ + + + + + +
+ +
+
+

+ 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 = ({
- + ) diff --git a/apps/sim/components/emails/waitlist-confirmation-email.tsx b/apps/sim/components/emails/waitlist-confirmation-email.tsx index 2dcbaae19a..445f11af7e 100644 --- a/apps/sim/components/emails/waitlist-confirmation-email.tsx +++ b/apps/sim/components/emails/waitlist-confirmation-email.tsx @@ -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 ( @@ -76,7 +80,7 @@ export const WaitlistConfirmationEmail = ({ email = '' }: WaitlistConfirmationEm - + ) diff --git a/apps/sim/db/migrations/0041_sparkling_ma_gnuci.sql b/apps/sim/db/migrations/0041_sparkling_ma_gnuci.sql new file mode 100644 index 0000000000..1bf286ef68 --- /dev/null +++ b/apps/sim/db/migrations/0041_sparkling_ma_gnuci.sql @@ -0,0 +1 @@ +ALTER TABLE "settings" ADD COLUMN "email_preferences" json DEFAULT '{}' NOT NULL; \ No newline at end of file diff --git a/apps/sim/db/migrations/meta/0041_snapshot.json b/apps/sim/db/migrations/meta/0041_snapshot.json index b2d570e4a7..2f81fba6c0 100644 --- a/apps/sim/db/migrations/meta/0041_snapshot.json +++ b/apps/sim/db/migrations/meta/0041_snapshot.json @@ -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", diff --git a/apps/sim/db/migrations/meta/_journal.json b/apps/sim/db/migrations/meta/_journal.json index 079a31beb5..9afe5b351d 100644 --- a/apps/sim/db/migrations/meta/_journal.json +++ b/apps/sim/db/migrations/meta/_journal.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 } ] } diff --git a/apps/sim/db/schema.ts b/apps/sim/db/schema.ts index 35b7878273..a8c74a8f6e 100644 --- a/apps/sim/db/schema.ts +++ b/apps/sim/db/schema.ts @@ -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('{}'), diff --git a/apps/sim/lib/email/mailer.test.ts b/apps/sim/lib/email/mailer.test.ts new file mode 100644 index 0000000000..05901b1c2c --- /dev/null +++ b/apps/sim/lib/email/mailer.test.ts @@ -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: '

Test email content

', + } + + 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 ', + 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 = '

Test content

Unsubscribe' + + 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 ', + to: testEmailOptions.to, + subject: testEmailOptions.subject, + html: '

Test content

Unsubscribe', + headers: { + 'List-Unsubscribe': + '', + '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 ', + }) + ) + }) + + 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 = '

Content

Unsubscribe' + + await sendEmail({ + ...testEmailOptions, + html: htmlWithPlaceholder, + emailType: 'updates' as EmailType, + }) + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + html: '

Content

Unsubscribe', + }) + ) + }) + }) +}) diff --git a/apps/sim/lib/mailer.ts b/apps/sim/lib/email/mailer.ts similarity index 81% rename from apps/sim/lib/mailer.ts rename to apps/sim/lib/email/mailer.ts index f52a305959..6922760ef2 100644 --- a/apps/sim/lib/mailer.ts +++ b/apps/sim/lib/email/mailer.ts @@ -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 { 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 = {} + + 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) { diff --git a/apps/sim/lib/email/unsubscribe.test.ts b/apps/sim/lib/email/unsubscribe.test.ts new file mode 100644 index 0000000000..89abcb9fa5 --- /dev/null +++ b/apps/sim/lib/email/unsubscribe.test.ts @@ -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) + }) + }) + }) +}) diff --git a/apps/sim/lib/email/unsubscribe.ts b/apps/sim/lib/email/unsubscribe.ts new file mode 100644 index 0000000000..7ca03d83c1 --- /dev/null +++ b/apps/sim/lib/email/unsubscribe.ts @@ -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 { + 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 { + 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 { + 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 { + return updateEmailPreferences(email, { unsubscribeAll: true }) +} + +/** + * Resubscribe user (remove all unsubscribe flags) + */ +export async function resubscribe(email: string): Promise { + return updateEmailPreferences(email, { + unsubscribeAll: false, + unsubscribeMarketing: false, + unsubscribeUpdates: false, + unsubscribeNotifications: false, + }) +} diff --git a/apps/sim/lib/waitlist/service.ts b/apps/sim/lib/waitlist/service.ts index 492042737c..f4e54fb8a6 100644 --- a/apps/sim/lib/waitlist/service.ts +++ b/apps/sim/lib/waitlist/service.ts @@ -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, } }) )