feat(emails): add sub-processor change notification template (#6820)

* feat(emails): add sub-processor change notification template

* improvement(emails): link the objection address and preference URL
This commit is contained in:
Waleed
2026-08-18 14:31:50 -07:00
committed by GitHub
parent 3a03774e42
commit 56a270ed54
5 changed files with 224 additions and 1 deletions
@@ -1 +1,6 @@
export { ScheduleDisabledEmail } from './schedule-disabled-email'
export {
type SubprocessorChange,
SubprocessorChangeEmail,
type SubprocessorChangeType,
} from './subprocessor-change-email'
@@ -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<SubprocessorChangeType, string> = {
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 (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={baseStyles.greeting}>{recipientName ? `Hi ${recipientName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
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{' '}
<EmailStrong>{effectiveDateLabel}</EmailStrong>.
</Text>
{changes.map((change) => (
<Section key={change.name} style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>
{change.name} — {CHANGE_TYPE_LABEL[change.changeType]}
</Text>
<Text style={baseStyles.infoBoxList}>
Purpose: {change.purpose}
<br />
Data processed: {change.dataCategories}
<br />
Processing location: {change.location}
<br />
Effective: {effectiveDateLabel}
</Text>
</Section>
))}
<Text style={baseStyles.paragraph}>
If you object to this change, reply to this email or write to{' '}
<Link href={`mailto:${objectionEmail}`} style={baseStyles.link}>
{objectionEmail}
</Link>{' '}
by <EmailStrong>{formatNoticeDate(objectionDeadline)}</EmailStrong>. We will work with you
on a resolution, and you may terminate the affected service if we cannot reach one.
</Text>
<Text style={baseStyles.paragraph}>
No action is needed if you have no objection. The full list of sub-processors stays current
at the link below.
</Text>
<EmailButton href={subprocessorListUrl}>View sub-processor list</EmailButton>
<div style={baseStyles.divider} />
<Text style={baseStyles.footnote}>
Sent to customers subscribed to sub-processor change notices.
{subscriptionUrl ? (
<>
{' '}
<Link href={subscriptionUrl} style={baseStyles.footerLink}>
Manage whether you receive them
</Link>
.
</>
) : null}
</Text>
</EmailLayout>
)
}
export default SubprocessorChangeEmail
@@ -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')
})
})
+17 -1
View File
@@ -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<string> {
return await render(SubprocessorChangeEmail(params))
}
export async function renderFreeTierUpgradeEmail(params: {
userName?: string
percentUsed: number
+3
View File
@@ -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':