@aglyn/shared-util-email 1.0.0-beta.143

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.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/package.json +37 -0
  4. package/src/index.d.ts +37 -0
  5. package/src/index.js +46 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/email-delivery-events.d.ts +198 -0
  8. package/src/lib/email-delivery-events.js +310 -0
  9. package/src/lib/email-delivery-events.js.map +1 -0
  10. package/src/lib/email-health.d.ts +154 -0
  11. package/src/lib/email-health.js +264 -0
  12. package/src/lib/email-health.js.map +1 -0
  13. package/src/lib/email-media-src.d.ts +33 -0
  14. package/src/lib/email-media-src.js +113 -0
  15. package/src/lib/email-media-src.js.map +1 -0
  16. package/src/lib/email-merge.d.ts +31 -0
  17. package/src/lib/email-merge.js +36 -0
  18. package/src/lib/email-merge.js.map +1 -0
  19. package/src/lib/email-render.d.ts +114 -0
  20. package/src/lib/email-render.js +293 -0
  21. package/src/lib/email-render.js.map +1 -0
  22. package/src/lib/email-revenue-window.d.ts +55 -0
  23. package/src/lib/email-revenue-window.js +58 -0
  24. package/src/lib/email-revenue-window.js.map +1 -0
  25. package/src/lib/host-email-render.d.ts +99 -0
  26. package/src/lib/host-email-render.js +126 -0
  27. package/src/lib/host-email-render.js.map +1 -0
  28. package/src/lib/host-sender.d.ts +86 -0
  29. package/src/lib/host-sender.js +131 -0
  30. package/src/lib/host-sender.js.map +1 -0
  31. package/src/lib/marketing-send.d.ts +466 -0
  32. package/src/lib/marketing-send.js +459 -0
  33. package/src/lib/marketing-send.js.map +1 -0
  34. package/src/lib/platform-sending-domain.d.ts +362 -0
  35. package/src/lib/platform-sending-domain.js +697 -0
  36. package/src/lib/platform-sending-domain.js.map +1 -0
  37. package/src/lib/received-email.d.ts +86 -0
  38. package/src/lib/received-email.js +124 -0
  39. package/src/lib/received-email.js.map +1 -0
  40. package/src/lib/send-ceilings.d.ts +394 -0
  41. package/src/lib/send-ceilings.js +341 -0
  42. package/src/lib/send-ceilings.js.map +1 -0
  43. package/src/lib/send-email.d.ts +385 -0
  44. package/src/lib/send-email.js +586 -0
  45. package/src/lib/send-email.js.map +1 -0
  46. package/src/lib/send-rate.d.ts +298 -0
  47. package/src/lib/send-rate.js +310 -0
  48. package/src/lib/send-rate.js.map +1 -0
  49. package/src/lib/sender-reputation.d.ts +357 -0
  50. package/src/lib/sender-reputation.js +392 -0
  51. package/src/lib/sender-reputation.js.map +1 -0
  52. package/src/lib/sending-domain.d.ts +744 -0
  53. package/src/lib/sending-domain.js +798 -0
  54. package/src/lib/sending-domain.js.map +1 -0
  55. package/src/lib/sending-mailbox.d.ts +132 -0
  56. package/src/lib/sending-mailbox.js +192 -0
  57. package/src/lib/sending-mailbox.js.map +1 -0
  58. package/src/lib/stored-email-nodes.d.ts +38 -0
  59. package/src/lib/stored-email-nodes.js +78 -0
  60. package/src/lib/stored-email-nodes.js.map +1 -0
  61. package/src/lib/svix-signature.d.ts +38 -0
  62. package/src/lib/svix-signature.js +55 -0
  63. package/src/lib/svix-signature.js.map +1 -0
  64. package/src/lib/system-email-catalog.d.ts +170 -0
  65. package/src/lib/system-email-catalog.js +828 -0
  66. package/src/lib/system-email-catalog.js.map +1 -0
  67. package/src/lib/tenant-email-catalog.d.ts +78 -0
  68. package/src/lib/tenant-email-catalog.js +452 -0
  69. package/src/lib/tenant-email-catalog.js.map +1 -0
  70. package/src/lib/text-email-html.d.ts +32 -0
  71. package/src/lib/text-email-html.js +119 -0
  72. package/src/lib/text-email-html.js.map +1 -0
@@ -0,0 +1,828 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * Origins used as merge-token SAMPLE values in the staff email designer
19
+ * (AGL-2202).
20
+ *
21
+ * These are never sent — they render in the template preview so an author can
22
+ * see what `{{console.url}}` will become. That still made them wrong on a
23
+ * self-host install: the preview showed Aglyn's console as the example for a
24
+ * link that will resolve to the operator's, which is exactly the sort of
25
+ * quiet mis-teaching that ends up copied into a hand-written template.
26
+ *
27
+ * Read from the environment rather than through `@aglyn/aglyn`'s
28
+ * `platform-brand` / `constants`: `shared-util-email` is tagged `scope:shared`
29
+ * and `@nx/enforce-module-boundaries` refuses the edge. Same defaults, so a
30
+ * deployment that configures nothing sees exactly what it saw before.
31
+ */ const SAMPLE_CONSOLE_ORIGIN = (process.env.NEXT_PUBLIC_CONSOLE_URL || '').trim().replace(/\/+$/, '') || 'https://app.aglyn.com';
32
+ /**
33
+ * Mirrors `PLATFORM_SUPPORT_URL`'s precedence, including the step that makes
34
+ * the operator identity sufficient on its own: a configured support URL, else
35
+ * the operator's support mailbox as a `mailto:`, else ours.
36
+ */ const SAMPLE_SUPPORT_URL = (process.env.NEXT_PUBLIC_PLATFORM_SUPPORT_URL || '').trim() || ((process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL || '').trim() ? `mailto:${(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL || '').trim()}` : 'https://aglyn.com/support');
37
+ // Deep import, NOT the barrel (AGL-1151): `@aglyn/shared-data-enums` re-exports
38
+ // `firebase-auth`, whose `AuthErrorCodes` is a VALUE import of `firebase/auth`.
39
+ // This module is reachable from the email plugin's site half, which a PUBLISHED
40
+ // tenant page loads, so the barrel put the Firebase auth client into that
41
+ // page's chunks for one brand constant.
42
+ import { BRAND } from "@aglyn/shared-data-enums/aglyn";
43
+ import { EMAIL_NODE_ROOT_ID } from "./email-render.js";
44
+ /**
45
+ * Every system email Aglyn sends.
46
+ *
47
+ * Deliberately **code-defined and fixed**: staff edit the system emails that
48
+ * exist, they do not create or delete them. Adding one is a code change,
49
+ * which is what keeps this list from drifting into advertising an email the
50
+ * product never actually sends — the failure mode a Firestore-backed list
51
+ * would have.
52
+ *
53
+ * The staff page renders from this registry, not from Firestore, so an email
54
+ * with no template designed yet still appears (as "Using default").
55
+ */ /**
56
+ * Brand tokens EVERY system email resolves (AGL-2139).
57
+ *
58
+ * White-label used to invert precisely when staff published a template: the
59
+ * designed template wins over each sender's branded fallback, and the copy
60
+ * here hard-coded "Aglyn". Nor was there anything to design against — the
61
+ * merge maps carried `org.name`, `invite.role`, `signInUrl` and friends and
62
+ * nothing about the brand, while `blankUnresolvedTokens` DELETES any token
63
+ * the caller did not supply, so a designer who typed `{{brand.productName}}`
64
+ * would have shipped an email with a hole in the sentence.
65
+ *
66
+ * Declared once and appended to every template's own list rather than copied
67
+ * into each: 13 hand-maintained copies is how the token list falls behind the
68
+ * copy, and the editor renders this list as the palette a designer picks
69
+ * from — a token missing here is a token nobody knows exists.
70
+ *
71
+ * The samples are the DEPLOYMENT's own values (`BRAND.ORG_NAME`, AGL-2319),
72
+ * because that is what an unbranded org genuinely renders — on a self-host
73
+ * install a hardcoded "Aglyn" would preview a brand that appears nowhere in
74
+ * the mail the designer is editing.
75
+ */ export const BRAND_MERGE_TOKENS = [
76
+ {
77
+ name: 'brand.productName',
78
+ description: "The sender's product name — the org's own on white-label",
79
+ sample: BRAND.ORG_NAME
80
+ },
81
+ {
82
+ name: 'brand.fromName',
83
+ description: 'Sender display name the mail is delivered under',
84
+ sample: BRAND.ORG_NAME
85
+ },
86
+ {
87
+ name: 'brand.supportUrl',
88
+ description: 'Where the recipient should go for help',
89
+ sample: SAMPLE_SUPPORT_URL
90
+ }
91
+ ];
92
+ // NOT declared: a `brand.logoUrl` token. The logo is STRUCTURAL — the
93
+ // renderer emits it as a header row above the designed body, or emits
94
+ // nothing — so a designer never places it, and a palette entry for it would
95
+ // need a sample URL that a staff test-send would render as a broken image.
96
+ // The three tokens above are the ones that belong in copy.
97
+ const BASE_SYSTEM_EMAIL_TEMPLATES = [
98
+ {
99
+ key: 'org-invite',
100
+ name: 'Organization invite',
101
+ description: 'Sent when an admin invites someone to an organization from the ' + 'Team page.',
102
+ deliveredBy: 'resend',
103
+ defaultSubject: "You've been invited to {{org.name}} on {{brand.productName}}",
104
+ mergeTokens: [
105
+ {
106
+ name: 'org.name',
107
+ description: 'Name of the inviting organization',
108
+ sample: 'Test Org'
109
+ },
110
+ {
111
+ name: 'invite.role',
112
+ description: 'Role the person is invited as',
113
+ sample: 'editor'
114
+ },
115
+ {
116
+ name: 'signInUrl',
117
+ description: 'Console URL to sign in and accept',
118
+ sample: SAMPLE_CONSOLE_ORIGIN
119
+ }
120
+ ],
121
+ // Mirrors the fallbackText in invites/route.ts.
122
+ defaultBody: [
123
+ {
124
+ block: 'text',
125
+ text: "You've been invited to join {{org.name}} as {{invite.role}}.",
126
+ variant: 'body'
127
+ },
128
+ {
129
+ block: 'text',
130
+ text: 'Sign in with this email address and accept the invite from ' + 'your dashboard.',
131
+ variant: 'body'
132
+ },
133
+ {
134
+ block: 'button',
135
+ label: 'Sign in',
136
+ href: '{{signInUrl}}'
137
+ }
138
+ ],
139
+ source: 'apps/console/app/api/orgs/invites/route.ts'
140
+ },
141
+ {
142
+ key: 'usage-summary',
143
+ name: 'Monthly usage summary',
144
+ description: 'Monthly per-organization usage and metered-cost summary, sent by ' + 'the scheduled job.',
145
+ deliveredBy: 'resend',
146
+ defaultSubject: 'Your {{brand.productName}} usage summary for {{month}}',
147
+ mergeTokens: [
148
+ {
149
+ name: 'month',
150
+ description: 'Billing month as YYYY-MM',
151
+ sample: '2026-06'
152
+ },
153
+ {
154
+ name: 'org.name',
155
+ description: 'Organization the summary is for',
156
+ sample: 'Test Org'
157
+ },
158
+ {
159
+ name: 'usage.summary',
160
+ description: 'Pre-formatted usage lines',
161
+ sample: 'Page views: 12,400'
162
+ }
163
+ ],
164
+ // Mirrors the summary lines assembled in usage-email/route.ts.
165
+ defaultBody: [
166
+ {
167
+ block: 'text',
168
+ text: 'Usage summary for {{org.name}}',
169
+ variant: 'heading'
170
+ },
171
+ {
172
+ block: 'text',
173
+ text: 'Here is your {{brand.productName}} usage summary for {{month}}.',
174
+ variant: 'body'
175
+ },
176
+ {
177
+ block: 'text',
178
+ text: '{{usage.summary}}',
179
+ variant: 'body'
180
+ }
181
+ ],
182
+ source: 'apps/console/app/api/billing/usage-email/route.ts'
183
+ },
184
+ {
185
+ key: 'erasure-hold-alert',
186
+ name: 'Erasure hold staff alert',
187
+ description: 'Internal alert to STAFF_ALERT_EMAIL when GDPR erasure requests ' + 'pass their 7-day hold.',
188
+ deliveredBy: 'resend',
189
+ defaultSubject: '{{count}} erasure request(s) past the 7-day hold',
190
+ mergeTokens: [
191
+ {
192
+ name: 'count',
193
+ description: 'How many organizations are past the hold',
194
+ sample: '3'
195
+ },
196
+ {
197
+ name: 'orgs.list',
198
+ description: 'The organizations, one per line',
199
+ sample: '- Test Org (org_123), requested 2026-07-01'
200
+ }
201
+ ],
202
+ // Mirrors the staff-alert text in audit-archive/route.ts.
203
+ defaultBody: [
204
+ {
205
+ block: 'text',
206
+ text: 'These organizations are past their GDPR erasure hold. Run ' + 'tools/scripts/erase-tenant.mjs to hard-delete. No copy is kept:',
207
+ variant: 'body'
208
+ },
209
+ {
210
+ block: 'text',
211
+ text: '{{orgs.list}}',
212
+ variant: 'body'
213
+ }
214
+ ],
215
+ source: 'apps/console/app/api/admin/audit-archive/route.ts'
216
+ },
217
+ {
218
+ key: 'welcome',
219
+ name: 'Welcome',
220
+ description: 'Sent to a new owner the first time they create an organization ' + '(not on every organization they later create).',
221
+ deliveredBy: 'resend',
222
+ defaultSubject: 'Welcome to {{brand.productName}}',
223
+ mergeTokens: [
224
+ {
225
+ name: 'name',
226
+ description: "The new owner's display name",
227
+ sample: 'Alex'
228
+ },
229
+ {
230
+ name: 'org.name',
231
+ description: 'The organization they just created',
232
+ sample: 'Test Org'
233
+ },
234
+ {
235
+ name: 'consoleUrl',
236
+ description: 'Console dashboard URL',
237
+ sample: SAMPLE_CONSOLE_ORIGIN
238
+ }
239
+ ],
240
+ // Mirrors the fallbackText in orgs/create/route.ts.
241
+ defaultBody: [
242
+ {
243
+ block: 'text',
244
+ text: 'Welcome to {{brand.productName}}',
245
+ variant: 'heading'
246
+ },
247
+ {
248
+ block: 'text',
249
+ text: 'Hi {{name}}, thanks for creating {{org.name}}. Your workspace ' + 'is ready.',
250
+ variant: 'body'
251
+ },
252
+ {
253
+ block: 'text',
254
+ text: 'From your dashboard you can add a site, invite your team, and ' + 'start building.',
255
+ variant: 'body'
256
+ },
257
+ {
258
+ block: 'button',
259
+ label: 'Open your dashboard',
260
+ href: '{{consoleUrl}}'
261
+ }
262
+ ],
263
+ source: 'apps/console/app/api/orgs/create/route.ts'
264
+ },
265
+ {
266
+ key: 'member-added',
267
+ name: 'Added to an organization',
268
+ description: `Sent when an existing ${BRAND.ORG_NAME} account is added directly ` + 'to an organization. People who do not have an account yet get the ' + 'organization invite email instead.',
269
+ deliveredBy: 'resend',
270
+ defaultSubject: "You've been added to {{org.name}}",
271
+ mergeTokens: [
272
+ {
273
+ name: 'org.name',
274
+ description: 'The organization they were added to',
275
+ sample: 'Test Org'
276
+ },
277
+ {
278
+ name: 'member.role',
279
+ description: 'Role they were given',
280
+ sample: 'editor'
281
+ },
282
+ {
283
+ name: 'signInUrl',
284
+ description: 'Console URL to open the organization',
285
+ sample: SAMPLE_CONSOLE_ORIGIN
286
+ }
287
+ ],
288
+ // Mirrors the fallbackText in orgs/members/route.ts.
289
+ defaultBody: [
290
+ {
291
+ block: 'text',
292
+ text: 'You were added to {{org.name}} as {{member.role}}.',
293
+ variant: 'body'
294
+ },
295
+ {
296
+ block: 'text',
297
+ text: 'Sign in to switch to it from your dashboard.',
298
+ variant: 'body'
299
+ },
300
+ {
301
+ block: 'button',
302
+ label: 'Open {{brand.productName}}',
303
+ href: '{{signInUrl}}'
304
+ }
305
+ ],
306
+ source: 'apps/console/app/api/orgs/members/route.ts'
307
+ },
308
+ {
309
+ key: 'erasure-confirmation',
310
+ name: 'Erasure completed',
311
+ description: 'Sent to an organization owner once its data has been permanently ' + 'erased under a GDPR request.',
312
+ deliveredBy: 'resend',
313
+ defaultSubject: 'Your {{brand.productName}} data has been erased',
314
+ mergeTokens: [
315
+ {
316
+ name: 'org.name',
317
+ description: 'The organization that was erased',
318
+ sample: 'Test Org'
319
+ }
320
+ ],
321
+ // Mirrors the fallbackText in admin/run-erasures/route.ts.
322
+ defaultBody: [
323
+ {
324
+ block: 'text',
325
+ text: 'Your data has been erased',
326
+ variant: 'heading'
327
+ },
328
+ {
329
+ block: 'text',
330
+ text: '{{org.name}} and all of its data have been permanently erased ' + 'from {{brand.productName}}, as requested.',
331
+ variant: 'body'
332
+ },
333
+ {
334
+ block: 'text',
335
+ text: 'This is complete and cannot be undone.',
336
+ variant: 'body'
337
+ }
338
+ ],
339
+ source: 'apps/console/app/api/admin/run-erasures/route.ts'
340
+ },
341
+ {
342
+ key: 'erasure-requested',
343
+ name: 'Erasure requested',
344
+ description: 'Confirms to an organization owner that a GDPR erasure request was ' + 'recorded, and that deletion follows after a 7-day hold.',
345
+ deliveredBy: 'resend',
346
+ defaultSubject: 'We received your erasure request',
347
+ mergeTokens: [
348
+ {
349
+ name: 'org.name',
350
+ description: 'The organization to be erased',
351
+ sample: 'Test Org'
352
+ }
353
+ ],
354
+ // Mirrors the fallbackText in admin/erasure-request/route.ts.
355
+ defaultBody: [
356
+ {
357
+ block: 'text',
358
+ text: 'Erasure request received',
359
+ variant: 'heading'
360
+ },
361
+ {
362
+ block: 'text',
363
+ text: 'We have recorded a request to erase {{org.name}} and all of ' + 'its data from {{brand.productName}}.',
364
+ variant: 'body'
365
+ },
366
+ {
367
+ block: 'text',
368
+ text: 'Deletion is permanent and happens after a 7-day hold. If this ' + 'was not intended, contact support before then to cancel.',
369
+ variant: 'body'
370
+ }
371
+ ],
372
+ source: 'apps/console/app/api/admin/erasure-request/route.ts'
373
+ },
374
+ // Admin-initiated password mail (AGL-910). Unlike the self-serve
375
+ // `password-reset` below, Aglyn composes and sends these itself — the
376
+ // reset LINK still comes from Firebase (generatePasswordResetLink), but
377
+ // the message around it is ours, so both are designable.
378
+ {
379
+ key: 'admin-password-reset',
380
+ name: 'Password reset (sent by an admin)',
381
+ description: 'Password reset link sent on someone else\'s behalf from the ' + 'console — by staff from a user\'s detail page, or by an ' + 'organization admin from a team member\'s page.',
382
+ deliveredBy: 'resend',
383
+ defaultSubject: 'Reset your {{brand.productName}} password',
384
+ mergeTokens: [
385
+ {
386
+ name: 'resetUrl',
387
+ description: 'One-time link that opens the reset form',
388
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/reset-password?oobCode=…`
389
+ },
390
+ {
391
+ name: 'actor.name',
392
+ description: 'Who asked for the reset',
393
+ sample: '{{brand.productName}} support'
394
+ }
395
+ ],
396
+ // Mirrors the fallbackText in _lib/password-admin.ts.
397
+ defaultBody: [
398
+ {
399
+ block: 'text',
400
+ text: 'Reset your password',
401
+ variant: 'heading'
402
+ },
403
+ {
404
+ block: 'text',
405
+ text: '{{actor.name}} started a password reset for your {{brand.productName}} ' + 'account. Choose a new password here:',
406
+ variant: 'body'
407
+ },
408
+ {
409
+ block: 'button',
410
+ label: 'Set a new password',
411
+ href: '{{resetUrl}}'
412
+ },
413
+ {
414
+ block: 'text',
415
+ text: 'The link expires shortly. If you were not expecting this, you ' + 'can ignore this email — your password stays as it is.',
416
+ variant: 'caption'
417
+ }
418
+ ],
419
+ source: 'apps/console/app/api/_lib/password-admin.ts'
420
+ },
421
+ {
422
+ key: 'password-changed-by-admin',
423
+ name: 'Password changed by an admin',
424
+ description: 'Notice sent to the account holder after an admin sets their ' + 'password directly. Always sent — it is how someone finds out an ' + 'administrator changed their credentials.',
425
+ deliveredBy: 'resend',
426
+ defaultSubject: 'Your {{brand.productName}} password was changed',
427
+ mergeTokens: [
428
+ {
429
+ name: 'actor.name',
430
+ description: 'Who changed the password',
431
+ sample: '{{brand.productName}} support'
432
+ },
433
+ {
434
+ name: 'signInUrl',
435
+ description: 'Where to sign in with the new password',
436
+ sample: SAMPLE_CONSOLE_ORIGIN
437
+ }
438
+ ],
439
+ // Mirrors the fallbackText in _lib/password-admin.ts.
440
+ defaultBody: [
441
+ {
442
+ block: 'text',
443
+ text: 'Your password was changed',
444
+ variant: 'heading'
445
+ },
446
+ {
447
+ block: 'text',
448
+ text: '{{actor.name}} set a new password on your account. You have ' + 'been signed out everywhere and will need the new password to ' + 'sign back in.',
449
+ variant: 'body'
450
+ },
451
+ {
452
+ block: 'button',
453
+ label: 'Sign in',
454
+ href: '{{signInUrl}}'
455
+ },
456
+ {
457
+ block: 'text',
458
+ text: 'If you did not expect this, contact whoever administers your ' + 'account straight away.',
459
+ variant: 'caption'
460
+ }
461
+ ],
462
+ source: 'apps/console/app/api/_lib/password-admin.ts'
463
+ },
464
+ // Security alerts (AGL-665). Factual and actionable: what happened, from
465
+ // where, and one button to the account-security surface. These must be
466
+ // believed, so the send path only fires them on genuinely new devices —
467
+ // never on every sign-in.
468
+ {
469
+ key: 'security-new-device',
470
+ name: 'New device sign-in',
471
+ description: 'Security alert sent when an account signs in from a device it has ' + 'not used before. Not sent on routine sign-ins from known devices.',
472
+ deliveredBy: 'resend',
473
+ defaultSubject: 'New sign-in to your {{brand.productName}} account',
474
+ mergeTokens: [
475
+ {
476
+ name: 'device.name',
477
+ description: 'Browser and operating system that signed in',
478
+ sample: 'Chrome on macOS'
479
+ },
480
+ {
481
+ name: 'device.location',
482
+ description: 'Approximate location, from the request IP',
483
+ sample: 'Denver, CO, US'
484
+ },
485
+ {
486
+ name: 'device.ip',
487
+ description: 'IP address the sign-in came from',
488
+ sample: '203.0.113.7'
489
+ },
490
+ {
491
+ name: 'device.time',
492
+ description: 'When the sign-in happened (UTC)',
493
+ sample: '2026-08-08 14:03 UTC'
494
+ },
495
+ {
496
+ name: 'accountSecurityUrl',
497
+ description: 'Manage-account Security section — Recent sign-ins, where the ' + 'device can be signed out, plus password and passkeys',
498
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user/security`
499
+ }
500
+ ],
501
+ // Mirrors the fallbackText in _lib/security-alerts.ts.
502
+ defaultBody: [
503
+ {
504
+ block: 'text',
505
+ text: 'New device sign-in',
506
+ variant: 'heading'
507
+ },
508
+ {
509
+ block: 'text',
510
+ text: 'Your {{brand.productName}} account was just signed in to from a device it has ' + 'not used before.',
511
+ variant: 'body'
512
+ },
513
+ {
514
+ block: 'text',
515
+ text: 'Device: {{device.name}}',
516
+ variant: 'body'
517
+ },
518
+ {
519
+ block: 'text',
520
+ text: 'Location: {{device.location}}',
521
+ variant: 'body'
522
+ },
523
+ {
524
+ block: 'text',
525
+ text: 'IP address: {{device.ip}}',
526
+ variant: 'body'
527
+ },
528
+ {
529
+ block: 'text',
530
+ text: 'When: {{device.time}}',
531
+ variant: 'body'
532
+ },
533
+ {
534
+ // AGL-1959. The button used to say "Review account security" and
535
+ // land on the account page's DEFAULT tab, which offered a password
536
+ // change and nothing about the device the mail is about. AGL-665
537
+ // asked for this: "'Manage your devices' has to land somewhere that
538
+ // can list and revoke sessions, or the alert tells you something is
539
+ // wrong and offers no way to act on it — which is worse than sending
540
+ // nothing."
541
+ block: 'button',
542
+ label: 'Manage your devices',
543
+ href: '{{accountSecurityUrl}}'
544
+ },
545
+ {
546
+ block: 'text',
547
+ text: 'If this was you, no action is needed. If it was not, sign this ' + 'device out from Recent sign-ins and change your password right ' + 'away.',
548
+ variant: 'caption'
549
+ }
550
+ ],
551
+ source: 'apps/console/app/api/_lib/security-alerts.ts'
552
+ },
553
+ {
554
+ key: 'security-passkey-added',
555
+ name: 'New passkey added',
556
+ description: 'Security alert sent when a passkey is registered on an account. ' + 'The trigger ships with passkey support (AGL-662); until then no ' + 'call site sends this.',
557
+ deliveredBy: 'resend',
558
+ defaultSubject: 'A passkey was added to your {{brand.productName}} account',
559
+ mergeTokens: [
560
+ {
561
+ name: 'passkey.label',
562
+ description: 'Name the passkey was saved under',
563
+ sample: 'MacBook Touch ID'
564
+ },
565
+ {
566
+ name: 'device.time',
567
+ description: 'When the passkey was added (UTC)',
568
+ sample: '2026-08-08 14:03 UTC'
569
+ },
570
+ {
571
+ name: 'accountSecurityUrl',
572
+ description: 'Manage-account page where credentials are reviewed',
573
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/manage/user`
574
+ }
575
+ ],
576
+ // Mirrors the fallbackText in _lib/security-alerts.ts.
577
+ defaultBody: [
578
+ {
579
+ block: 'text',
580
+ text: 'New passkey added',
581
+ variant: 'heading'
582
+ },
583
+ {
584
+ block: 'text',
585
+ text: 'A passkey ("{{passkey.label}}") was just added to your {{brand.productName}} ' + 'account. It can be used to sign in without your password.',
586
+ variant: 'body'
587
+ },
588
+ {
589
+ block: 'text',
590
+ text: 'When: {{device.time}}',
591
+ variant: 'body'
592
+ },
593
+ {
594
+ block: 'button',
595
+ label: 'Review account security',
596
+ href: '{{accountSecurityUrl}}'
597
+ },
598
+ {
599
+ block: 'text',
600
+ text: 'If you did not add this passkey, change your password from ' + 'Manage account right away.',
601
+ variant: 'caption'
602
+ }
603
+ ],
604
+ source: 'apps/console/app/api/_lib/security-alerts.ts'
605
+ },
606
+ // Aglyn composes and sends both of these now (AGL-1112). They were
607
+ // Firebase's, from a template nobody could edit — the subject still
608
+ // carried `[aglyn.io]`, a domain the company no longer uses, and the link
609
+ // landed on `aglyn-main.firebaseapp.com`. Neither could be fixed: every
610
+ // write under `notification.sendEmail` is refused with
611
+ // `EMAIL_TEMPLATE_UPDATE_NOT_ALLOWED`.
612
+ //
613
+ // Firebase still MINTS the one-time code, which is the part that has to
614
+ // be its. Only the message around it, and the host the link points at,
615
+ // became ours. So editing these here now does what it says.
616
+ {
617
+ key: 'password-reset',
618
+ name: 'Forgot password',
619
+ description: 'Password reset link, sent when someone asks for one from Account ' + 'recovery.',
620
+ deliveredBy: 'resend',
621
+ defaultSubject: 'Reset your {{brand.productName}} password',
622
+ mergeTokens: [
623
+ {
624
+ name: 'resetUrl',
625
+ description: 'One-time link that opens the choose-a-new-password page',
626
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/reset-password?oobCode=\u2026`
627
+ }
628
+ ],
629
+ // Mirrors the fallbackText in the route, so the designed and undesigned
630
+ // versions say the same thing.
631
+ defaultBody: [
632
+ {
633
+ block: 'text',
634
+ text: 'Reset your password',
635
+ variant: 'heading'
636
+ },
637
+ {
638
+ block: 'text',
639
+ text: 'Someone asked to reset the password for your {{brand.productName}} account. ' + 'Choose a new one here:',
640
+ variant: 'body'
641
+ },
642
+ {
643
+ block: 'button',
644
+ label: 'Choose a new password',
645
+ href: '{{resetUrl}}'
646
+ },
647
+ {
648
+ block: 'text',
649
+ text: 'The link expires shortly. If this was not you, you can ignore ' + 'this email — your password stays as it is.',
650
+ variant: 'caption'
651
+ }
652
+ ],
653
+ source: 'apps/console/app/api/auth/send-password-reset/route.ts'
654
+ },
655
+ {
656
+ key: 'email-verification',
657
+ name: 'Confirm email',
658
+ description: 'Address verification link, sent after sign-up and whenever someone ' + 'asks for another from the verify screen.',
659
+ deliveredBy: 'resend',
660
+ defaultSubject: 'Confirm your email address',
661
+ mergeTokens: [
662
+ {
663
+ name: 'verifyUrl',
664
+ description: 'One-time link that confirms the address',
665
+ sample: `${SAMPLE_CONSOLE_ORIGIN}/verify-email?oobCode=\u2026`
666
+ }
667
+ ],
668
+ defaultBody: [
669
+ {
670
+ block: 'text',
671
+ text: 'Confirm your email',
672
+ variant: 'heading'
673
+ },
674
+ {
675
+ block: 'text',
676
+ text: 'Confirm this address to finish setting up your {{brand.productName}} account:',
677
+ variant: 'body'
678
+ },
679
+ {
680
+ block: 'button',
681
+ label: 'Confirm my email',
682
+ href: '{{verifyUrl}}'
683
+ },
684
+ {
685
+ block: 'text',
686
+ text: 'If you did not create an {{brand.productName}} account, you can ignore this ' + 'email.',
687
+ variant: 'caption'
688
+ }
689
+ ],
690
+ source: 'apps/console/app/api/auth/send-verification/route.ts'
691
+ },
692
+ // Stripe-delivered billing email (AGL-767). Aglyn never composes these —
693
+ // Stripe sends them from the Dashboard's Customer-emails and Subscription
694
+ // settings, so they are listed for visibility only and are not designable.
695
+ // Whether each actually sends is a Dashboard toggle the code cannot read;
696
+ // the descriptions say "if enabled" rather than asserting it is on.
697
+ {
698
+ key: 'stripe-receipt',
699
+ name: 'Payment receipt',
700
+ description: 'Receipt for a successful subscription or invoice payment. Sent by ' + 'Stripe if "Successful payments" is enabled under Customer emails.',
701
+ deliveredBy: 'stripe',
702
+ defaultSubject: `Your receipt from ${BRAND.ORG_NAME}`,
703
+ mergeTokens: [],
704
+ source: 'Stripe Dashboard → Settings → Customer emails'
705
+ },
706
+ {
707
+ key: 'stripe-payment-failed',
708
+ name: 'Payment failed',
709
+ description: 'Dunning notice when a subscription charge fails and Stripe retries ' + 'it. Sent by Stripe if failed-payment emails are enabled under ' + 'Subscriptions and emails.',
710
+ deliveredBy: 'stripe',
711
+ defaultSubject: `Your ${BRAND.ORG_NAME} payment could not be processed`,
712
+ mergeTokens: [],
713
+ source: 'Stripe Dashboard → Settings → Subscriptions and emails'
714
+ },
715
+ {
716
+ key: 'stripe-refund',
717
+ name: 'Refund confirmation',
718
+ description: 'Confirms a refund back to the customer. Sent by Stripe if ' + '"Refunds" is enabled under Customer emails.',
719
+ deliveredBy: 'stripe',
720
+ defaultSubject: `Your ${BRAND.ORG_NAME} refund`,
721
+ mergeTokens: [],
722
+ source: 'Stripe Dashboard → Settings → Customer emails'
723
+ },
724
+ {
725
+ key: 'stripe-card-expiring',
726
+ name: 'Card expiring soon',
727
+ description: 'Reminder that the card on file is about to expire. Sent by Stripe ' + 'if expiring-card reminders are enabled under Subscriptions and ' + 'emails.',
728
+ deliveredBy: 'stripe',
729
+ defaultSubject: 'Your card on file is expiring soon',
730
+ mergeTokens: [],
731
+ source: 'Stripe Dashboard → Settings → Subscriptions and emails'
732
+ },
733
+ {
734
+ key: 'stripe-invoice',
735
+ name: 'Invoice',
736
+ description: 'A finalized invoice emailed to the organization. Sent by Stripe ' + 'when invoice emails are enabled for the billing configuration.',
737
+ deliveredBy: 'stripe',
738
+ defaultSubject: `Your ${BRAND.ORG_NAME} invoice`,
739
+ mergeTokens: [],
740
+ source: 'Stripe Dashboard → Settings → Customer emails'
741
+ }
742
+ ];
743
+ /**
744
+ * Every system email Aglyn sends, each with the universal brand tokens
745
+ * appended to its own (AGL-2139).
746
+ *
747
+ * The template's own tokens come FIRST so the editor's palette still leads
748
+ * with what is specific to that email; the brand block reads as the shared
749
+ * footer it is.
750
+ */ export const SYSTEM_EMAIL_TEMPLATES = BASE_SYSTEM_EMAIL_TEMPLATES.map((entry)=>_extends({}, entry, {
751
+ mergeTokens: [
752
+ ...entry.mergeTokens,
753
+ ...BRAND_MERGE_TOKENS
754
+ ]
755
+ }));
756
+ /** Firestore collection holding the designed templates. */ export const SYSTEM_EMAIL_COLLECTION = 'systemEmailTemplates';
757
+ export function getSystemEmailTemplate(key) {
758
+ return SYSTEM_EMAIL_TEMPLATES.find((entry)=>entry.key === key);
759
+ }
760
+ /** True when a besigner template can actually change what is delivered. */ export function isSystemEmailEditable(definition) {
761
+ return definition.deliveredBy === 'resend';
762
+ }
763
+ const PLACEHOLDER_DEFAULT_BODY = [
764
+ {
765
+ block: 'text',
766
+ text: 'Hello,',
767
+ variant: 'body'
768
+ }
769
+ ];
770
+ /**
771
+ * Turns a template's `defaultBody` into the besigner node map the editor
772
+ * seeds and the send-time default renders (AGL-764/766).
773
+ *
774
+ * One place builds this so the two never diverge — the version a staffer
775
+ * opens is byte-for-byte what a test/send renders when nothing is published.
776
+ * Rooted at {@link EMAIL_NODE_ROOT_ID} so `renderEmailHtml` finds it (AGL-765),
777
+ * with deterministic ids: a version doc only needs ids unique within itself,
778
+ * and determinism keeps the map stable and diffable.
779
+ */ export function buildDefaultEmailNodeMap(// Structural so the tenant catalog (AGL-770) can seed the same way — every
780
+ // definition that carries a `defaultBody` builds an identical node map.
781
+ definition) {
782
+ var _definition_defaultBody;
783
+ const blocks = ((_definition_defaultBody = definition.defaultBody) == null ? void 0 : _definition_defaultBody.length) ? definition.defaultBody : PLACEHOLDER_DEFAULT_BODY;
784
+ const sectionId = 'default-section';
785
+ const childIds = [];
786
+ const map = {};
787
+ blocks.forEach((block, index)=>{
788
+ var _block_variant;
789
+ const id = `default-${index}`;
790
+ childIds.push(id);
791
+ map[id] = block.block === 'button' ? {
792
+ $id: id,
793
+ componentId: 'emailButton',
794
+ pluginId: 'email',
795
+ parentId: sectionId,
796
+ props: {
797
+ children: block.label,
798
+ href: block.href
799
+ }
800
+ } : {
801
+ $id: id,
802
+ componentId: 'emailText',
803
+ pluginId: 'email',
804
+ parentId: sectionId,
805
+ props: {
806
+ children: block.text,
807
+ variant: (_block_variant = block.variant) != null ? _block_variant : 'body'
808
+ }
809
+ };
810
+ });
811
+ map[EMAIL_NODE_ROOT_ID] = {
812
+ $id: EMAIL_NODE_ROOT_ID,
813
+ componentId: 'div',
814
+ nodes: [
815
+ sectionId
816
+ ]
817
+ };
818
+ map[sectionId] = {
819
+ $id: sectionId,
820
+ componentId: 'emailSection',
821
+ pluginId: 'email',
822
+ parentId: EMAIL_NODE_ROOT_ID,
823
+ nodes: childIds
824
+ };
825
+ return map;
826
+ }
827
+
828
+ //# sourceMappingURL=system-email-catalog.js.map