@aglyn/shared-util-email 1.0.0-beta.234 → 1.0.0-beta.235
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/mail-provider-resend.js +3 -1
- package/src/lib/mail-provider-resend.js.map +1 -1
- package/src/lib/mail-provider-webhook.d.ts +3 -0
- package/src/lib/mail-provider-webhook.js +6 -1
- package/src/lib/mail-provider-webhook.js.map +1 -1
- package/src/lib/mail-provider.d.ts +2 -0
- package/src/lib/mail-provider.js.map +1 -1
- package/src/lib/retention-emails.d.ts +104 -0
- package/src/lib/retention-emails.js +375 -0
- package/src/lib/retention-emails.js.map +1 -0
- package/src/lib/send-email.d.ts +8 -0
- package/src/lib/send-email.js +21 -3
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/system-email-catalog.js +11 -1
- package/src/lib/system-email-catalog.js.map +1 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* THE GETTING-STARTED EMAILS (AGL-3692).
|
|
18
|
+
*
|
|
19
|
+
* Until these, the platform said one thing to a new account, the welcome
|
|
20
|
+
* email, and then nothing, whether the person built a site, stalled on
|
|
21
|
+
* verification or stopped coming back. Each email here is sent once per
|
|
22
|
+
* crossing of an activation stage by the hourly retention sweep, and is in
|
|
23
|
+
* the system-email catalog so staff can also send it by hand from an
|
|
24
|
+
* account's staff page (AGL-3691).
|
|
25
|
+
*
|
|
26
|
+
* ## Which preference governs them
|
|
27
|
+
*
|
|
28
|
+
* The verification reminder is ACCOUNT mail: it finishes something the
|
|
29
|
+
* person started, so it follows only the suppression list, like the
|
|
30
|
+
* verification email itself.
|
|
31
|
+
*
|
|
32
|
+
* Every other email here is a product tip and belongs to the account's
|
|
33
|
+
* PRODUCT EMAIL answer (`marketingConsent` on `users/{uid}`, the switch under
|
|
34
|
+
* Manage account → Emails). A person who said No there, or left the product
|
|
35
|
+
* updates list from a marketing email (mirrored onto the same answer,
|
|
36
|
+
* AGL-3305), is never sent one. Each carries a link to that switch in its
|
|
37
|
+
* body and in `List-Unsubscribe`.
|
|
38
|
+
*
|
|
39
|
+
* ## Copy rules
|
|
40
|
+
*
|
|
41
|
+
* The heading states the topic, so a reader who skipped the subject still
|
|
42
|
+
* knows what the email is about (AGL-3432). Customer copy says "pages",
|
|
43
|
+
* never "screens". No prices. The product is named by token, so a renamed
|
|
44
|
+
* deployment reads as itself.
|
|
45
|
+
*
|
|
46
|
+
* ## Same design as every other system email
|
|
47
|
+
*
|
|
48
|
+
* Each is a catalog entry like `welcome` or `email-verification`, so it
|
|
49
|
+
* renders through `renderSystemEmail`: the staff design when one is published
|
|
50
|
+
* under Staff → Emails, else these blocks, inside the same header, footer,
|
|
51
|
+
* palette and button. Like the rest, an email carries ONE button; anything
|
|
52
|
+
* else it points at is a caption line with a bare link, the house shape of
|
|
53
|
+
* the opt-out caption `notification` and the digests carry.
|
|
54
|
+
*
|
|
55
|
+
* ## Docs links
|
|
56
|
+
*
|
|
57
|
+
* Each email points at the docs page that walks through its one task. The
|
|
58
|
+
* paths live in {@link RETENTION_DOCS_PATHS}, the merge values are built from
|
|
59
|
+
* the deployment's docs origin by {@link retentionDocsMergeValues} at send
|
|
60
|
+
* time, so the copy names a token and never a host, and a spec holds every
|
|
61
|
+
* path to a real page under `apps/docs`.
|
|
62
|
+
*/ export const RETENTION_VERIFY_REMINDER_EMAIL = 'retention-verify-reminder';
|
|
63
|
+
export const RETENTION_BUILD_SITE_EMAIL = 'retention-build-site';
|
|
64
|
+
export const RETENTION_PUBLISH_REMINDER_EMAIL = 'retention-publish-reminder';
|
|
65
|
+
export const RETENTION_IDLE_EMAIL = 'retention-idle';
|
|
66
|
+
export const RETENTION_NEXT_STEPS_EMAIL = 'retention-next-steps';
|
|
67
|
+
/**
|
|
68
|
+
* The docs pages the getting-started emails link to, by merge token: a path
|
|
69
|
+
* on the docs site, with a heading anchor where the page covers more than
|
|
70
|
+
* the one task. Each is a page under `apps/docs/docs` (the docs site serves
|
|
71
|
+
* it at its root), which `retention-emails.spec.ts` checks.
|
|
72
|
+
*/ export const RETENTION_DOCS_PATHS = {
|
|
73
|
+
'docs.verifyEmailUrl': '/workspace-and-billing/signing-in-and-sessions#verifying-your-email',
|
|
74
|
+
'docs.generateSiteUrl': '/ai/generate-a-site',
|
|
75
|
+
'docs.publishUrl': '/getting-started/publish-your-first-screen',
|
|
76
|
+
'docs.connectDomainUrl': '/building-sites/custom-domains/connect-a-domain',
|
|
77
|
+
'docs.formsUrl': '/content-and-data/forms/overview',
|
|
78
|
+
'docs.generateSectionUrl': '/ai/generate-section',
|
|
79
|
+
'docs.copyAssistUrl': '/ai/copy-assist'
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Every docs link as a merge value, on the given docs origin. The console
|
|
83
|
+
* passes its own docs origin (`DOCS_BASE_URL`), so a self-hosted deployment
|
|
84
|
+
* links its own docs build.
|
|
85
|
+
*/ export function retentionDocsMergeValues(docsOrigin) {
|
|
86
|
+
const origin = docsOrigin.trim().replace(/\/+$/, '');
|
|
87
|
+
return Object.fromEntries(Object.entries(RETENTION_DOCS_PATHS).map(([token, path])=>[
|
|
88
|
+
token,
|
|
89
|
+
`${origin}${path}`
|
|
90
|
+
]));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The retention emails that are product tips, governed by the account's
|
|
94
|
+
* product email answer. The verification reminder is not one of them.
|
|
95
|
+
*/ export const PRODUCT_TIP_RETENTION_EMAILS = new Set([
|
|
96
|
+
RETENTION_BUILD_SITE_EMAIL,
|
|
97
|
+
RETENTION_PUBLISH_REMINDER_EMAIL,
|
|
98
|
+
RETENTION_IDLE_EMAIL,
|
|
99
|
+
RETENTION_NEXT_STEPS_EMAIL
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* The getting-started entries, built on the catalog's sample console origin.
|
|
103
|
+
*
|
|
104
|
+
* A function rather than a constant so that origin is the catalog's own
|
|
105
|
+
* `SAMPLE_CONSOLE_ORIGIN`, one reading of `NEXT_PUBLIC_CONSOLE_URL` for every
|
|
106
|
+
* preview sample, rather than a second spelled-out default here.
|
|
107
|
+
*/ export function retentionSystemEmailTemplates(SAMPLE_CONSOLE_ORIGIN, SAMPLE_DOCS_ORIGIN) {
|
|
108
|
+
const docsSamples = retentionDocsMergeValues(SAMPLE_DOCS_ORIGIN);
|
|
109
|
+
const DOCS_TOKEN = (name, description)=>({
|
|
110
|
+
name,
|
|
111
|
+
description,
|
|
112
|
+
sample: docsSamples[name]
|
|
113
|
+
});
|
|
114
|
+
const NAME_TOKEN = {
|
|
115
|
+
name: 'name',
|
|
116
|
+
description: "The account holder's first name, or “there”",
|
|
117
|
+
sample: 'Alex'
|
|
118
|
+
};
|
|
119
|
+
const SITE_NAME_TOKEN = {
|
|
120
|
+
name: 'site.name',
|
|
121
|
+
description: 'The name of their site',
|
|
122
|
+
sample: 'Test Site'
|
|
123
|
+
};
|
|
124
|
+
const CTA_TOKEN = {
|
|
125
|
+
name: 'ctaUrl',
|
|
126
|
+
description: 'Where the button opens in the console',
|
|
127
|
+
sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/hosts/test-site`
|
|
128
|
+
};
|
|
129
|
+
const PREFERENCES_TOKEN = {
|
|
130
|
+
name: 'preferencesUrl',
|
|
131
|
+
description: 'The product email switch in account settings',
|
|
132
|
+
sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user/emails`
|
|
133
|
+
};
|
|
134
|
+
// The house opt-out line, as `notification` and the digests word it.
|
|
135
|
+
const PREFERENCES_CAPTION = {
|
|
136
|
+
block: 'text',
|
|
137
|
+
text: 'Change what you are emailed about: {{preferencesUrl}}',
|
|
138
|
+
variant: 'caption'
|
|
139
|
+
};
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
key: RETENTION_VERIFY_REMINDER_EMAIL,
|
|
143
|
+
name: 'Getting started: confirm your email',
|
|
144
|
+
description: 'Sent about an hour, and again a day, after sign-up to an account ' + 'that has not confirmed its email address. Carries a fresh link.',
|
|
145
|
+
deliveredBy: 'resend',
|
|
146
|
+
defaultSubject: 'Confirm your email to open your {{brand.productName}} workspace',
|
|
147
|
+
mergeTokens: [
|
|
148
|
+
NAME_TOKEN,
|
|
149
|
+
{
|
|
150
|
+
name: 'verifyUrl',
|
|
151
|
+
description: 'One-time link that confirms the address',
|
|
152
|
+
sample: `${SAMPLE_CONSOLE_ORIGIN}/verify-email?oobCode=…`
|
|
153
|
+
},
|
|
154
|
+
DOCS_TOKEN('docs.verifyEmailUrl', 'Docs: verifying your email, under Signing in & sessions')
|
|
155
|
+
],
|
|
156
|
+
defaultBody: [
|
|
157
|
+
{
|
|
158
|
+
block: 'text',
|
|
159
|
+
text: 'Confirm your email to finish signing up',
|
|
160
|
+
variant: 'heading'
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
block: 'text',
|
|
164
|
+
text: 'Hi {{name}}, you started a {{brand.productName}} account but your ' + 'email address is not confirmed yet. Confirm it and your ' + 'workspace opens, ready for your website.',
|
|
165
|
+
variant: 'body'
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
block: 'button',
|
|
169
|
+
label: 'Confirm my email',
|
|
170
|
+
href: '{{verifyUrl}}'
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
block: 'text',
|
|
174
|
+
text: 'The link works once and expires soon. If you did not sign up, ' + 'ignore this email and nothing happens.',
|
|
175
|
+
variant: 'caption'
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
block: 'text',
|
|
179
|
+
text: 'Link expired or not arriving? See verifying your email: {{docs.verifyEmailUrl}}',
|
|
180
|
+
variant: 'caption'
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
footerReason: 'You’re receiving this because this address was used to sign up ' + 'for {{brand.productName}} and has not been confirmed.',
|
|
184
|
+
source: 'apps/console/app/api/admin/retention-emails/route.ts'
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
key: RETENTION_BUILD_SITE_EMAIL,
|
|
188
|
+
name: 'Getting started: build your site',
|
|
189
|
+
description: 'Sent a day, and again three days, after sign-up to a confirmed ' + 'account with no page of its own yet: no workspace, no site, or ' + 'only the starter pages.',
|
|
190
|
+
deliveredBy: 'resend',
|
|
191
|
+
defaultSubject: 'Your website is a few minutes away',
|
|
192
|
+
mergeTokens: [
|
|
193
|
+
NAME_TOKEN,
|
|
194
|
+
CTA_TOKEN,
|
|
195
|
+
DOCS_TOKEN('docs.generateSiteUrl', 'Docs: generate a website from a prompt'),
|
|
196
|
+
PREFERENCES_TOKEN
|
|
197
|
+
],
|
|
198
|
+
defaultBody: [
|
|
199
|
+
{
|
|
200
|
+
block: 'text',
|
|
201
|
+
text: 'Build your website with {{brand.productName}} AI',
|
|
202
|
+
variant: 'heading'
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
block: 'text',
|
|
206
|
+
text: 'Hi {{name}}, your account is ready, but your site has no pages ' + 'of your own yet. Describe your business in a sentence and ' + '{{brand.productName}} AI builds a full website for you, with ' + 'pages, photos and copy you can edit. Your first AI site is ' + 'included on the Free plan, and you can start right now.',
|
|
207
|
+
variant: 'body'
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
block: 'text',
|
|
211
|
+
text: 'Prefer to start by hand? Add a page from your dashboard and pick a layout.',
|
|
212
|
+
variant: 'body'
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
block: 'button',
|
|
216
|
+
label: 'Build my site',
|
|
217
|
+
href: '{{ctaUrl}}'
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
block: 'text',
|
|
221
|
+
text: 'How a site is built from your description, step by step: {{docs.generateSiteUrl}}',
|
|
222
|
+
variant: 'caption'
|
|
223
|
+
},
|
|
224
|
+
PREFERENCES_CAPTION
|
|
225
|
+
],
|
|
226
|
+
footerReason: 'You’re receiving this because you signed up for ' + '{{brand.productName}} and have not added a page yet.',
|
|
227
|
+
source: 'apps/console/app/api/admin/retention-emails/route.ts'
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
key: RETENTION_PUBLISH_REMINDER_EMAIL,
|
|
231
|
+
name: 'Getting started: publish your changes',
|
|
232
|
+
description: 'Sent once to an account that edited pages a day or more ago and ' + 'has published none of its own work.',
|
|
233
|
+
deliveredBy: 'resend',
|
|
234
|
+
defaultSubject: 'Your changes on {{site.name}} are not live yet',
|
|
235
|
+
mergeTokens: [
|
|
236
|
+
NAME_TOKEN,
|
|
237
|
+
SITE_NAME_TOKEN,
|
|
238
|
+
CTA_TOKEN,
|
|
239
|
+
DOCS_TOKEN('docs.publishUrl', 'Docs: publish your first page'),
|
|
240
|
+
PREFERENCES_TOKEN
|
|
241
|
+
],
|
|
242
|
+
defaultBody: [
|
|
243
|
+
{
|
|
244
|
+
block: 'text',
|
|
245
|
+
text: 'Your edits are saved, not published',
|
|
246
|
+
variant: 'heading'
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
block: 'text',
|
|
250
|
+
text: 'Hi {{name}}, you edited pages on {{site.name}}, and those ' + 'changes are saved. Visitors will not see them until you ' + 'publish.',
|
|
251
|
+
variant: 'body'
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
block: 'button',
|
|
255
|
+
label: 'Review and publish',
|
|
256
|
+
href: '{{ctaUrl}}'
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
block: 'text',
|
|
260
|
+
text: 'How previewing and publishing a page works: {{docs.publishUrl}}',
|
|
261
|
+
variant: 'caption'
|
|
262
|
+
},
|
|
263
|
+
PREFERENCES_CAPTION
|
|
264
|
+
],
|
|
265
|
+
footerReason: 'You’re receiving this because you have unpublished changes on a ' + '{{brand.productName}} site.',
|
|
266
|
+
source: 'apps/console/app/api/admin/retention-emails/route.ts'
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
key: RETENTION_IDLE_EMAIL,
|
|
270
|
+
name: 'Getting started: pick up where you left off',
|
|
271
|
+
description: 'Sent 7 days, and again 14 days, after the last sign-in of an ' + 'account that has worked on its site. Once per quiet spell; coming ' + 'back resets it.',
|
|
272
|
+
deliveredBy: 'resend',
|
|
273
|
+
defaultSubject: 'Pick up where you left off on {{site.name}}',
|
|
274
|
+
mergeTokens: [
|
|
275
|
+
NAME_TOKEN,
|
|
276
|
+
SITE_NAME_TOKEN,
|
|
277
|
+
CTA_TOKEN,
|
|
278
|
+
DOCS_TOKEN('docs.generateSectionUrl', 'Docs: generate a section on the canvas'),
|
|
279
|
+
DOCS_TOKEN('docs.copyAssistUrl', 'Docs: rewrite and write copy with AI'),
|
|
280
|
+
PREFERENCES_TOKEN
|
|
281
|
+
],
|
|
282
|
+
defaultBody: [
|
|
283
|
+
{
|
|
284
|
+
block: 'text',
|
|
285
|
+
text: 'Pick up where you left off on {{site.name}}',
|
|
286
|
+
variant: 'heading'
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
block: 'text',
|
|
290
|
+
text: 'Hi {{name}}, it has been a little while since you worked on ' + '{{site.name}}. Your pages are where you left them. A new ' + 'section, a fresh photo or an updated page keeps the site ' + 'current for the people who find it.',
|
|
291
|
+
variant: 'body'
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
block: 'text',
|
|
295
|
+
text: 'Short on time? {{brand.productName}} AI can build a new section ' + 'or rewrite the words on a page from one sentence.',
|
|
296
|
+
variant: 'body'
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
block: 'button',
|
|
300
|
+
label: 'Open my site',
|
|
301
|
+
href: '{{ctaUrl}}'
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
block: 'text',
|
|
305
|
+
text: 'Build a section with AI: {{docs.generateSectionUrl}}\n' + 'Rewrite your copy with AI: {{docs.copyAssistUrl}}',
|
|
306
|
+
variant: 'caption'
|
|
307
|
+
},
|
|
308
|
+
PREFERENCES_CAPTION
|
|
309
|
+
],
|
|
310
|
+
footerReason: 'You’re receiving this because you have a site on ' + '{{brand.productName}} and have not signed in for a while.',
|
|
311
|
+
source: 'apps/console/app/api/admin/retention-emails/route.ts'
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
key: RETENTION_NEXT_STEPS_EMAIL,
|
|
315
|
+
name: 'Getting started: your site is live',
|
|
316
|
+
description: 'Sent once, after an account first publishes pages of its own: ' + 'what to do next, a custom domain and a contact form.',
|
|
317
|
+
deliveredBy: 'resend',
|
|
318
|
+
defaultSubject: '{{site.name}} is live: what to do next',
|
|
319
|
+
mergeTokens: [
|
|
320
|
+
NAME_TOKEN,
|
|
321
|
+
SITE_NAME_TOKEN,
|
|
322
|
+
{
|
|
323
|
+
name: 'siteUrl',
|
|
324
|
+
description: 'The published site',
|
|
325
|
+
sample: 'https://www.example.com'
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: 'domainUrl',
|
|
329
|
+
description: 'The site’s domain settings in the console',
|
|
330
|
+
sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/hosts/test-site/admin/domain`
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: 'formsUrl',
|
|
334
|
+
description: 'The site’s forms in the console',
|
|
335
|
+
sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/hosts/test-site/forms`
|
|
336
|
+
},
|
|
337
|
+
DOCS_TOKEN('docs.connectDomainUrl', 'Docs: connect a domain'),
|
|
338
|
+
DOCS_TOKEN('docs.formsUrl', 'Docs: forms and lead capture'),
|
|
339
|
+
PREFERENCES_TOKEN
|
|
340
|
+
],
|
|
341
|
+
defaultBody: [
|
|
342
|
+
{
|
|
343
|
+
block: 'text',
|
|
344
|
+
text: '{{site.name}} is live',
|
|
345
|
+
variant: 'heading'
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
block: 'text',
|
|
349
|
+
text: 'Hi {{name}}, your pages are published and anyone with the link ' + 'can see them: {{siteUrl}}',
|
|
350
|
+
variant: 'body'
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
block: 'text',
|
|
354
|
+
text: 'Three things that help people find and reach you:\n' + '1. Connect your own domain, so the address is yours.\n' + '2. Add a contact form, so visitors can reach you from any ' + 'page: {{formsUrl}}\n' + '3. Share the link where your customers already are.',
|
|
355
|
+
variant: 'body'
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
block: 'button',
|
|
359
|
+
label: 'Connect a domain',
|
|
360
|
+
href: '{{domainUrl}}'
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
block: 'text',
|
|
364
|
+
text: 'Connect a domain, step by step: {{docs.connectDomainUrl}}\n' + 'Add a form and read its submissions: {{docs.formsUrl}}',
|
|
365
|
+
variant: 'caption'
|
|
366
|
+
},
|
|
367
|
+
PREFERENCES_CAPTION
|
|
368
|
+
],
|
|
369
|
+
footerReason: 'You’re receiving this because you published a site on ' + '{{brand.productName}}.',
|
|
370
|
+
source: 'apps/console/app/api/admin/retention-emails/route.ts'
|
|
371
|
+
}
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
//# sourceMappingURL=retention-emails.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/retention-emails.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE GETTING-STARTED EMAILS (AGL-3692).\n *\n * Until these, the platform said one thing to a new account, the welcome\n * email, and then nothing, whether the person built a site, stalled on\n * verification or stopped coming back. Each email here is sent once per\n * crossing of an activation stage by the hourly retention sweep, and is in\n * the system-email catalog so staff can also send it by hand from an\n * account's staff page (AGL-3691).\n *\n * ## Which preference governs them\n *\n * The verification reminder is ACCOUNT mail: it finishes something the\n * person started, so it follows only the suppression list, like the\n * verification email itself.\n *\n * Every other email here is a product tip and belongs to the account's\n * PRODUCT EMAIL answer (`marketingConsent` on `users/{uid}`, the switch under\n * Manage account → Emails). A person who said No there, or left the product\n * updates list from a marketing email (mirrored onto the same answer,\n * AGL-3305), is never sent one. Each carries a link to that switch in its\n * body and in `List-Unsubscribe`.\n *\n * ## Copy rules\n *\n * The heading states the topic, so a reader who skipped the subject still\n * knows what the email is about (AGL-3432). Customer copy says \"pages\",\n * never \"screens\". No prices. The product is named by token, so a renamed\n * deployment reads as itself.\n *\n * ## Same design as every other system email\n *\n * Each is a catalog entry like `welcome` or `email-verification`, so it\n * renders through `renderSystemEmail`: the staff design when one is published\n * under Staff → Emails, else these blocks, inside the same header, footer,\n * palette and button. Like the rest, an email carries ONE button; anything\n * else it points at is a caption line with a bare link, the house shape of\n * the opt-out caption `notification` and the digests carry.\n *\n * ## Docs links\n *\n * Each email points at the docs page that walks through its one task. The\n * paths live in {@link RETENTION_DOCS_PATHS}, the merge values are built from\n * the deployment's docs origin by {@link retentionDocsMergeValues} at send\n * time, so the copy names a token and never a host, and a spec holds every\n * path to a real page under `apps/docs`.\n */\n\nimport type {\n SystemEmailDefaultBlock,\n SystemEmailMergeToken,\n SystemEmailTemplateDefinition,\n} from './system-email-catalog'\n\nexport const RETENTION_VERIFY_REMINDER_EMAIL = 'retention-verify-reminder'\nexport const RETENTION_BUILD_SITE_EMAIL = 'retention-build-site'\nexport const RETENTION_PUBLISH_REMINDER_EMAIL = 'retention-publish-reminder'\nexport const RETENTION_IDLE_EMAIL = 'retention-idle'\nexport const RETENTION_NEXT_STEPS_EMAIL = 'retention-next-steps'\n\n/**\n * The docs pages the getting-started emails link to, by merge token: a path\n * on the docs site, with a heading anchor where the page covers more than\n * the one task. Each is a page under `apps/docs/docs` (the docs site serves\n * it at its root), which `retention-emails.spec.ts` checks.\n */\nexport const RETENTION_DOCS_PATHS = {\n 'docs.verifyEmailUrl':\n '/workspace-and-billing/signing-in-and-sessions#verifying-your-email',\n 'docs.generateSiteUrl': '/ai/generate-a-site',\n 'docs.publishUrl': '/getting-started/publish-your-first-screen',\n 'docs.connectDomainUrl': '/building-sites/custom-domains/connect-a-domain',\n 'docs.formsUrl': '/content-and-data/forms/overview',\n 'docs.generateSectionUrl': '/ai/generate-section',\n 'docs.copyAssistUrl': '/ai/copy-assist',\n} as const\n\nexport type RetentionDocsToken = keyof typeof RETENTION_DOCS_PATHS\n\n/**\n * Every docs link as a merge value, on the given docs origin. The console\n * passes its own docs origin (`DOCS_BASE_URL`), so a self-hosted deployment\n * links its own docs build.\n */\nexport function retentionDocsMergeValues(\n docsOrigin: string,\n): Record<RetentionDocsToken, string> {\n const origin = docsOrigin.trim().replace(/\\/+$/, '')\n return Object.fromEntries(\n Object.entries(RETENTION_DOCS_PATHS).map(([token, path]) => [\n token,\n `${origin}${path}`,\n ]),\n ) as Record<RetentionDocsToken, string>\n}\n\n/**\n * The retention emails that are product tips, governed by the account's\n * product email answer. The verification reminder is not one of them.\n */\nexport const PRODUCT_TIP_RETENTION_EMAILS: ReadonlySet<string> = new Set([\n RETENTION_BUILD_SITE_EMAIL,\n RETENTION_PUBLISH_REMINDER_EMAIL,\n RETENTION_IDLE_EMAIL,\n RETENTION_NEXT_STEPS_EMAIL,\n])\n\n/**\n * The getting-started entries, built on the catalog's sample console origin.\n *\n * A function rather than a constant so that origin is the catalog's own\n * `SAMPLE_CONSOLE_ORIGIN`, one reading of `NEXT_PUBLIC_CONSOLE_URL` for every\n * preview sample, rather than a second spelled-out default here.\n */\nexport function retentionSystemEmailTemplates(\n SAMPLE_CONSOLE_ORIGIN: string,\n SAMPLE_DOCS_ORIGIN: string,\n): readonly SystemEmailTemplateDefinition[] {\n const docsSamples = retentionDocsMergeValues(SAMPLE_DOCS_ORIGIN)\n const DOCS_TOKEN = (\n name: RetentionDocsToken,\n description: string,\n ): SystemEmailMergeToken => ({ name, description, sample: docsSamples[name] })\n\n const NAME_TOKEN: SystemEmailMergeToken = {\n name: 'name',\n description: \"The account holder's first name, or “there”\",\n sample: 'Alex',\n }\n\n const SITE_NAME_TOKEN: SystemEmailMergeToken = {\n name: 'site.name',\n description: 'The name of their site',\n sample: 'Test Site',\n }\n\n const CTA_TOKEN: SystemEmailMergeToken = {\n name: 'ctaUrl',\n description: 'Where the button opens in the console',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/hosts/test-site`,\n }\n\n const PREFERENCES_TOKEN: SystemEmailMergeToken = {\n name: 'preferencesUrl',\n description: 'The product email switch in account settings',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user/emails`,\n }\n\n // The house opt-out line, as `notification` and the digests word it.\n const PREFERENCES_CAPTION: SystemEmailDefaultBlock = {\n block: 'text',\n text: 'Change what you are emailed about: {{preferencesUrl}}',\n variant: 'caption',\n }\n\n return [\n {\n key: RETENTION_VERIFY_REMINDER_EMAIL,\n name: 'Getting started: confirm your email',\n description:\n 'Sent about an hour, and again a day, after sign-up to an account ' +\n 'that has not confirmed its email address. Carries a fresh link.',\n deliveredBy: 'resend',\n defaultSubject:\n 'Confirm your email to open your {{brand.productName}} workspace',\n mergeTokens: [\n NAME_TOKEN,\n {\n name: 'verifyUrl',\n description: 'One-time link that confirms the address',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/verify-email?oobCode=…`,\n },\n DOCS_TOKEN(\n 'docs.verifyEmailUrl',\n 'Docs: verifying your email, under Signing in & sessions',\n ),\n ],\n defaultBody: [\n {\n block: 'text',\n text: 'Confirm your email to finish signing up',\n variant: 'heading',\n },\n {\n block: 'text',\n text:\n 'Hi {{name}}, you started a {{brand.productName}} account but your ' +\n 'email address is not confirmed yet. Confirm it and your ' +\n 'workspace opens, ready for your website.',\n variant: 'body',\n },\n { block: 'button', label: 'Confirm my email', href: '{{verifyUrl}}' },\n {\n block: 'text',\n text:\n 'The link works once and expires soon. If you did not sign up, ' +\n 'ignore this email and nothing happens.',\n variant: 'caption',\n },\n {\n block: 'text',\n text: 'Link expired or not arriving? See verifying your email: {{docs.verifyEmailUrl}}',\n variant: 'caption',\n },\n ],\n footerReason:\n 'You’re receiving this because this address was used to sign up ' +\n 'for {{brand.productName}} and has not been confirmed.',\n source: 'apps/console/app/api/admin/retention-emails/route.ts',\n },\n {\n key: RETENTION_BUILD_SITE_EMAIL,\n name: 'Getting started: build your site',\n description:\n 'Sent a day, and again three days, after sign-up to a confirmed ' +\n 'account with no page of its own yet: no workspace, no site, or ' +\n 'only the starter pages.',\n deliveredBy: 'resend',\n defaultSubject: 'Your website is a few minutes away',\n mergeTokens: [\n NAME_TOKEN,\n CTA_TOKEN,\n DOCS_TOKEN('docs.generateSiteUrl', 'Docs: generate a website from a prompt'),\n PREFERENCES_TOKEN,\n ],\n defaultBody: [\n {\n block: 'text',\n text: 'Build your website with {{brand.productName}} AI',\n variant: 'heading',\n },\n {\n block: 'text',\n text:\n 'Hi {{name}}, your account is ready, but your site has no pages ' +\n 'of your own yet. Describe your business in a sentence and ' +\n '{{brand.productName}} AI builds a full website for you, with ' +\n 'pages, photos and copy you can edit. Your first AI site is ' +\n 'included on the Free plan, and you can start right now.',\n variant: 'body',\n },\n {\n block: 'text',\n text: 'Prefer to start by hand? Add a page from your dashboard and pick a layout.',\n variant: 'body',\n },\n { block: 'button', label: 'Build my site', href: '{{ctaUrl}}' },\n {\n block: 'text',\n text: 'How a site is built from your description, step by step: {{docs.generateSiteUrl}}',\n variant: 'caption',\n },\n PREFERENCES_CAPTION,\n ],\n footerReason:\n 'You’re receiving this because you signed up for ' +\n '{{brand.productName}} and have not added a page yet.',\n source: 'apps/console/app/api/admin/retention-emails/route.ts',\n },\n {\n key: RETENTION_PUBLISH_REMINDER_EMAIL,\n name: 'Getting started: publish your changes',\n description:\n 'Sent once to an account that edited pages a day or more ago and ' +\n 'has published none of its own work.',\n deliveredBy: 'resend',\n defaultSubject: 'Your changes on {{site.name}} are not live yet',\n mergeTokens: [\n NAME_TOKEN,\n SITE_NAME_TOKEN,\n CTA_TOKEN,\n DOCS_TOKEN('docs.publishUrl', 'Docs: publish your first page'),\n PREFERENCES_TOKEN,\n ],\n defaultBody: [\n {\n block: 'text',\n text: 'Your edits are saved, not published',\n variant: 'heading',\n },\n {\n block: 'text',\n text:\n 'Hi {{name}}, you edited pages on {{site.name}}, and those ' +\n 'changes are saved. Visitors will not see them until you ' +\n 'publish.',\n variant: 'body',\n },\n { block: 'button', label: 'Review and publish', href: '{{ctaUrl}}' },\n {\n block: 'text',\n text: 'How previewing and publishing a page works: {{docs.publishUrl}}',\n variant: 'caption',\n },\n PREFERENCES_CAPTION,\n ],\n footerReason:\n 'You’re receiving this because you have unpublished changes on a ' +\n '{{brand.productName}} site.',\n source: 'apps/console/app/api/admin/retention-emails/route.ts',\n },\n {\n key: RETENTION_IDLE_EMAIL,\n name: 'Getting started: pick up where you left off',\n description:\n 'Sent 7 days, and again 14 days, after the last sign-in of an ' +\n 'account that has worked on its site. Once per quiet spell; coming ' +\n 'back resets it.',\n deliveredBy: 'resend',\n defaultSubject: 'Pick up where you left off on {{site.name}}',\n mergeTokens: [\n NAME_TOKEN,\n SITE_NAME_TOKEN,\n CTA_TOKEN,\n DOCS_TOKEN('docs.generateSectionUrl', 'Docs: generate a section on the canvas'),\n DOCS_TOKEN('docs.copyAssistUrl', 'Docs: rewrite and write copy with AI'),\n PREFERENCES_TOKEN,\n ],\n defaultBody: [\n {\n block: 'text',\n text: 'Pick up where you left off on {{site.name}}',\n variant: 'heading',\n },\n {\n block: 'text',\n text:\n 'Hi {{name}}, it has been a little while since you worked on ' +\n '{{site.name}}. Your pages are where you left them. A new ' +\n 'section, a fresh photo or an updated page keeps the site ' +\n 'current for the people who find it.',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'Short on time? {{brand.productName}} AI can build a new section ' +\n 'or rewrite the words on a page from one sentence.',\n variant: 'body',\n },\n { block: 'button', label: 'Open my site', href: '{{ctaUrl}}' },\n {\n block: 'text',\n text:\n 'Build a section with AI: {{docs.generateSectionUrl}}\\n' +\n 'Rewrite your copy with AI: {{docs.copyAssistUrl}}',\n variant: 'caption',\n },\n PREFERENCES_CAPTION,\n ],\n footerReason:\n 'You’re receiving this because you have a site on ' +\n '{{brand.productName}} and have not signed in for a while.',\n source: 'apps/console/app/api/admin/retention-emails/route.ts',\n },\n {\n key: RETENTION_NEXT_STEPS_EMAIL,\n name: 'Getting started: your site is live',\n description:\n 'Sent once, after an account first publishes pages of its own: ' +\n 'what to do next, a custom domain and a contact form.',\n deliveredBy: 'resend',\n defaultSubject: '{{site.name}} is live: what to do next',\n mergeTokens: [\n NAME_TOKEN,\n SITE_NAME_TOKEN,\n {\n name: 'siteUrl',\n description: 'The published site',\n sample: 'https://www.example.com',\n },\n {\n name: 'domainUrl',\n description: 'The site’s domain settings in the console',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/hosts/test-site/admin/domain`,\n },\n {\n name: 'formsUrl',\n description: 'The site’s forms in the console',\n sample: `${SAMPLE_CONSOLE_ORIGIN}/test-org/hosts/test-site/forms`,\n },\n DOCS_TOKEN('docs.connectDomainUrl', 'Docs: connect a domain'),\n DOCS_TOKEN('docs.formsUrl', 'Docs: forms and lead capture'),\n PREFERENCES_TOKEN,\n ],\n defaultBody: [\n { block: 'text', text: '{{site.name}} is live', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Hi {{name}}, your pages are published and anyone with the link ' +\n 'can see them: {{siteUrl}}',\n variant: 'body',\n },\n {\n block: 'text',\n text:\n 'Three things that help people find and reach you:\\n' +\n '1. Connect your own domain, so the address is yours.\\n' +\n '2. Add a contact form, so visitors can reach you from any ' +\n 'page: {{formsUrl}}\\n' +\n '3. Share the link where your customers already are.',\n variant: 'body',\n },\n { block: 'button', label: 'Connect a domain', href: '{{domainUrl}}' },\n {\n block: 'text',\n text:\n 'Connect a domain, step by step: {{docs.connectDomainUrl}}\\n' +\n 'Add a form and read its submissions: {{docs.formsUrl}}',\n variant: 'caption',\n },\n PREFERENCES_CAPTION,\n ],\n footerReason:\n 'You’re receiving this because you published a site on ' +\n '{{brand.productName}}.',\n source: 'apps/console/app/api/admin/retention-emails/route.ts',\n },\n ]\n}\n"],"names":["RETENTION_VERIFY_REMINDER_EMAIL","RETENTION_BUILD_SITE_EMAIL","RETENTION_PUBLISH_REMINDER_EMAIL","RETENTION_IDLE_EMAIL","RETENTION_NEXT_STEPS_EMAIL","RETENTION_DOCS_PATHS","retentionDocsMergeValues","docsOrigin","origin","trim","replace","Object","fromEntries","entries","map","token","path","PRODUCT_TIP_RETENTION_EMAILS","Set","retentionSystemEmailTemplates","SAMPLE_CONSOLE_ORIGIN","SAMPLE_DOCS_ORIGIN","docsSamples","DOCS_TOKEN","name","description","sample","NAME_TOKEN","SITE_NAME_TOKEN","CTA_TOKEN","PREFERENCES_TOKEN","PREFERENCES_CAPTION","block","text","variant","key","deliveredBy","defaultSubject","mergeTokens","defaultBody","label","href","footerReason","source"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8CC,GAQD,OAAO,MAAMA,kCAAkC,4BAA2B;AAC1E,OAAO,MAAMC,6BAA6B,uBAAsB;AAChE,OAAO,MAAMC,mCAAmC,6BAA4B;AAC5E,OAAO,MAAMC,uBAAuB,iBAAgB;AACpD,OAAO,MAAMC,6BAA6B,uBAAsB;AAEhE;;;;;CAKC,GACD,OAAO,MAAMC,uBAAuB;IAClC,uBACE;IACF,wBAAwB;IACxB,mBAAmB;IACnB,yBAAyB;IACzB,iBAAiB;IACjB,2BAA2B;IAC3B,sBAAsB;AACxB,EAAU;AAIV;;;;CAIC,GACD,OAAO,SAASC,yBACdC,UAAkB;IAElB,MAAMC,SAASD,WAAWE,IAAI,GAAGC,OAAO,CAAC,QAAQ;IACjD,OAAOC,OAAOC,WAAW,CACvBD,OAAOE,OAAO,CAACR,sBAAsBS,GAAG,CAAC,CAAC,CAACC,OAAOC,KAAK,GAAK;YAC1DD;YACA,GAAGP,SAASQ,MAAM;SACnB;AAEL;AAEA;;;CAGC,GACD,OAAO,MAAMC,+BAAoD,IAAIC,IAAI;IACvEjB;IACAC;IACAC;IACAC;CACD,EAAC;AAEF;;;;;;CAMC,GACD,OAAO,SAASe,8BACdC,qBAA6B,EAC7BC,kBAA0B;IAE1B,MAAMC,cAAchB,yBAAyBe;IAC7C,MAAME,aAAa,CACjBC,MACAC,cAC2B,CAAA;YAAED;YAAMC;YAAaC,QAAQJ,WAAW,CAACE,KAAK;QAAC,CAAA;IAE5E,MAAMG,aAAoC;QACxCH,MAAM;QACNC,aAAa;QACbC,QAAQ;IACV;IAEA,MAAME,kBAAyC;QAC7CJ,MAAM;QACNC,aAAa;QACbC,QAAQ;IACV;IAEA,MAAMG,YAAmC;QACvCL,MAAM;QACNC,aAAa;QACbC,QAAQ,GAAGN,sBAAsB,yBAAyB,CAAC;IAC7D;IAEA,MAAMU,oBAA2C;QAC/CN,MAAM;QACNC,aAAa;QACbC,QAAQ,GAAGN,sBAAsB,mBAAmB,CAAC;IACvD;IAEA,qEAAqE;IACrE,MAAMW,sBAA+C;QACnDC,OAAO;QACPC,MAAM;QACNC,SAAS;IACX;IAEA,OAAO;QACL;YACEC,KAAKnC;YACLwB,MAAM;YACNC,aACE,sEACA;YACFW,aAAa;YACbC,gBACE;YACFC,aAAa;gBACXX;gBACA;oBACEH,MAAM;oBACNC,aAAa;oBACbC,QAAQ,GAAGN,sBAAsB,uBAAuB,CAAC;gBAC3D;gBACAG,WACE,uBACA;aAEH;YACDgB,aAAa;gBACX;oBACEP,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MACE,uEACA,6DACA;oBACFC,SAAS;gBACX;gBACA;oBAAEF,OAAO;oBAAUQ,OAAO;oBAAoBC,MAAM;gBAAgB;gBACpE;oBACET,OAAO;oBACPC,MACE,mEACA;oBACFC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;aACD;YACDQ,cACE,oEACA;YACFC,QAAQ;QACV;QACA;YACER,KAAKlC;YACLuB,MAAM;YACNC,aACE,oEACA,oEACA;YACFW,aAAa;YACbC,gBAAgB;YAChBC,aAAa;gBACXX;gBACAE;gBACAN,WAAW,wBAAwB;gBACnCO;aACD;YACDS,aAAa;gBACX;oBACEP,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MACE,oEACA,+DACA,kEACA,gEACA;oBACFC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACA;oBAAEF,OAAO;oBAAUQ,OAAO;oBAAiBC,MAAM;gBAAa;gBAC9D;oBACET,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACAH;aACD;YACDW,cACE,qDACA;YACFC,QAAQ;QACV;QACA;YACER,KAAKjC;YACLsB,MAAM;YACNC,aACE,qEACA;YACFW,aAAa;YACbC,gBAAgB;YAChBC,aAAa;gBACXX;gBACAC;gBACAC;gBACAN,WAAW,mBAAmB;gBAC9BO;aACD;YACDS,aAAa;gBACX;oBACEP,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MACE,+DACA,6DACA;oBACFC,SAAS;gBACX;gBACA;oBAAEF,OAAO;oBAAUQ,OAAO;oBAAsBC,MAAM;gBAAa;gBACnE;oBACET,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACAH;aACD;YACDW,cACE,qEACA;YACFC,QAAQ;QACV;QACA;YACER,KAAKhC;YACLqB,MAAM;YACNC,aACE,kEACA,uEACA;YACFW,aAAa;YACbC,gBAAgB;YAChBC,aAAa;gBACXX;gBACAC;gBACAC;gBACAN,WAAW,2BAA2B;gBACtCA,WAAW,sBAAsB;gBACjCO;aACD;YACDS,aAAa;gBACX;oBACEP,OAAO;oBACPC,MAAM;oBACNC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MACE,iEACA,8DACA,8DACA;oBACFC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MACE,qEACA;oBACFC,SAAS;gBACX;gBACA;oBAAEF,OAAO;oBAAUQ,OAAO;oBAAgBC,MAAM;gBAAa;gBAC7D;oBACET,OAAO;oBACPC,MACE,2DACA;oBACFC,SAAS;gBACX;gBACAH;aACD;YACDW,cACE,sDACA;YACFC,QAAQ;QACV;QACA;YACER,KAAK/B;YACLoB,MAAM;YACNC,aACE,mEACA;YACFW,aAAa;YACbC,gBAAgB;YAChBC,aAAa;gBACXX;gBACAC;gBACA;oBACEJ,MAAM;oBACNC,aAAa;oBACbC,QAAQ;gBACV;gBACA;oBACEF,MAAM;oBACNC,aAAa;oBACbC,QAAQ,GAAGN,sBAAsB,sCAAsC,CAAC;gBAC1E;gBACA;oBACEI,MAAM;oBACNC,aAAa;oBACbC,QAAQ,GAAGN,sBAAsB,+BAA+B,CAAC;gBACnE;gBACAG,WAAW,yBAAyB;gBACpCA,WAAW,iBAAiB;gBAC5BO;aACD;YACDS,aAAa;gBACX;oBAAEP,OAAO;oBAAQC,MAAM;oBAAyBC,SAAS;gBAAU;gBACnE;oBACEF,OAAO;oBACPC,MACE,oEACA;oBACFC,SAAS;gBACX;gBACA;oBACEF,OAAO;oBACPC,MACE,wDACA,2DACA,+DACA,yBACA;oBACFC,SAAS;gBACX;gBACA;oBAAEF,OAAO;oBAAUQ,OAAO;oBAAoBC,MAAM;gBAAgB;gBACpE;oBACET,OAAO;oBACPC,MACE,gEACA;oBACFC,SAAS;gBACX;gBACAH;aACD;YACDW,cACE,2DACA;YACFC,QAAQ;QACV;KACD;AACH"}
|
package/src/lib/send-email.d.ts
CHANGED
|
@@ -56,6 +56,14 @@ export interface SendEmailOptions {
|
|
|
56
56
|
/** Delivery tags for the opens/clicks webhook. */
|
|
57
57
|
tags?: EmailTag[];
|
|
58
58
|
replyTo?: string | string[];
|
|
59
|
+
/**
|
|
60
|
+
* Blind copies (AGL-3699): addresses that receive the same message without
|
|
61
|
+
* appearing on it — a review platform's invitation inbox copied on an order
|
|
62
|
+
* email, for one. Never a second customer: a blind copy is not screened by
|
|
63
|
+
* the deliverability preflight or the marketing gate, which ask about the
|
|
64
|
+
* `to` recipients, so a marketing send refuses any.
|
|
65
|
+
*/
|
|
66
|
+
bcc?: string | string[];
|
|
59
67
|
/**
|
|
60
68
|
* White-label display name for the sender (White-Label Phase 1). Replaces
|
|
61
69
|
* only the display name in front of the verified address — the address
|
package/src/lib/send-email.js
CHANGED
|
@@ -280,7 +280,7 @@ function normalizeRecipients(to) {
|
|
|
280
280
|
* `{ sent: false, reason: 'unconfigured' }` rather than failing, so local and
|
|
281
281
|
* preview environments keep working without a mail provider.
|
|
282
282
|
*/ export async function sendEmail(options) {
|
|
283
|
-
var _ref, _ref1, _ref2, _ref3, _ref4, _options_text;
|
|
283
|
+
var _ref, _ref1, _ref2, _ref3, _ref4, _options_bcc, _options_text;
|
|
284
284
|
var _options_sendingIdentity, _options_sendingIdentity1, _options_marketing, _preflight_refused, _preflight_held, _options_marketing1, _options_marketing2, _options_headers;
|
|
285
285
|
const provider = mailProvider();
|
|
286
286
|
const configuredFrom = process.env.USAGE_EMAIL_FROM || undefined;
|
|
@@ -467,6 +467,16 @@ function normalizeRecipients(to) {
|
|
|
467
467
|
*/ let unsubscribeUrl = (_ref3 = (_options_marketing1 = options.marketing) == null ? void 0 : _options_marketing1.unsubscribeUrl) != null ? _ref3 : '';
|
|
468
468
|
let oneClickUrl = (_ref4 = (_options_marketing2 = options.marketing) == null ? void 0 : _options_marketing2.oneClickUrl) != null ? _ref4 : '';
|
|
469
469
|
if (options.marketing) {
|
|
470
|
+
var _options_bcc1;
|
|
471
|
+
if (normalizeRecipients((_options_bcc1 = options.bcc) != null ? _options_bcc1 : []).length) {
|
|
472
|
+
// A blind copy of a marketing send is mail the gate never asked about.
|
|
473
|
+
console.error(`${label} refused — a marketing send carries no blind copies`);
|
|
474
|
+
return {
|
|
475
|
+
sent: false,
|
|
476
|
+
reason: 'no-recipient',
|
|
477
|
+
detail: 'A marketing send carries no blind copies.'
|
|
478
|
+
};
|
|
479
|
+
}
|
|
470
480
|
if (to.length !== 1) {
|
|
471
481
|
// Not a delivery outcome — a caller error, and one that would put the
|
|
472
482
|
// first recipient's signed unsubscribe link in everybody else's copy.
|
|
@@ -590,14 +600,20 @@ function normalizeRecipients(to) {
|
|
|
590
600
|
* into an outage on the product is a worse bug than the burst it was
|
|
591
601
|
* guarding. The same posture `sendEmail` takes everywhere else: it
|
|
592
602
|
* never throws, and neither does this.
|
|
593
|
-
*/
|
|
603
|
+
*/ // Blind copies, de-duplicated against the visible recipients: an address
|
|
604
|
+
// on both would receive the message twice.
|
|
605
|
+
const visible = new Set(to.map((address)=>address.toLowerCase()));
|
|
606
|
+
const bcc = [
|
|
607
|
+
...new Set(normalizeRecipients((_options_bcc = options.bcc) != null ? _options_bcc : []).filter((address)=>!visible.has(address.toLowerCase())))
|
|
608
|
+
];
|
|
609
|
+
const priority = resolveSendPriority(options.context, options.priority);
|
|
594
610
|
const governor = getEmailSendGovernor();
|
|
595
611
|
if (governor) {
|
|
596
612
|
let verdict;
|
|
597
613
|
try {
|
|
598
614
|
verdict = await governor({
|
|
599
615
|
priority,
|
|
600
|
-
count: to.length,
|
|
616
|
+
count: to.length + bcc.length,
|
|
601
617
|
context: options.context
|
|
602
618
|
});
|
|
603
619
|
} catch (error) {
|
|
@@ -667,6 +683,8 @@ function normalizeRecipients(to) {
|
|
|
667
683
|
tags
|
|
668
684
|
} : {}, options.replyTo ? {
|
|
669
685
|
replyTo: options.replyTo
|
|
686
|
+
} : {}, bcc.length ? {
|
|
687
|
+
bcc
|
|
670
688
|
} : {}), options.context);
|
|
671
689
|
if (!outcome.accepted) {
|
|
672
690
|
var _refusal_detail;
|