diff --git a/apps/sim/components/emails/notifications/index.ts b/apps/sim/components/emails/notifications/index.ts index c930a723ce9..2a40138acf7 100644 --- a/apps/sim/components/emails/notifications/index.ts +++ b/apps/sim/components/emails/notifications/index.ts @@ -1 +1,6 @@ export { ScheduleDisabledEmail } from './schedule-disabled-email' +export { + type SubprocessorChange, + SubprocessorChangeEmail, + type SubprocessorChangeType, +} from './subprocessor-change-email' diff --git a/apps/sim/components/emails/notifications/subprocessor-change-email.tsx b/apps/sim/components/emails/notifications/subprocessor-change-email.tsx new file mode 100644 index 00000000000..fbb02da0a9e --- /dev/null +++ b/apps/sim/components/emails/notifications/subprocessor-change-email.tsx @@ -0,0 +1,138 @@ +import { Link, Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' +import { getBrandConfig } from '@/ee/whitelabeling' + +/** How a sub-processor's role on the list is changing. */ +export type SubprocessorChangeType = 'added' | 'replaced' | 'removed' + +const CHANGE_TYPE_LABEL: Record = { + added: 'New sub-processor', + replaced: 'Replacement sub-processor', + removed: 'Sub-processor being removed', +} + +/** + * Dates in a notice period have to be unambiguous, so the month is spelled out + * rather than left to the recipient's locale ordering of a numeric date. The + * zone is pinned because the notice window is contractual — the rendered day + * must not shift with the server the send happens to run on. + */ +const NOTICE_DATE_FORMAT: Intl.DateTimeFormatOptions = { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +} + +function formatNoticeDate(date: Date): string { + return date.toLocaleDateString('en-US', NOTICE_DATE_FORMAT) +} + +export interface SubprocessorChange { + /** Legal entity name of the sub-processor. */ + name: string + /** What it is used for, in plain language. */ + purpose: string + /** Categories of customer personal data it will process. */ + dataCategories: string + /** Primary processing location, e.g. `United States`. */ + location: string + changeType: SubprocessorChangeType +} + +interface SubprocessorChangeEmailProps { + recipientName?: string + /** The sub-processors being added, replaced, or removed in this notice. */ + changes: SubprocessorChange[] + /** When the change takes effect. Sent at least 30 days ahead of this date. */ + effectiveDate: Date + /** Last day an objection can be raised. Falls on or before {@link effectiveDate}. */ + objectionDeadline: Date + /** Where an objection is sent. */ + objectionEmail: string + /** The public sub-processor list, which reflects the change once it is live. */ + subprocessorListUrl: string + /** Where the recipient manages whether they receive these notices. */ + subscriptionUrl?: string +} + +/** + * Advance notice to subscribed customers that the sub-processors handling their + * personal data are changing, with the window and address for objecting. + */ +export function SubprocessorChangeEmail({ + recipientName, + changes, + effectiveDate, + objectionDeadline, + objectionEmail, + subprocessorListUrl, + subscriptionUrl, +}: SubprocessorChangeEmailProps) { + const brand = getBrandConfig() + const effectiveDateLabel = formatNoticeDate(effectiveDate) + const previewText = `${brand.name} is changing its sub-processors on ${effectiveDateLabel}` + + return ( + + {recipientName ? `Hi ${recipientName},` : 'Hi,'} + + + We are giving you advance notice of a change to the sub-processors {brand.name} uses to + process customer personal data. The change takes effect on{' '} + {effectiveDateLabel}. + + + {changes.map((change) => ( +
+ + {change.name} — {CHANGE_TYPE_LABEL[change.changeType]} + + + Purpose: {change.purpose} +
+ Data processed: {change.dataCategories} +
+ Processing location: {change.location} +
+ Effective: {effectiveDateLabel} +
+
+ ))} + + + If you object to this change, reply to this email or write to{' '} + + {objectionEmail} + {' '} + by {formatNoticeDate(objectionDeadline)}. We will work with you + on a resolution, and you may terminate the affected service if we cannot reach one. + + + + No action is needed if you have no objection. The full list of sub-processors stays current + at the link below. + + + View sub-processor list + +
+ + + Sent to customers subscribed to sub-processor change notices. + {subscriptionUrl ? ( + <> + {' '} + + Manage whether you receive them + + . + + ) : null} + + + ) +} + +export default SubprocessorChangeEmail diff --git a/apps/sim/components/emails/render-notifications.test.ts b/apps/sim/components/emails/render-notifications.test.ts index b33ef4d8757..fb5286cfc08 100644 --- a/apps/sim/components/emails/render-notifications.test.ts +++ b/apps/sim/components/emails/render-notifications.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { renderScheduleDisabledEmail, + renderSubprocessorChangeEmail, renderUsageLimitReachedEmail, } from '@/components/emails/render' @@ -93,3 +94,63 @@ describe('renderUsageLimitReachedEmail', () => { expect(html).not.toContain('$20') }) }) + +describe('renderSubprocessorChangeEmail', () => { + const notice = { + changes: [ + { + name: 'Example Analytics Inc.', + purpose: 'Product usage analytics', + dataCategories: 'Account identifiers, usage events', + location: 'United States', + changeType: 'added' as const, + }, + ], + effectiveDate: new Date('2026-10-01T00:00:00Z'), + objectionDeadline: new Date('2026-09-24T00:00:00Z'), + objectionEmail: 'privacy@example.com', + subprocessorListUrl: 'https://example.com/subprocessors', + } + + it('renders the change details, the objection window, and the list link', async () => { + const html = await renderSubprocessorChangeEmail({ recipientName: 'John', ...notice }) + + expect(html).toContain('Example Analytics Inc.') + expect(html).toContain('New sub-processor') + expect(html).toContain('Account identifiers, usage events') + expect(html).toContain('October 1, 2026') + expect(html).toContain('September 24, 2026') + expect(html).toContain('mailto:privacy@example.com') + expect(html).toContain('https://example.com/subprocessors') + }) + + it('renders every change in a multi-sub-processor notice', async () => { + const html = await renderSubprocessorChangeEmail({ + ...notice, + changes: [ + ...notice.changes, + { + name: 'Legacy Mail Co.', + purpose: 'Transactional email delivery', + dataCategories: 'Email addresses', + location: 'Ireland', + changeType: 'removed' as const, + }, + ], + }) + + expect(html).toContain('Legacy Mail Co.') + expect(html).toContain('Sub-processor being removed') + }) + + it('mentions the subscription setting only when one is given', async () => { + const withSetting = await renderSubprocessorChangeEmail({ + ...notice, + subscriptionUrl: 'https://example.com/preferences', + }) + const withoutSetting = await renderSubprocessorChangeEmail(notice) + + expect(withSetting).toContain('href="https://example.com/preferences"') + expect(withoutSetting).not.toContain('Manage whether you receive them') + }) +}) diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 83d285fe17f..4d79c01cf10 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -25,7 +25,11 @@ import { WorkspaceAddedEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' -import { ScheduleDisabledEmail } from '@/components/emails/notifications' +import { + ScheduleDisabledEmail, + type SubprocessorChange, + SubprocessorChangeEmail, +} from '@/components/emails/notifications' import { HelpConfirmationEmail } from '@/components/emails/support' import type { UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -159,6 +163,18 @@ export async function renderScheduleDisabledEmail(params: { return await render(ScheduleDisabledEmail(params)) } +export async function renderSubprocessorChangeEmail(params: { + recipientName?: string + changes: SubprocessorChange[] + effectiveDate: Date + objectionDeadline: Date + objectionEmail: string + subprocessorListUrl: string + subscriptionUrl?: string +}): Promise { + return await render(SubprocessorChangeEmail(params)) +} + export async function renderFreeTierUpgradeEmail(params: { userName?: string percentUsed: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index a56b542d606..1d27fc720ec 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -20,6 +20,7 @@ export type EmailSubjectType = | 'abandoned-checkout' | 'free-tier-exhausted' | 'schedule-disabled' + | 'subprocessor-change' | 'onboarding-followup' | 'welcome' @@ -66,6 +67,8 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've run out of free credits on ${brandName}` case 'schedule-disabled': return `A schedule was turned off on ${brandName}` + case 'subprocessor-change': + return `Upcoming change to ${brandName} sub-processors` case 'onboarding-followup': return `Quick question about ${brandName}` case 'welcome':