Skip to content

Commit 49a8a05

Browse files
authored
fix(email): added unsubscribe from email functionality (#468)
* added unsubscribe from email functionality * added tests * ack PR comments
1 parent 59b68d2 commit 49a8a05

File tree

17 files changed

+1174
-50
lines changed

17 files changed

+1174
-50
lines changed

apps/sim/app/api/chat/[subdomain]/otp/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { eq } from 'drizzle-orm'
33
import type { NextRequest } from 'next/server'
44
import { z } from 'zod'
55
import OTPVerificationEmail from '@/components/emails/otp-verification-email'
6+
import { sendEmail } from '@/lib/email/mailer'
67
import { createLogger } from '@/lib/logs/console-logger'
7-
import { sendEmail } from '@/lib/mailer'
88
import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis'
99
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1010
import { db } from '@/db'

apps/sim/app/api/user/settings/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const SettingsSchema = z.object({
1616
autoFillEnvVars: z.boolean().optional(),
1717
telemetryEnabled: z.boolean().optional(),
1818
telemetryNotifiedUser: z.boolean().optional(),
19+
emailPreferences: z
20+
.object({
21+
unsubscribeAll: z.boolean().optional(),
22+
unsubscribeMarketing: z.boolean().optional(),
23+
unsubscribeUpdates: z.boolean().optional(),
24+
unsubscribeNotifications: z.boolean().optional(),
25+
})
26+
.optional(),
1927
})
2028

2129
// Default settings values
@@ -26,6 +34,7 @@ const defaultSettings = {
2634
autoFillEnvVars: true,
2735
telemetryEnabled: true,
2836
telemetryNotifiedUser: false,
37+
emailPreferences: {},
2938
}
3039

3140
export async function GET() {
@@ -58,6 +67,7 @@ export async function GET() {
5867
autoFillEnvVars: userSettings.autoFillEnvVars,
5968
telemetryEnabled: userSettings.telemetryEnabled,
6069
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
70+
emailPreferences: userSettings.emailPreferences ?? {},
6171
},
6272
},
6373
{ status: 200 }
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import type { EmailType } from '@/lib/email/mailer'
4+
import {
5+
getEmailPreferences,
6+
isTransactionalEmail,
7+
unsubscribeFromAll,
8+
updateEmailPreferences,
9+
verifyUnsubscribeToken,
10+
} from '@/lib/email/unsubscribe'
11+
import { createLogger } from '@/lib/logs/console-logger'
12+
13+
const logger = createLogger('UnsubscribeAPI')
14+
15+
const unsubscribeSchema = z.object({
16+
email: z.string().email('Invalid email address'),
17+
token: z.string().min(1, 'Token is required'),
18+
type: z.enum(['all', 'marketing', 'updates', 'notifications']).optional().default('all'),
19+
})
20+
21+
export async function GET(req: NextRequest) {
22+
const requestId = crypto.randomUUID().slice(0, 8)
23+
24+
try {
25+
const { searchParams } = new URL(req.url)
26+
const email = searchParams.get('email')
27+
const token = searchParams.get('token')
28+
29+
if (!email || !token) {
30+
logger.warn(`[${requestId}] Missing email or token in GET request`)
31+
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
32+
}
33+
34+
// Verify token and get email type
35+
const tokenVerification = verifyUnsubscribeToken(email, token)
36+
if (!tokenVerification.valid) {
37+
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
38+
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
39+
}
40+
41+
const emailType = tokenVerification.emailType as EmailType
42+
const isTransactional = isTransactionalEmail(emailType)
43+
44+
// Get current preferences
45+
const preferences = await getEmailPreferences(email)
46+
47+
logger.info(
48+
`[${requestId}] Valid unsubscribe GET request for email: ${email}, type: ${emailType}`
49+
)
50+
51+
return NextResponse.json({
52+
success: true,
53+
email,
54+
token,
55+
emailType,
56+
isTransactional,
57+
currentPreferences: preferences || {},
58+
})
59+
} catch (error) {
60+
logger.error(`[${requestId}] Error processing unsubscribe GET request:`, error)
61+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
62+
}
63+
}
64+
65+
export async function POST(req: NextRequest) {
66+
const requestId = crypto.randomUUID().slice(0, 8)
67+
68+
try {
69+
const body = await req.json()
70+
const result = unsubscribeSchema.safeParse(body)
71+
72+
if (!result.success) {
73+
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
74+
errors: result.error.format(),
75+
})
76+
return NextResponse.json(
77+
{ error: 'Invalid request data', details: result.error.format() },
78+
{ status: 400 }
79+
)
80+
}
81+
82+
const { email, token, type } = result.data
83+
84+
// Verify token and get email type
85+
const tokenVerification = verifyUnsubscribeToken(email, token)
86+
if (!tokenVerification.valid) {
87+
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
88+
return NextResponse.json({ error: 'Invalid or expired unsubscribe link' }, { status: 400 })
89+
}
90+
91+
const emailType = tokenVerification.emailType as EmailType
92+
const isTransactional = isTransactionalEmail(emailType)
93+
94+
// Prevent unsubscribing from transactional emails
95+
if (isTransactional) {
96+
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
97+
return NextResponse.json(
98+
{
99+
error: 'Cannot unsubscribe from transactional emails',
100+
isTransactional: true,
101+
message:
102+
'Transactional emails cannot be unsubscribed from as they contain important account information.',
103+
},
104+
{ status: 400 }
105+
)
106+
}
107+
108+
// Process unsubscribe based on type
109+
let success = false
110+
switch (type) {
111+
case 'all':
112+
success = await unsubscribeFromAll(email)
113+
break
114+
case 'marketing':
115+
success = await updateEmailPreferences(email, { unsubscribeMarketing: true })
116+
break
117+
case 'updates':
118+
success = await updateEmailPreferences(email, { unsubscribeUpdates: true })
119+
break
120+
case 'notifications':
121+
success = await updateEmailPreferences(email, { unsubscribeNotifications: true })
122+
break
123+
}
124+
125+
if (!success) {
126+
logger.error(`[${requestId}] Failed to update unsubscribe preferences for: ${email}`)
127+
return NextResponse.json({ error: 'Failed to process unsubscribe request' }, { status: 500 })
128+
}
129+
130+
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
131+
132+
// Return 200 for one-click unsubscribe compliance
133+
return NextResponse.json(
134+
{
135+
success: true,
136+
message: `Successfully unsubscribed from ${type} emails`,
137+
email,
138+
type,
139+
emailType,
140+
},
141+
{ status: 200 }
142+
)
143+
} catch (error) {
144+
logger.error(`[${requestId}] Error processing unsubscribe POST request:`, error)
145+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
146+
}
147+
}

0 commit comments

Comments
 (0)