Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
406 changes: 157 additions & 249 deletions apps/sim/app/unsubscribe/unsubscribe.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/sim/components/emails/auth/otp-verification-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function OTPVerificationEmail({
const brand = getBrandConfig()

return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)}>
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>

<Section style={baseStyles.codeContainer}>
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/components/emails/auth/reset-password-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw
const brand = getBrandConfig()

return (
<EmailLayout preview={`Reset your ${brand.name} password`}>
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
A password reset was requested for your {brand.name} account. Click below to set a new
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/components/emails/auth/welcome-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) {
const baseUrl = getBaseUrl()

return (
<EmailLayout preview={`Welcome to ${brand.name}`}>
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hey ${userName},` : 'Hey,'}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function CreditPurchaseEmail({
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`

return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export function EnterpriseSubscriptionEmail({
const effectiveLoginLink = loginLink || `${baseUrl}/login`

return (
<EmailLayout preview={`Your Enterprise Plan is now active on ${brand.name}`}>
<EmailLayout
preview={`Your Enterprise Plan is now active on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function FreeTierUpgradeEmail({
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`

return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function PaymentFailedEmail({
const previewText = `${brand.name}: Payment Failed - Action Required`

return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/components/emails/billing/plan-welcome-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
const previewText = `${brand.name}: Your ${planName} plan is active`

return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function UsageThresholdEmail({
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`

return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export function CareersConfirmationEmail({
const baseUrl = getBaseUrl()

return (
<EmailLayout preview={`Your application to ${brand.name} has been received`}>
<EmailLayout
preview={`Your application to ${brand.name} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello {name},</Text>
<Text style={baseStyles.paragraph}>
We've received your application for <strong>{position}</strong>. Our team reviews every
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function CareersSubmissionEmail({
submittedDate = new Date(),
}: CareersSubmissionEmailProps) {
return (
<EmailLayout preview={`New Career Application from ${name}`} hideFooter>
<EmailLayout preview={`New Career Application from ${name}`} hideFooter showUnsubscribe={false}>
<Text
style={{
...baseStyles.paragraph,
Expand Down
46 changes: 27 additions & 19 deletions apps/sim/components/emails/components/email-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,29 @@ import { getBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'

interface UnsubscribeOptions {
unsubscribeToken?: string
email?: string
}

interface EmailFooterProps {
baseUrl?: string
unsubscribe?: UnsubscribeOptions
messageId?: string
/**
* Whether to show unsubscribe link. Defaults to true.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe?: boolean
}

/**
* Email footer component styled to match Stripe's email design.
* Sits in the gray area below the main white card.
*
* For non-transactional emails, the unsubscribe link uses placeholders
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
* by the mailer when sending.
*/
export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: EmailFooterProps) {
export function EmailFooter({
baseUrl = getBaseUrl(),
messageId,
showUnsubscribe = true,
}: EmailFooterProps) {
const brand = getBrandConfig()

const footerLinkStyle = {
Expand Down Expand Up @@ -181,19 +188,20 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
•{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service
</a>{' '}
•{' '}
<a
href={
unsubscribe?.unsubscribeToken && unsubscribe?.email
? `${baseUrl}/unsubscribe?token=${unsubscribe.unsubscribeToken}&email=${encodeURIComponent(unsubscribe.email)}`
: `mailto:${brand.supportEmail}?subject=Unsubscribe%20Request&body=Please%20unsubscribe%20me%20from%20all%20emails.`
}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
{showUnsubscribe && (
<>
{' '}
•{' '}
<a
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</>
)}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
Expand Down
14 changes: 12 additions & 2 deletions apps/sim/components/emails/components/email-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -43,7 +53,7 @@ export function EmailLayout({ preview, children, hideFooter = false }: EmailLayo
</Container>

{/* Footer in gray section */}
{!hideFooter && <EmailFooter baseUrl={baseUrl} />}
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
</Body>
</Html>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function BatchInvitationEmail({
return (
<EmailLayout
preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/components/emails/invitations/invitation-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export function InvitationEmail({
}

return (
<EmailLayout preview={`You've been invited to join ${organizationName} on ${brand.name}`}>
<EmailLayout
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export function PollingGroupInvitationEmail({
const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook'

return (
<EmailLayout preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}>
<EmailLayout
preview={`You've been invited to join ${pollingGroupName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> from <strong>{organizationName}</strong> has invited you to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function WorkspaceInvitationEmail({
return (
<EmailLayout
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function WorkflowNotificationEmail({
: 'Your workflow completed successfully.'

return (
<EmailLayout preview={previewText}>
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>Hello,</Text>
<Text style={baseStyles.paragraph}>{message}</Text>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ export function HelpConfirmationEmail({
const typeLabel = getTypeLabel(type)

return (
<EmailLayout preview={`Your ${typeLabel.toLowerCase()} has been received`}>
<EmailLayout
preview={`Your ${typeLabel.toLowerCase()} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
Expand Down
17 changes: 11 additions & 6 deletions apps/sim/lib/messaging/email/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -361,15 +366,15 @@ async function sendBatchWithResend(emails: EmailOptions[]): Promise<BatchSendEma
subject: email.subject,
}

if (email.html) emailData.html = email.html
if (email.text) emailData.text = email.text

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
} else {
if (email.html) emailData.html = email.html
if (email.text) emailData.text = email.text
}

batchEmails.push(emailData)
Expand Down
8 changes: 1 addition & 7 deletions apps/sim/lib/messaging/email/unsubscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,15 @@ describe('unsubscribe utilities', () => {
})

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', () => {
Expand Down Expand Up @@ -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({
Expand All @@ -235,7 +232,6 @@ describe('unsubscribe utilities', () => {
}),
})

// Mock getting existing settings
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
Expand All @@ -244,7 +240,6 @@ describe('unsubscribe utilities', () => {
}),
})

// Mock insert with upsert
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -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: {
Expand Down
Loading