From 4152edcd5cc28ab4180e4b67ecc3e310955dc2ff Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 18 Jan 2026 20:36:42 -0800 Subject: [PATCH] improvement(emails): update unsub page, standardize unsub process --- apps/sim/app/unsubscribe/unsubscribe.tsx | 406 +++++++----------- .../emails/auth/otp-verification-email.tsx | 2 +- .../emails/auth/reset-password-email.tsx | 2 +- .../components/emails/auth/welcome-email.tsx | 2 +- .../emails/billing/credit-purchase-email.tsx | 2 +- .../billing/enterprise-subscription-email.tsx | 5 +- .../billing/free-tier-upgrade-email.tsx | 2 +- .../emails/billing/payment-failed-email.tsx | 2 +- .../emails/billing/plan-welcome-email.tsx | 2 +- .../emails/billing/usage-threshold-email.tsx | 2 +- .../careers/careers-confirmation-email.tsx | 5 +- .../careers/careers-submission-email.tsx | 2 +- .../emails/components/email-footer.tsx | 46 +- .../emails/components/email-layout.tsx | 14 +- .../invitations/batch-invitation-email.tsx | 1 + .../emails/invitations/invitation-email.tsx | 5 +- .../polling-group-invitation-email.tsx | 5 +- .../workspace-invitation-email.tsx | 1 + .../workflow-notification-email.tsx | 2 +- .../support/help-confirmation-email.tsx | 5 +- apps/sim/lib/messaging/email/mailer.ts | 17 +- .../lib/messaging/email/unsubscribe.test.ts | 8 +- apps/sim/lib/messaging/email/unsubscribe.ts | 8 - 23 files changed, 240 insertions(+), 306 deletions(-) diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index a48f8afe46..75d565c06a 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -1,10 +1,13 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react' +import { Loader2 } from 'lucide-react' import { useSearchParams } from 'next/navigation' -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { useBrandConfig } from '@/lib/branding/branding' +import { inter } from '@/app/_styles/fonts/inter/inter' +import { soehne } from '@/app/_styles/fonts/soehne/soehne' +import { BrandedButton } from '@/app/(auth)/components/branded-button' +import { SupportFooter } from '@/app/(auth)/components/support-footer' +import { InviteLayout } from '@/app/invite/components' interface UnsubscribeData { success: boolean @@ -27,7 +30,6 @@ function UnsubscribeContent() { const [error, setError] = useState(null) const [processing, setProcessing] = useState(false) const [unsubscribed, setUnsubscribed] = useState(false) - const brand = useBrandConfig() const email = searchParams.get('email') const token = searchParams.get('token') @@ -109,7 +111,7 @@ function UnsubscribeContent() { } else { setError(result.error || 'Failed to unsubscribe') } - } catch (error) { + } catch { setError('Failed to process unsubscribe request') } finally { setProcessing(false) @@ -118,272 +120,171 @@ function UnsubscribeContent() { if (loading) { return ( -
- - - - - -
+ +
+

+ Loading +

+

+ Validating your unsubscribe link... +

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

+ Invalid Unsubscribe Link +

+

+ {error} +

+
-
- - -
+
+ window.history.back()}>Go Back +
-
-

- Need immediate help? Email us at{' '} - - {brand.supportEmail} - -

-
-
-
-
+ + ) } 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. -

-
+ +
+

+ Important Account Emails +

+

+ Transactional emails like password resets, account confirmations, and security alerts + cannot be unsubscribed from as they contain essential information for your account. +

+
-
-

- If you no longer wish to receive these emails, you can: -

-
    -
  • Close your account entirely
  • -
  • Contact our support team for assistance
  • -
-
+
+ window.close()}>Close +
-
- - -
-
-
-
+ + ) } 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{' '} - - {brand.supportEmail} - -

-
-
-
+ +
+

+ Successfully Unsubscribed +

+

+ You have been unsubscribed from our emails. You will stop receiving emails within 48 + hours. +

+
+ +
+ window.close()}>Close +
+ + +
) } + const isAlreadyUnsubscribedFromAll = data?.currentPreferences.unsubscribeAll + 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. - -
-

- Email: {data?.email} -

-
-
- -
- + +
+

+ Email Preferences +

+

+ Choose which emails you'd like to stop receiving. +

+

+ {data?.email} +

+
-
- or choose specific types: -
+
+ handleUnsubscribe('all')} + disabled={processing || isAlreadyUnsubscribedFromAll} + loading={processing} + loadingText='Unsubscribing' + > + {isAlreadyUnsubscribedFromAll + ? 'Unsubscribed from All Emails' + : 'Unsubscribe from All Marketing Emails'} + - +
+ + or choose specific types + +
- + handleUnsubscribe('marketing')} + disabled={ + processing || + isAlreadyUnsubscribedFromAll || + data?.currentPreferences.unsubscribeMarketing + } + > + {data?.currentPreferences.unsubscribeMarketing + ? 'Unsubscribed from Marketing' + : 'Unsubscribe from Marketing Emails'} + - -
+ handleUnsubscribe('updates')} + disabled={ + processing || + isAlreadyUnsubscribedFromAll || + data?.currentPreferences.unsubscribeUpdates + } + > + {data?.currentPreferences.unsubscribeUpdates + ? 'Unsubscribed from Updates' + : 'Unsubscribe from Product Updates'} + + + handleUnsubscribe('notifications')} + disabled={ + processing || + isAlreadyUnsubscribedFromAll || + data?.currentPreferences.unsubscribeNotifications + } + > + {data?.currentPreferences.unsubscribeNotifications + ? 'Unsubscribed from Notifications' + : 'Unsubscribe from Notifications'} + +
-
-
-

- Note: You'll continue receiving important account emails like - password resets and security alerts. -

-
+
+

+ You'll continue receiving important account emails like password resets and security + alerts. +

+
-

- Questions? Contact us at{' '} - - {brand.supportEmail} - -

-
-
-
-
+ + ) } @@ -391,13 +292,20 @@ export default function Unsubscribe() { return ( - - - - - - + +
+

+ Loading +

+

+ Validating your unsubscribe link... +

+
+
+ +
+ +
} > diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx index 41791adfb6..d6ec6dc63d 100644 --- a/apps/sim/components/emails/auth/otp-verification-email.tsx +++ b/apps/sim/components/emails/auth/otp-verification-email.tsx @@ -34,7 +34,7 @@ export function OTPVerificationEmail({ const brand = getBrandConfig() return ( - + Your verification code:
diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx index 68f95c2ae9..fa5e031b26 100644 --- a/apps/sim/components/emails/auth/reset-password-email.tsx +++ b/apps/sim/components/emails/auth/reset-password-email.tsx @@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw const brand = getBrandConfig() return ( - + Hello {username}, A password reset was requested for your {brand.name} account. Click below to set a new diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx index 3300060e1c..ba3e16b9ad 100644 --- a/apps/sim/components/emails/auth/welcome-email.tsx +++ b/apps/sim/components/emails/auth/welcome-email.tsx @@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) { const baseUrl = getBaseUrl() return ( - + {userName ? `Hey ${userName},` : 'Hey,'} diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx index b2c62a0a0e..581f9dbc34 100644 --- a/apps/sim/components/emails/billing/credit-purchase-email.tsx +++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx @@ -23,7 +23,7 @@ export function CreditPurchaseEmail({ const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account` return ( - + {userName ? `Hi ${userName},` : 'Hi,'} diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx index 28afdcb72c..d3f237349a 100644 --- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx +++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx @@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({ const effectiveLoginLink = loginLink || `${baseUrl}/login` return ( - + Hello {userName}, Your Enterprise Plan is now active. You have full access to advanced diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index c18d7bc17c..464221d890 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({ const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits` return ( - + {userName ? `Hi ${userName},` : 'Hi,'} diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index eb982fe391..58d7474103 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -25,7 +25,7 @@ export function PaymentFailedEmail({ const previewText = `${brand.name}: Payment Failed - Action Required` return ( - + {userName ? `Hi ${userName},` : 'Hi,'} diff --git a/apps/sim/components/emails/billing/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx index fe50ea8976..295f4a01cb 100644 --- a/apps/sim/components/emails/billing/plan-welcome-email.tsx +++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx @@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE const previewText = `${brand.name}: Your ${planName} plan is active` return ( - + {userName ? `Hi ${userName},` : 'Hi,'} diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx index fdcb01c326..be31ec0a6e 100644 --- a/apps/sim/components/emails/billing/usage-threshold-email.tsx +++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx @@ -25,7 +25,7 @@ export function UsageThresholdEmail({ const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget` return ( - + {userName ? `Hi ${userName},` : 'Hi,'} diff --git a/apps/sim/components/emails/careers/careers-confirmation-email.tsx b/apps/sim/components/emails/careers/careers-confirmation-email.tsx index beb07e1daf..1cdda08efb 100644 --- a/apps/sim/components/emails/careers/careers-confirmation-email.tsx +++ b/apps/sim/components/emails/careers/careers-confirmation-email.tsx @@ -20,7 +20,10 @@ export function CareersConfirmationEmail({ const baseUrl = getBaseUrl() return ( - + Hello {name}, We've received your application for {position}. Our team reviews every diff --git a/apps/sim/components/emails/careers/careers-submission-email.tsx b/apps/sim/components/emails/careers/careers-submission-email.tsx index 0d12664beb..04f5b03063 100644 --- a/apps/sim/components/emails/careers/careers-submission-email.tsx +++ b/apps/sim/components/emails/careers/careers-submission-email.tsx @@ -40,7 +40,7 @@ export function CareersSubmissionEmail({ submittedDate = new Date(), }: CareersSubmissionEmailProps) { return ( - + Terms of Service - {' '} - •{' '} - - Unsubscribe + {showUnsubscribe && ( + <> + {' '} + •{' '} + + Unsubscribe + + + )}   diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx index 4f6f5395e6..f55249576c 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -11,13 +11,23 @@ interface EmailLayoutProps { children: React.ReactNode /** Optional: hide footer for internal emails */ hideFooter?: boolean + /** + * Whether to show unsubscribe link in footer. + * Set to false for transactional emails where unsubscribe doesn't apply. + */ + showUnsubscribe: boolean } /** * Shared email layout wrapper providing consistent structure. * Includes Html, Head, Body, Container with logo header, and Footer. */ -export function EmailLayout({ preview, children, hideFooter = false }: EmailLayoutProps) { +export function EmailLayout({ + preview, + children, + hideFooter = false, + showUnsubscribe, +}: EmailLayoutProps) { const brand = getBrandConfig() const baseUrl = getBaseUrl() @@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo {/* Footer in gray section */} - {!hideFooter && } + {!hideFooter && } ) diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx index b955af326d..53651044ed 100644 --- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -54,6 +54,7 @@ export function BatchInvitationEmail({ return ( Hello, diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx index fae1ec1c81..285901a32c 100644 --- a/apps/sim/components/emails/invitations/invitation-email.tsx +++ b/apps/sim/components/emails/invitations/invitation-email.tsx @@ -36,7 +36,10 @@ export function InvitationEmail({ } return ( - + Hello, {inviterName} invited you to join {organizationName} on{' '} diff --git a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx index e87436a154..b0f8e239bf 100644 --- a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx @@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({ const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook' return ( - + Hello, {inviterName} from {organizationName} has invited you to diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx index f9c2dca545..fb64cdfe3e 100644 --- a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx @@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({ return ( Hello, diff --git a/apps/sim/components/emails/notifications/workflow-notification-email.tsx b/apps/sim/components/emails/notifications/workflow-notification-email.tsx index 88ad6fba66..860688c66e 100644 --- a/apps/sim/components/emails/notifications/workflow-notification-email.tsx +++ b/apps/sim/components/emails/notifications/workflow-notification-email.tsx @@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({ : 'Your workflow completed successfully.' return ( - + Hello, {message} diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx index 354a50826b..6e2b0726c6 100644 --- a/apps/sim/components/emails/support/help-confirmation-email.tsx +++ b/apps/sim/components/emails/support/help-confirmation-email.tsx @@ -32,7 +32,10 @@ export function HelpConfirmationEmail({ const typeLabel = getTypeLabel(type) return ( - + Hello, We've received your {typeLabel.toLowerCase()} and will get back to you diff --git a/apps/sim/lib/messaging/email/mailer.ts b/apps/sim/lib/messaging/email/mailer.ts index 2940cfd69a..9d3c7b3337 100644 --- a/apps/sim/lib/messaging/email/mailer.ts +++ b/apps/sim/lib/messaging/email/mailer.ts @@ -152,15 +152,20 @@ function addUnsubscribeData( ): UnsubscribeData { const unsubscribeToken = generateUnsubscribeToken(recipientEmail, emailType) const baseUrl = getBaseUrl() - const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodeURIComponent(recipientEmail)}` + const encodedEmail = encodeURIComponent(recipientEmail) + const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${unsubscribeToken}&email=${encodedEmail}` 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), + html: html + ?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken) + .replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail), + text: text + ?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, unsubscribeToken) + .replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail), } } @@ -361,15 +366,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise { }) it.concurrent('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 secret = 'test-secret-key' const { createHash } = require('crypto') const hash = createHash('sha256').update(`${testEmail}:${salt}:${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 + expect(result.emailType).toBe('marketing') }) it.concurrent('should reject malformed tokens', () => { @@ -226,7 +224,6 @@ describe('unsubscribe utilities', () => { it('should update email preferences for existing user', async () => { const userId = 'user-123' - // Mock finding the user mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => { }), }) - // Mock getting existing settings mockDb.select.mockReturnValueOnce({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => { }), }) - // Mock insert with upsert mockDb.insert.mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), @@ -300,7 +295,6 @@ describe('unsubscribe utilities', () => { await updateEmailPreferences(testEmail, { unsubscribeMarketing: true }) - // Verify that the merged preferences are passed expect(mockInsertValues).toHaveBeenCalledWith( expect.objectContaining({ emailPreferences: { diff --git a/apps/sim/lib/messaging/email/unsubscribe.ts b/apps/sim/lib/messaging/email/unsubscribe.ts index 5082202642..30ace1d69a 100644 --- a/apps/sim/lib/messaging/email/unsubscribe.ts +++ b/apps/sim/lib/messaging/email/unsubscribe.ts @@ -38,7 +38,6 @@ export function verifyUnsubscribeToken( 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') @@ -48,7 +47,6 @@ export function verifyUnsubscribeToken( return { valid: hash === expectedHash, emailType: 'marketing' } } - // Handle new tokens (with email type) const [salt, expectedHash, emailType] = parts if (!salt || !expectedHash || !emailType) return { valid: false } @@ -101,7 +99,6 @@ export async function updateEmailPreferences( preferences: EmailPreferences ): Promise { try { - // First, find the user const userResult = await db .select({ id: user.id }) .from(user) @@ -115,7 +112,6 @@ export async function updateEmailPreferences( const userId = userResult[0].id - // Get existing email preferences const existingSettings = await db .select({ emailPreferences: settings.emailPreferences }) .from(settings) @@ -127,13 +123,11 @@ export async function updateEmailPreferences( currentEmailPreferences = (existingSettings[0].emailPreferences as EmailPreferences) || {} } - // Merge email preferences const updatedEmailPreferences = { ...currentEmailPreferences, ...preferences, } - // Upsert settings await db .insert(settings) .values({ @@ -168,10 +162,8 @@ export async function isUnsubscribed( 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