Skip to content

Commit 24356d9

Browse files
authored
fix(unsubscribe): add one-click unsubscribe (#2467)
* fix(unsubscribe): add one-click unsubscribe * ack Pr comments
1 parent 6de1c04 commit 24356d9

File tree

3 files changed

+114
-57
lines changed

3 files changed

+114
-57
lines changed

apps/sim/app/api/users/me/settings/unsubscribe/route.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ export async function GET(req: NextRequest) {
3232
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
3333
}
3434

35-
// Verify token and get email type
3635
const tokenVerification = verifyUnsubscribeToken(email, token)
3736
if (!tokenVerification.valid) {
3837
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
@@ -42,7 +41,6 @@ export async function GET(req: NextRequest) {
4241
const emailType = tokenVerification.emailType as EmailType
4342
const isTransactional = isTransactionalEmail(emailType)
4443

45-
// Get current preferences
4644
const preferences = await getEmailPreferences(email)
4745

4846
logger.info(
@@ -67,22 +65,42 @@ export async function POST(req: NextRequest) {
6765
const requestId = generateRequestId()
6866

6967
try {
70-
const body = await req.json()
71-
const result = unsubscribeSchema.safeParse(body)
72-
73-
if (!result.success) {
74-
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
75-
errors: result.error.format(),
76-
})
77-
return NextResponse.json(
78-
{ error: 'Invalid request data', details: result.error.format() },
79-
{ status: 400 }
80-
)
68+
const { searchParams } = new URL(req.url)
69+
const contentType = req.headers.get('content-type') || ''
70+
71+
let email: string
72+
let token: string
73+
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
74+
75+
if (contentType.includes('application/x-www-form-urlencoded')) {
76+
email = searchParams.get('email') || ''
77+
token = searchParams.get('token') || ''
78+
79+
if (!email || !token) {
80+
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
81+
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
82+
}
83+
84+
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
85+
} else {
86+
const body = await req.json()
87+
const result = unsubscribeSchema.safeParse(body)
88+
89+
if (!result.success) {
90+
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
91+
errors: result.error.format(),
92+
})
93+
return NextResponse.json(
94+
{ error: 'Invalid request data', details: result.error.format() },
95+
{ status: 400 }
96+
)
97+
}
98+
99+
email = result.data.email
100+
token = result.data.token
101+
type = result.data.type
81102
}
82103

83-
const { email, token, type } = result.data
84-
85-
// Verify token and get email type
86104
const tokenVerification = verifyUnsubscribeToken(email, token)
87105
if (!tokenVerification.valid) {
88106
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
@@ -92,7 +110,6 @@ export async function POST(req: NextRequest) {
92110
const emailType = tokenVerification.emailType as EmailType
93111
const isTransactional = isTransactionalEmail(emailType)
94112

95-
// Prevent unsubscribing from transactional emails
96113
if (isTransactional) {
97114
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
98115
return NextResponse.json(
@@ -106,7 +123,6 @@ export async function POST(req: NextRequest) {
106123
)
107124
}
108125

109-
// Process unsubscribe based on type
110126
let success = false
111127
switch (type) {
112128
case 'all':
@@ -130,7 +146,6 @@ export async function POST(req: NextRequest) {
130146

131147
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
132148

133-
// Return 200 for one-click unsubscribe compliance
134149
return NextResponse.json(
135150
{
136151
success: true,

apps/sim/app/unsubscribe/unsubscribe.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ function UnsubscribeContent() {
3939
return
4040
}
4141

42-
// Validate the unsubscribe link
4342
fetch(
4443
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
4544
)
@@ -81,9 +80,7 @@ function UnsubscribeContent() {
8180

8281
if (result.success) {
8382
setUnsubscribed(true)
84-
// Update the data to reflect the change
8583
if (data) {
86-
// Type-safe property construction with validation
8784
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
8885
if (validTypes.includes(type)) {
8986
if (type === 'all') {
@@ -192,7 +189,6 @@ function UnsubscribeContent() {
192189
)
193190
}
194191

195-
// Handle transactional emails
196192
if (data?.isTransactional) {
197193
return (
198194
<div className='flex min-h-screen items-center justify-center bg-background p-4'>

apps/sim/lib/messaging/email/mailer.ts

Lines changed: 80 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,8 @@ export function hasEmailService(): boolean {
7979

8080
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
8181
try {
82-
// Check if user has unsubscribed (skip for critical transactional emails)
8382
if (options.emailType !== 'transactional') {
8483
const unsubscribeType = options.emailType as 'marketing' | 'updates' | 'notifications'
85-
// For arrays, check the first email address (batch emails typically go to similar recipients)
8684
const primaryEmail = Array.isArray(options.to) ? options.to[0] : options.to
8785
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
8886
if (hasUnsubscribed) {
@@ -99,10 +97,8 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
9997
}
10098
}
10199

102-
// Process email data with unsubscribe tokens and headers
103100
const processedData = await processEmailData(options)
104101

105-
// Try Resend first if configured
106102
if (resend) {
107103
try {
108104
return await sendWithResend(processedData)
@@ -111,7 +107,6 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
111107
}
112108
}
113109

114-
// Fallback to Azure Communication Services if configured
115110
if (azureEmailClient) {
116111
try {
117112
return await sendWithAzure(processedData)
@@ -124,7 +119,6 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
124119
}
125120
}
126121

127-
// No email service configured
128122
logger.info('Email not sent (no email service configured):', {
129123
to: options.to,
130124
subject: options.subject,
@@ -144,6 +138,32 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
144138
}
145139
}
146140

141+
interface UnsubscribeData {
142+
headers: Record<string, string>
143+
html?: string
144+
text?: string
145+
}
146+
147+
function addUnsubscribeData(
148+
recipientEmail: string,
149+
emailType: string,
150+
html?: string,
151+
text?: string
152+
): UnsubscribeData {
153+
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
154+
const baseUrl = getBaseUrl()
155+
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}`
156+
157+
return {
158+
headers: {
159+
'List-Unsubscribe': `<${unsubscribeUrl}>`,
160+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
161+
},
162+
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
163+
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
164+
}
165+
}
166+
147167
async function processEmailData(options: EmailOptions): Promise<ProcessedEmailData> {
148168
const {
149169
to,
@@ -159,27 +179,16 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
159179

160180
const senderEmail = from || getFromEmailAddress()
161181

162-
// Generate unsubscribe token and add to content
163182
let finalHtml = html
164183
let finalText = text
165-
const headers: Record<string, string> = {}
184+
let headers: Record<string, string> = {}
166185

167186
if (includeUnsubscribe && emailType !== 'transactional') {
168-
// For arrays, use the first email for unsubscribe (batch emails typically go to similar recipients)
169187
const primaryEmail = Array.isArray(to) ? to[0] : to
170-
const unsubscribeToken = generateUnsubscribeToken(primaryEmail, emailType)
171-
const baseUrl = getBaseUrl()
172-
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(primaryEmail)}`
173-
174-
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`
175-
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
176-
177-
if (html) {
178-
finalHtml = html.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
179-
}
180-
if (text) {
181-
finalText = text.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
182-
}
188+
const unsubData = addUnsubscribeData(primaryEmail, emailType, html, text)
189+
headers = unsubData.headers
190+
finalHtml = unsubData.html
191+
finalText = unsubData.text
183192
}
184193

185194
return {
@@ -234,13 +243,10 @@ async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult
234243
async function sendWithAzure(data: ProcessedEmailData): Promise<SendEmailResult> {
235244
if (!azureEmailClient) throw new Error('Azure Communication Services not configured')
236245

237-
// Azure Communication Services requires at least one content type
238246
if (!data.html && !data.text) {
239247
throw new Error('Azure Communication Services requires either HTML or text content')
240248
}
241249

242-
// For Azure, use just the email address part (no display name)
243-
// Azure will use the display name configured in the portal for the sender address
244250
const senderEmailOnly = data.senderEmail.includes('<')
245251
? data.senderEmail.match(/<(.+)>/)?.[1] || data.senderEmail
246252
: data.senderEmail
@@ -281,7 +287,6 @@ export async function sendBatchEmails(options: BatchEmailOptions): Promise<Batch
281287
try {
282288
const results: SendEmailResult[] = []
283289

284-
// Try Resend first for batch emails if available
285290
if (resend) {
286291
try {
287292
return await sendBatchWithResend(options.emails)
@@ -290,7 +295,6 @@ export async function sendBatchEmails(options: BatchEmailOptions): Promise<Batch
290295
}
291296
}
292297

293-
// Fallback to individual sends (works with both Azure and Resend)
294298
logger.info('Sending batch emails individually')
295299
for (const email of options.emails) {
296300
try {
@@ -328,17 +332,57 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
328332
if (!resend) throw new Error('Resend not configured')
329333

330334
const results: SendEmailResult[] = []
331-
const batchEmails = emails.map((email) => {
335+
const skippedIndices: number[] = []
336+
const batchEmails: any[] = []
337+
338+
for (let i = 0; i < emails.length; i++) {
339+
const email = emails[i]
340+
const { emailType = 'transactional', includeUnsubscribe = true } = email
341+
342+
if (emailType !== 'transactional') {
343+
const unsubscribeType = emailType as 'marketing' | 'updates' | 'notifications'
344+
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
345+
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
346+
if (hasUnsubscribed) {
347+
skippedIndices.push(i)
348+
results.push({
349+
success: true,
350+
message: 'Email skipped (user unsubscribed)',
351+
data: { id: 'skipped-unsubscribed' },
352+
})
353+
continue
354+
}
355+
}
356+
332357
const senderEmail = email.from || getFromEmailAddress()
333358
const emailData: any = {
334359
from: senderEmail,
335360
to: email.to,
336361
subject: email.subject,
337362
}
363+
338364
if (email.html) emailData.html = email.html
339365
if (email.text) emailData.text = email.text
340-
return emailData
341-
})
366+
367+
if (includeUnsubscribe && emailType !== 'transactional') {
368+
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
369+
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
370+
emailData.headers = unsubData.headers
371+
if (unsubData.html) emailData.html = unsubData.html
372+
if (unsubData.text) emailData.text = unsubData.text
373+
}
374+
375+
batchEmails.push(emailData)
376+
}
377+
378+
if (batchEmails.length === 0) {
379+
return {
380+
success: true,
381+
message: 'All batch emails skipped (users unsubscribed)',
382+
results,
383+
data: { count: 0 },
384+
}
385+
}
342386

343387
try {
344388
const response = await resend.batch.send(batchEmails as any)
@@ -347,7 +391,6 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
347391
throw new Error(response.error.message || 'Resend batch API error')
348392
}
349393

350-
// Success - create results for each email
351394
batchEmails.forEach((_, index) => {
352395
results.push({
353396
success: true,
@@ -358,12 +401,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
358401

359402
return {
360403
success: true,
361-
message: 'All batch emails sent successfully via Resend',
404+
message:
405+
skippedIndices.length > 0
406+
? `${batchEmails.length} emails sent, ${skippedIndices.length} skipped (unsubscribed)`
407+
: 'All batch emails sent successfully via Resend',
362408
results,
363-
data: { count: results.length },
409+
data: { count: batchEmails.length },
364410
}
365411
} catch (error) {
366412
logger.error('Resend batch send failed:', error)
367-
throw error // Let the caller handle fallback
413+
throw error
368414
}
369415
}

0 commit comments

Comments
 (0)