mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
fix(unsubscribe): add one-click unsubscribe (#2467)
* fix(unsubscribe): add one-click unsubscribe * ack Pr comments
This commit is contained in:
@@ -32,7 +32,6 @@ export async function GET(req: NextRequest) {
|
||||
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}`)
|
||||
@@ -42,7 +41,6 @@ export async function GET(req: NextRequest) {
|
||||
const emailType = tokenVerification.emailType as EmailType
|
||||
const isTransactional = isTransactionalEmail(emailType)
|
||||
|
||||
// Get current preferences
|
||||
const preferences = await getEmailPreferences(email)
|
||||
|
||||
logger.info(
|
||||
@@ -67,22 +65,42 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const result = unsubscribeSchema.safeParse(body)
|
||||
const { searchParams } = new URL(req.url)
|
||||
const contentType = req.headers.get('content-type') || ''
|
||||
|
||||
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 }
|
||||
)
|
||||
let email: string
|
||||
let token: string
|
||||
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
email = searchParams.get('email') || ''
|
||||
token = searchParams.get('token') || ''
|
||||
|
||||
if (!email || !token) {
|
||||
logger.warn(`[${requestId}] One-click unsubscribe missing email or token in URL`)
|
||||
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Processing one-click unsubscribe for: ${email}`)
|
||||
} else {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
email = result.data.email
|
||||
token = result.data.token
|
||||
type = result.data.type
|
||||
}
|
||||
|
||||
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}`)
|
||||
@@ -92,7 +110,6 @@ export async function POST(req: NextRequest) {
|
||||
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(
|
||||
@@ -106,7 +123,6 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Process unsubscribe based on type
|
||||
let success = false
|
||||
switch (type) {
|
||||
case 'all':
|
||||
@@ -130,7 +146,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
|
||||
|
||||
// Return 200 for one-click unsubscribe compliance
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
|
||||
@@ -39,7 +39,6 @@ function UnsubscribeContent() {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
@@ -81,9 +80,7 @@ function UnsubscribeContent() {
|
||||
|
||||
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') {
|
||||
@@ -192,7 +189,6 @@ function UnsubscribeContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
|
||||
@@ -79,10 +79,8 @@ export function hasEmailService(): boolean {
|
||||
|
||||
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
|
||||
try {
|
||||
// Check if user has unsubscribed (skip for critical transactional emails)
|
||||
if (options.emailType !== 'transactional') {
|
||||
const unsubscribeType = options.emailType as 'marketing' | 'updates' | 'notifications'
|
||||
// For arrays, check the first email address (batch emails typically go to similar recipients)
|
||||
const primaryEmail = Array.isArray(options.to) ? options.to[0] : options.to
|
||||
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
|
||||
if (hasUnsubscribed) {
|
||||
@@ -99,10 +97,8 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
|
||||
}
|
||||
}
|
||||
|
||||
// Process email data with unsubscribe tokens and headers
|
||||
const processedData = await processEmailData(options)
|
||||
|
||||
// Try Resend first if configured
|
||||
if (resend) {
|
||||
try {
|
||||
return await sendWithResend(processedData)
|
||||
@@ -111,7 +107,6 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Azure Communication Services if configured
|
||||
if (azureEmailClient) {
|
||||
try {
|
||||
return await sendWithAzure(processedData)
|
||||
@@ -124,7 +119,6 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
|
||||
}
|
||||
}
|
||||
|
||||
// No email service configured
|
||||
logger.info('Email not sent (no email service configured):', {
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
@@ -144,6 +138,32 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
|
||||
}
|
||||
}
|
||||
|
||||
interface UnsubscribeData {
|
||||
headers: Record<string, string>
|
||||
html?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function addUnsubscribeData(
|
||||
recipientEmail: string,
|
||||
emailType: string,
|
||||
html?: string,
|
||||
text?: string
|
||||
): UnsubscribeData {
|
||||
const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType)
|
||||
const baseUrl = getBaseUrl()
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}`
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'List-Unsubscribe': `<${unsubscribeUrl}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
html: html?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
||||
text: text?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken),
|
||||
}
|
||||
}
|
||||
|
||||
async function processEmailData(options: EmailOptions): Promise<ProcessedEmailData> {
|
||||
const {
|
||||
to,
|
||||
@@ -159,27 +179,16 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
|
||||
|
||||
const senderEmail = from || getFromEmailAddress()
|
||||
|
||||
// Generate unsubscribe token and add to content
|
||||
let finalHtml = html
|
||||
let finalText = text
|
||||
const headers: Record<string, string> = {}
|
||||
let headers: Record<string, string> = {}
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
// For arrays, use the first email for unsubscribe (batch emails typically go to similar recipients)
|
||||
const primaryEmail = Array.isArray(to) ? to[0] : to
|
||||
const unsubscribeToken = generateUnsubscribeToken(primaryEmail, emailType)
|
||||
const baseUrl = getBaseUrl()
|
||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(primaryEmail)}`
|
||||
|
||||
headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`
|
||||
headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
|
||||
|
||||
if (html) {
|
||||
finalHtml = html.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||
}
|
||||
if (text) {
|
||||
finalText = text.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken)
|
||||
}
|
||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, html, text)
|
||||
headers = unsubData.headers
|
||||
finalHtml = unsubData.html
|
||||
finalText = unsubData.text
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -234,13 +243,10 @@ async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult
|
||||
async function sendWithAzure(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
if (!azureEmailClient) throw new Error('Azure Communication Services not configured')
|
||||
|
||||
// Azure Communication Services requires at least one content type
|
||||
if (!data.html && !data.text) {
|
||||
throw new Error('Azure Communication Services requires either HTML or text content')
|
||||
}
|
||||
|
||||
// For Azure, use just the email address part (no display name)
|
||||
// Azure will use the display name configured in the portal for the sender address
|
||||
const senderEmailOnly = data.senderEmail.includes('<')
|
||||
? data.senderEmail.match(/<(.+)>/)?.[1] || data.senderEmail
|
||||
: data.senderEmail
|
||||
@@ -281,7 +287,6 @@ export async function sendBatchEmails(options: BatchEmailOptions): Promise<Batch
|
||||
try {
|
||||
const results: SendEmailResult[] = []
|
||||
|
||||
// Try Resend first for batch emails if available
|
||||
if (resend) {
|
||||
try {
|
||||
return await sendBatchWithResend(options.emails)
|
||||
@@ -290,7 +295,6 @@ export async function sendBatchEmails(options: BatchEmailOptions): Promise<Batch
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to individual sends (works with both Azure and Resend)
|
||||
logger.info('Sending batch emails individually')
|
||||
for (const email of options.emails) {
|
||||
try {
|
||||
@@ -328,17 +332,57 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
||||
if (!resend) throw new Error('Resend not configured')
|
||||
|
||||
const results: SendEmailResult[] = []
|
||||
const batchEmails = emails.map((email) => {
|
||||
const skippedIndices: number[] = []
|
||||
const batchEmails: any[] = []
|
||||
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
const email = emails[i]
|
||||
const { emailType = 'transactional', includeUnsubscribe = true } = email
|
||||
|
||||
if (emailType !== 'transactional') {
|
||||
const unsubscribeType = emailType as 'marketing' | 'updates' | 'notifications'
|
||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
|
||||
if (hasUnsubscribed) {
|
||||
skippedIndices.push(i)
|
||||
results.push({
|
||||
success: true,
|
||||
message: 'Email skipped (user unsubscribed)',
|
||||
data: { id: 'skipped-unsubscribed' },
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const senderEmail = email.from || getFromEmailAddress()
|
||||
const emailData: any = {
|
||||
from: senderEmail,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
}
|
||||
|
||||
if (email.html) emailData.html = email.html
|
||||
if (email.text) emailData.text = email.text
|
||||
return emailData
|
||||
})
|
||||
|
||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
||||
const primaryEmail = Array.isArray(email.to) ? email.to[0] : email.to
|
||||
const unsubData = addUnsubscribeData(primaryEmail, emailType, email.html, email.text)
|
||||
emailData.headers = unsubData.headers
|
||||
if (unsubData.html) emailData.html = unsubData.html
|
||||
if (unsubData.text) emailData.text = unsubData.text
|
||||
}
|
||||
|
||||
batchEmails.push(emailData)
|
||||
}
|
||||
|
||||
if (batchEmails.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'All batch emails skipped (users unsubscribed)',
|
||||
results,
|
||||
data: { count: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await resend.batch.send(batchEmails as any)
|
||||
@@ -347,7 +391,6 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
||||
throw new Error(response.error.message || 'Resend batch API error')
|
||||
}
|
||||
|
||||
// Success - create results for each email
|
||||
batchEmails.forEach((_, index) => {
|
||||
results.push({
|
||||
success: true,
|
||||
@@ -358,12 +401,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'All batch emails sent successfully via Resend',
|
||||
message:
|
||||
skippedIndices.length > 0
|
||||
? `${batchEmails.length} emails sent, ${skippedIndices.length} skipped (unsubscribed)`
|
||||
: 'All batch emails sent successfully via Resend',
|
||||
results,
|
||||
data: { count: results.length },
|
||||
data: { count: batchEmails.length },
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Resend batch send failed:', error)
|
||||
throw error // Let the caller handle fallback
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user