mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -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 })
|
return NextResponse.json({ error: 'Missing email or token parameter' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token and get email type
|
|
||||||
const tokenVerification = verifyUnsubscribeToken(email, token)
|
const tokenVerification = verifyUnsubscribeToken(email, token)
|
||||||
if (!tokenVerification.valid) {
|
if (!tokenVerification.valid) {
|
||||||
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
|
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 emailType = tokenVerification.emailType as EmailType
|
||||||
const isTransactional = isTransactionalEmail(emailType)
|
const isTransactional = isTransactionalEmail(emailType)
|
||||||
|
|
||||||
// Get current preferences
|
|
||||||
const preferences = await getEmailPreferences(email)
|
const preferences = await getEmailPreferences(email)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -67,22 +65,42 @@ export async function POST(req: NextRequest) {
|
|||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json()
|
const { searchParams } = new URL(req.url)
|
||||||
const result = unsubscribeSchema.safeParse(body)
|
const contentType = req.headers.get('content-type') || ''
|
||||||
|
|
||||||
if (!result.success) {
|
let email: string
|
||||||
logger.warn(`[${requestId}] Invalid unsubscribe POST data`, {
|
let token: string
|
||||||
errors: result.error.format(),
|
let type: 'all' | 'marketing' | 'updates' | 'notifications' = 'all'
|
||||||
})
|
|
||||||
return NextResponse.json(
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
{ error: 'Invalid request data', details: result.error.format() },
|
email = searchParams.get('email') || ''
|
||||||
{ status: 400 }
|
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)
|
const tokenVerification = verifyUnsubscribeToken(email, token)
|
||||||
if (!tokenVerification.valid) {
|
if (!tokenVerification.valid) {
|
||||||
logger.warn(`[${requestId}] Invalid unsubscribe token for email: ${email}`)
|
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 emailType = tokenVerification.emailType as EmailType
|
||||||
const isTransactional = isTransactionalEmail(emailType)
|
const isTransactional = isTransactionalEmail(emailType)
|
||||||
|
|
||||||
// Prevent unsubscribing from transactional emails
|
|
||||||
if (isTransactional) {
|
if (isTransactional) {
|
||||||
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
|
logger.warn(`[${requestId}] Attempted to unsubscribe from transactional email: ${email}`)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -106,7 +123,6 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process unsubscribe based on type
|
|
||||||
let success = false
|
let success = false
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'all':
|
case 'all':
|
||||||
@@ -130,7 +146,6 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
|
logger.info(`[${requestId}] Successfully unsubscribed ${email} from ${type}`)
|
||||||
|
|
||||||
// Return 200 for one-click unsubscribe compliance
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function UnsubscribeContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the unsubscribe link
|
|
||||||
fetch(
|
fetch(
|
||||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||||
)
|
)
|
||||||
@@ -81,9 +80,7 @@ function UnsubscribeContent() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setUnsubscribed(true)
|
setUnsubscribed(true)
|
||||||
// Update the data to reflect the change
|
|
||||||
if (data) {
|
if (data) {
|
||||||
// Type-safe property construction with validation
|
|
||||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||||
if (validTypes.includes(type)) {
|
if (validTypes.includes(type)) {
|
||||||
if (type === 'all') {
|
if (type === 'all') {
|
||||||
@@ -192,7 +189,6 @@ function UnsubscribeContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle transactional emails
|
|
||||||
if (data?.isTransactional) {
|
if (data?.isTransactional) {
|
||||||
return (
|
return (
|
||||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
<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> {
|
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
|
||||||
try {
|
try {
|
||||||
// Check if user has unsubscribed (skip for critical transactional emails)
|
|
||||||
if (options.emailType !== 'transactional') {
|
if (options.emailType !== 'transactional') {
|
||||||
const unsubscribeType = options.emailType as 'marketing' | 'updates' | 'notifications'
|
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 primaryEmail = Array.isArray(options.to) ? options.to[0] : options.to
|
||||||
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
|
const hasUnsubscribed = await isUnsubscribed(primaryEmail, unsubscribeType)
|
||||||
if (hasUnsubscribed) {
|
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)
|
const processedData = await processEmailData(options)
|
||||||
|
|
||||||
// Try Resend first if configured
|
|
||||||
if (resend) {
|
if (resend) {
|
||||||
try {
|
try {
|
||||||
return await sendWithResend(processedData)
|
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) {
|
if (azureEmailClient) {
|
||||||
try {
|
try {
|
||||||
return await sendWithAzure(processedData)
|
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):', {
|
logger.info('Email not sent (no email service configured):', {
|
||||||
to: options.to,
|
to: options.to,
|
||||||
subject: options.subject,
|
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> {
|
async function processEmailData(options: EmailOptions): Promise<ProcessedEmailData> {
|
||||||
const {
|
const {
|
||||||
to,
|
to,
|
||||||
@@ -159,27 +179,16 @@ async function processEmailData(options: EmailOptions): Promise<ProcessedEmailDa
|
|||||||
|
|
||||||
const senderEmail = from || getFromEmailAddress()
|
const senderEmail = from || getFromEmailAddress()
|
||||||
|
|
||||||
// Generate unsubscribe token and add to content
|
|
||||||
let finalHtml = html
|
let finalHtml = html
|
||||||
let finalText = text
|
let finalText = text
|
||||||
const headers: Record<string, string> = {}
|
let headers: Record<string, string> = {}
|
||||||
|
|
||||||
if (includeUnsubscribe && emailType !== 'transactional') {
|
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 primaryEmail = Array.isArray(to) ? to[0] : to
|
||||||
const unsubscribeToken = generateUnsubscribeToken(primaryEmail, emailType)
|
const unsubData = addUnsubscribeData(primaryEmail, emailType, html, text)
|
||||||
const baseUrl = getBaseUrl()
|
headers = unsubData.headers
|
||||||
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(primaryEmail)}`
|
finalHtml = unsubData.html
|
||||||
|
finalText = unsubData.text
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -234,13 +243,10 @@ async function sendWithResend(data: ProcessedEmailData): Promise<SendEmailResult
|
|||||||
async function sendWithAzure(data: ProcessedEmailData): Promise<SendEmailResult> {
|
async function sendWithAzure(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||||
if (!azureEmailClient) throw new Error('Azure Communication Services not configured')
|
if (!azureEmailClient) throw new Error('Azure Communication Services not configured')
|
||||||
|
|
||||||
// Azure Communication Services requires at least one content type
|
|
||||||
if (!data.html && !data.text) {
|
if (!data.html && !data.text) {
|
||||||
throw new Error('Azure Communication Services requires either HTML or text content')
|
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('<')
|
const senderEmailOnly = data.senderEmail.includes('<')
|
||||||
? data.senderEmail.match(/<(.+)>/)?.[1] || data.senderEmail
|
? data.senderEmail.match(/<(.+)>/)?.[1] || data.senderEmail
|
||||||
: data.senderEmail
|
: data.senderEmail
|
||||||
@@ -281,7 +287,6 @@ export async function sendBatchEmails(options: BatchEmailOptions): Promise<Batch
|
|||||||
try {
|
try {
|
||||||
const results: SendEmailResult[] = []
|
const results: SendEmailResult[] = []
|
||||||
|
|
||||||
// Try Resend first for batch emails if available
|
|
||||||
if (resend) {
|
if (resend) {
|
||||||
try {
|
try {
|
||||||
return await sendBatchWithResend(options.emails)
|
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')
|
logger.info('Sending batch emails individually')
|
||||||
for (const email of options.emails) {
|
for (const email of options.emails) {
|
||||||
try {
|
try {
|
||||||
@@ -328,17 +332,57 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
|||||||
if (!resend) throw new Error('Resend not configured')
|
if (!resend) throw new Error('Resend not configured')
|
||||||
|
|
||||||
const results: SendEmailResult[] = []
|
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 senderEmail = email.from || getFromEmailAddress()
|
||||||
const emailData: any = {
|
const emailData: any = {
|
||||||
from: senderEmail,
|
from: senderEmail,
|
||||||
to: email.to,
|
to: email.to,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.html) emailData.html = email.html
|
if (email.html) emailData.html = email.html
|
||||||
if (email.text) emailData.text = email.text
|
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 {
|
try {
|
||||||
const response = await resend.batch.send(batchEmails as any)
|
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')
|
throw new Error(response.error.message || 'Resend batch API error')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - create results for each email
|
|
||||||
batchEmails.forEach((_, index) => {
|
batchEmails.forEach((_, index) => {
|
||||||
results.push({
|
results.push({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -358,12 +401,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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,
|
results,
|
||||||
data: { count: results.length },
|
data: { count: batchEmails.length },
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Resend batch send failed:', error)
|
logger.error('Resend batch send failed:', error)
|
||||||
throw error // Let the caller handle fallback
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user