mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user