@aglyn/tenant-data-admin 1.0.0-beta.200 → 1.0.0-beta.202

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 (67) hide show
  1. package/package.json +8 -7
  2. package/src/lib/server/card-payment-velocity.d.ts +85 -0
  3. package/src/lib/server/card-payment-velocity.js +202 -0
  4. package/src/lib/server/card-payment-velocity.js.map +1 -0
  5. package/src/lib/server/configured-url-fetch.d.ts +42 -0
  6. package/src/lib/server/configured-url-fetch.js +118 -0
  7. package/src/lib/server/configured-url-fetch.js.map +1 -0
  8. package/src/lib/server/dns-probe.d.ts +7 -0
  9. package/src/lib/server/dns-probe.js +52 -0
  10. package/src/lib/server/dns-probe.js.map +1 -1
  11. package/src/lib/server/email-deliverability.d.ts +150 -0
  12. package/src/lib/server/email-deliverability.js +538 -0
  13. package/src/lib/server/email-deliverability.js.map +1 -0
  14. package/src/lib/server/email-sender-reputation.d.ts +2 -11
  15. package/src/lib/server/email-sender-reputation.js +8 -25
  16. package/src/lib/server/email-sender-reputation.js.map +1 -1
  17. package/src/lib/server/email-suppression.d.ts +10 -1
  18. package/src/lib/server/email-suppression.js +4 -3
  19. package/src/lib/server/email-suppression.js.map +1 -1
  20. package/src/lib/server/erase-person.js +31 -3
  21. package/src/lib/server/erase-person.js.map +1 -1
  22. package/src/lib/server/host-visitor-records.d.ts +16 -2
  23. package/src/lib/server/host-visitor-records.js +31 -6
  24. package/src/lib/server/host-visitor-records.js.map +1 -1
  25. package/src/lib/server/hosted-page-review.d.ts +112 -0
  26. package/src/lib/server/hosted-page-review.js +378 -0
  27. package/src/lib/server/hosted-page-review.js.map +1 -0
  28. package/src/lib/server/member-email-aliases.js +18 -7
  29. package/src/lib/server/member-email-aliases.js.map +1 -1
  30. package/src/lib/server/notifications.d.ts +8 -0
  31. package/src/lib/server/notifications.js +92 -17
  32. package/src/lib/server/notifications.js.map +1 -1
  33. package/src/lib/server/org-age.d.ts +27 -0
  34. package/src/lib/server/org-age.js +44 -0
  35. package/src/lib/server/org-age.js.map +1 -0
  36. package/src/lib/server/org-member-notice.js +20 -5
  37. package/src/lib/server/org-member-notice.js.map +1 -1
  38. package/src/lib/server/organizations.d.ts +10 -1
  39. package/src/lib/server/organizations.js +12 -4
  40. package/src/lib/server/organizations.js.map +1 -1
  41. package/src/lib/server/outbound-send-review.d.ts +224 -0
  42. package/src/lib/server/outbound-send-review.js +479 -0
  43. package/src/lib/server/outbound-send-review.js.map +1 -0
  44. package/src/lib/server/payment-fraud-signal.d.ts +156 -0
  45. package/src/lib/server/payment-fraud-signal.js +368 -0
  46. package/src/lib/server/payment-fraud-signal.js.map +1 -0
  47. package/src/lib/server/payment-risk-record.d.ts +59 -0
  48. package/src/lib/server/payment-risk-record.js +76 -0
  49. package/src/lib/server/payment-risk-record.js.map +1 -0
  50. package/src/lib/server/render-system-email.d.ts +176 -0
  51. package/src/lib/server/render-system-email.js +315 -0
  52. package/src/lib/server/render-system-email.js.map +1 -0
  53. package/src/lib/server/sending-domains.d.ts +6 -0
  54. package/src/lib/server/sending-domains.js +98 -6
  55. package/src/lib/server/sending-domains.js.map +1 -1
  56. package/src/lib/server/staff-alert-email.d.ts +2 -1
  57. package/src/lib/server/staff-alert-email.js +16 -5
  58. package/src/lib/server/staff-alert-email.js.map +1 -1
  59. package/src/lib/server/stripe-card-authentication.d.ts +35 -0
  60. package/src/lib/server/stripe-card-authentication.js +63 -0
  61. package/src/lib/server/stripe-card-authentication.js.map +1 -0
  62. package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
  63. package/src/lib/server/tenant-write-lockdown.js +33 -3
  64. package/src/lib/server/tenant-write-lockdown.js.map +1 -1
  65. package/src/tenancy.d.ts +5 -0
  66. package/src/tenancy.js +10 -0
  67. package/src/tenancy.js.map +1 -1
@@ -0,0 +1,59 @@
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
+ import { type PaymentRiskSignal } from '@aglyn/aglyn/app-utils/payment-risk';
18
+ import type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications';
19
+ /** The notifier's shape: `notifyHostManagers` from the admin barrel. */
20
+ export type PaymentRiskNotifier = (hostId: string, payload: {
21
+ type: AglynNotificationType;
22
+ title: string;
23
+ body: string;
24
+ link: string;
25
+ }) => Promise<void>;
26
+ export interface RecordPaymentRiskInput {
27
+ ref: FirebaseFirestore.DocumentReference;
28
+ signal: PaymentRiskSignal;
29
+ /** The site whose managers are told. Empty tells nobody. */
30
+ hostId: string;
31
+ /** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */
32
+ subjectLabel: string;
33
+ /** The console path the notification opens. */
34
+ link: string;
35
+ /** The plugin's own notification type: `content.order`, `content.booking`. */
36
+ notificationType: AglynNotificationType;
37
+ /**
38
+ * More fields to write in the same transaction, from the record as read —
39
+ * a plugin's own activity line (an order's timeline) rides here.
40
+ */
41
+ extraUpdate?: (data: Record<string, unknown>) => Record<string, unknown>;
42
+ }
43
+ /**
44
+ * Stamp `signal` on `input.ref` and notify, once. Returns true when this
45
+ * delivery wrote it; false for a redelivery or a record that is gone.
46
+ * Throws only when the write throws, which the webhook answers with a 500 and
47
+ * Stripe's redelivery.
48
+ */
49
+ export declare function recordPaymentRiskOnRecord(input: RecordPaymentRiskInput, deps: {
50
+ firestore: FirebaseFirestore.Firestore;
51
+ notify: PaymentRiskNotifier;
52
+ }): Promise<boolean>;
53
+ /**
54
+ * Record a dispute's outcome on the signal already stamped for it. Silent:
55
+ * the plugin that owns money movement on a lost dispute says so itself.
56
+ */
57
+ export declare function settlePaymentRiskDisputeOnRecord(ref: FirebaseFirestore.DocumentReference, disputeId: string, outcome: string, deps: {
58
+ firestore: FirebaseFirestore.Firestore;
59
+ }): Promise<boolean>;
@@ -0,0 +1,76 @@
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
+ * STAMP A STRIPE FRAUD SIGNAL ON A MERCHANT'S RECORD (AGL-3360).
19
+ *
20
+ * The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that
21
+ * finds its own record behind a charge — an order, a booking — hands the
22
+ * document here, and this writes the `paymentRisk` field once per signal and
23
+ * tells the site's managers once. Any plugin that sells through Stripe uses
24
+ * the same two calls; nothing about the record's kind is known here.
25
+ *
26
+ * NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own
27
+ * customer; the notification says so.
28
+ *
29
+ * Dependencies are passed in — the Firestore handle and the notifier — so a
30
+ * plugin's spec that substitutes the admin barrel substitutes these too.
31
+ *=========================================*/ import { addPaymentRiskSignal, NOTHING_MOVED, PAYMENT_RISK_FIELD, paymentRiskAdvice, paymentRiskTitle, settlePaymentRiskDispute } from "@aglyn/aglyn/app-utils/payment-risk";
32
+ /**
33
+ * Stamp `signal` on `input.ref` and notify, once. Returns true when this
34
+ * delivery wrote it; false for a redelivery or a record that is gone.
35
+ * Throws only when the write throws, which the webhook answers with a 500 and
36
+ * Stripe's redelivery.
37
+ */ export async function recordPaymentRiskOnRecord(input, deps) {
38
+ const written = await deps.firestore.runTransaction(async (transaction)=>{
39
+ var _fresh_data;
40
+ const fresh = await transaction.get(input.ref);
41
+ if (!fresh.exists) return false;
42
+ const data = (_fresh_data = fresh.data()) != null ? _fresh_data : {};
43
+ const next = addPaymentRiskSignal(data[PAYMENT_RISK_FIELD], input.signal);
44
+ if (!next) return false;
45
+ transaction.update(input.ref, _extends({}, input.extraUpdate ? input.extraUpdate(data) : {}, {
46
+ [PAYMENT_RISK_FIELD]: next
47
+ }));
48
+ return true;
49
+ });
50
+ if (written && input.hostId) {
51
+ await deps.notify(input.hostId, {
52
+ type: input.notificationType,
53
+ title: paymentRiskTitle(input.signal.kind),
54
+ body: `${input.subjectLabel}. ${paymentRiskAdvice(input.signal.kind)}` + (input.signal.detail ? ` Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.` : '') + ` ${NOTHING_MOVED}`,
55
+ link: input.link
56
+ }).catch(()=>undefined);
57
+ }
58
+ return written;
59
+ }
60
+ /**
61
+ * Record a dispute's outcome on the signal already stamped for it. Silent:
62
+ * the plugin that owns money movement on a lost dispute says so itself.
63
+ */ export async function settlePaymentRiskDisputeOnRecord(ref, disputeId, outcome, deps) {
64
+ return deps.firestore.runTransaction(async (transaction)=>{
65
+ const fresh = await transaction.get(ref);
66
+ if (!fresh.exists) return false;
67
+ const next = settlePaymentRiskDispute(fresh.get(PAYMENT_RISK_FIELD), disputeId, outcome);
68
+ if (!next) return false;
69
+ transaction.update(ref, {
70
+ [PAYMENT_RISK_FIELD]: next
71
+ });
72
+ return true;
73
+ });
74
+ }
75
+
76
+ //# sourceMappingURL=payment-risk-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/payment-risk-record.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 * STAMP A STRIPE FRAUD SIGNAL ON A MERCHANT'S RECORD (AGL-3360).\n *\n * The server half of `@aglyn/aglyn/app-utils/payment-risk`. A plugin that\n * finds its own record behind a charge — an order, a booking — hands the\n * document here, and this writes the `paymentRisk` field once per signal and\n * tells the site's managers once. Any plugin that sells through Stripe uses\n * the same two calls; nothing about the record's kind is known here.\n *\n * NOTHING IS REFUNDED OR CANCELED. The merchant decides for their own\n * customer; the notification says so.\n *\n * Dependencies are passed in — the Firestore handle and the notifier — so a\n * plugin's spec that substitutes the admin barrel substitutes these too.\n *=========================================*/\n\nimport {\n addPaymentRiskSignal,\n NOTHING_MOVED,\n PAYMENT_RISK_FIELD,\n type PaymentRisk,\n type PaymentRiskSignal,\n paymentRiskAdvice,\n paymentRiskTitle,\n settlePaymentRiskDispute,\n} from '@aglyn/aglyn/app-utils/payment-risk'\nimport type { AglynNotificationType } from '@aglyn/aglyn/app-utils/notifications'\n\n/** The notifier's shape: `notifyHostManagers` from the admin barrel. */\nexport type PaymentRiskNotifier = (\n hostId: string,\n payload: {\n type: AglynNotificationType\n title: string\n body: string\n link: string\n },\n) => Promise<void>\n\nexport interface RecordPaymentRiskInput {\n ref: FirebaseFirestore.DocumentReference\n signal: PaymentRiskSignal\n /** The site whose managers are told. Empty tells nobody. */\n hostId: string\n /** What the record is, as the merchant calls it: `Order 1042`, `Booking`. */\n subjectLabel: string\n /** The console path the notification opens. */\n link: string\n /** The plugin's own notification type: `content.order`, `content.booking`. */\n notificationType: AglynNotificationType\n /**\n * More fields to write in the same transaction, from the record as read —\n * a plugin's own activity line (an order's timeline) rides here.\n */\n extraUpdate?: (data: Record<string, unknown>) => Record<string, unknown>\n}\n\n/**\n * Stamp `signal` on `input.ref` and notify, once. Returns true when this\n * delivery wrote it; false for a redelivery or a record that is gone.\n * Throws only when the write throws, which the webhook answers with a 500 and\n * Stripe's redelivery.\n */\nexport async function recordPaymentRiskOnRecord(\n input: RecordPaymentRiskInput,\n deps: {\n firestore: FirebaseFirestore.Firestore\n notify: PaymentRiskNotifier\n },\n): Promise<boolean> {\n const written = await deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(input.ref)\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const next = addPaymentRiskSignal(\n data[PAYMENT_RISK_FIELD] as PaymentRisk | undefined,\n input.signal,\n )\n if (!next) return false\n transaction.update(input.ref, {\n ...(input.extraUpdate ? input.extraUpdate(data) : {}),\n [PAYMENT_RISK_FIELD]: next,\n })\n return true\n })\n if (written && input.hostId) {\n await deps\n .notify(input.hostId, {\n type: input.notificationType,\n title: paymentRiskTitle(input.signal.kind),\n body:\n `${input.subjectLabel}. ${paymentRiskAdvice(input.signal.kind)}` +\n (input.signal.detail\n ? ` Stripe says: ${input.signal.detail.replace(/_/g, ' ')}.`\n : '') +\n ` ${NOTHING_MOVED}`,\n link: input.link,\n })\n .catch(() => undefined)\n }\n return written\n}\n\n/**\n * Record a dispute's outcome on the signal already stamped for it. Silent:\n * the plugin that owns money movement on a lost dispute says so itself.\n */\nexport async function settlePaymentRiskDisputeOnRecord(\n ref: FirebaseFirestore.DocumentReference,\n disputeId: string,\n outcome: string,\n deps: { firestore: FirebaseFirestore.Firestore },\n): Promise<boolean> {\n return deps.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(ref)\n if (!fresh.exists) return false\n const next = settlePaymentRiskDispute(\n fresh.get(PAYMENT_RISK_FIELD) as PaymentRisk | undefined,\n disputeId,\n outcome,\n )\n if (!next) return false\n transaction.update(ref, { [PAYMENT_RISK_FIELD]: next })\n return true\n })\n}\n"],"names":["addPaymentRiskSignal","NOTHING_MOVED","PAYMENT_RISK_FIELD","paymentRiskAdvice","paymentRiskTitle","settlePaymentRiskDispute","recordPaymentRiskOnRecord","input","deps","written","firestore","runTransaction","transaction","fresh","get","ref","exists","data","next","signal","update","extraUpdate","hostId","notify","type","notificationType","title","kind","body","subjectLabel","detail","replace","link","catch","undefined","settlePaymentRiskDisputeOnRecord","disputeId","outcome"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;2CAc2C,GAE3C,SACEA,oBAAoB,EACpBC,aAAa,EACbC,kBAAkB,EAGlBC,iBAAiB,EACjBC,gBAAgB,EAChBC,wBAAwB,QACnB,sCAAqC;AAgC5C;;;;;CAKC,GACD,OAAO,eAAeC,0BACpBC,KAA6B,EAC7BC,IAGC;IAED,MAAMC,UAAU,MAAMD,KAAKE,SAAS,CAACC,cAAc,CAAC,OAAOC;YAG3CC;QAFd,MAAMA,QAAQ,MAAMD,YAAYE,GAAG,CAACP,MAAMQ,GAAG;QAC7C,IAAI,CAACF,MAAMG,MAAM,EAAE,OAAO;QAC1B,MAAMC,QAAQJ,cAAAA,MAAMI,IAAI,cAAVJ,cAAgB,CAAC;QAC/B,MAAMK,OAAOlB,qBACXiB,IAAI,CAACf,mBAAmB,EACxBK,MAAMY,MAAM;QAEd,IAAI,CAACD,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACb,MAAMQ,GAAG,EAAE,aACxBR,MAAMc,WAAW,GAAGd,MAAMc,WAAW,CAACJ,QAAQ,CAAC;YACnD,CAACf,mBAAmB,EAAEgB;;QAExB,OAAO;IACT;IACA,IAAIT,WAAWF,MAAMe,MAAM,EAAE;QAC3B,MAAMd,KACHe,MAAM,CAAChB,MAAMe,MAAM,EAAE;YACpBE,MAAMjB,MAAMkB,gBAAgB;YAC5BC,OAAOtB,iBAAiBG,MAAMY,MAAM,CAACQ,IAAI;YACzCC,MACE,GAAGrB,MAAMsB,YAAY,CAAC,EAAE,EAAE1B,kBAAkBI,MAAMY,MAAM,CAACQ,IAAI,GAAG,GAC/DpB,CAAAA,MAAMY,MAAM,CAACW,MAAM,GAChB,CAAC,cAAc,EAAEvB,MAAMY,MAAM,CAACW,MAAM,CAACC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,GAC1D,EAAC,IACL,CAAC,CAAC,EAAE9B,eAAe;YACrB+B,MAAMzB,MAAMyB,IAAI;QAClB,GACCC,KAAK,CAAC,IAAMC;IACjB;IACA,OAAOzB;AACT;AAEA;;;CAGC,GACD,OAAO,eAAe0B,iCACpBpB,GAAwC,EACxCqB,SAAiB,EACjBC,OAAe,EACf7B,IAAgD;IAEhD,OAAOA,KAAKE,SAAS,CAACC,cAAc,CAAC,OAAOC;QAC1C,MAAMC,QAAQ,MAAMD,YAAYE,GAAG,CAACC;QACpC,IAAI,CAACF,MAAMG,MAAM,EAAE,OAAO;QAC1B,MAAME,OAAOb,yBACXQ,MAAMC,GAAG,CAACZ,qBACVkC,WACAC;QAEF,IAAI,CAACnB,MAAM,OAAO;QAClBN,YAAYQ,MAAM,CAACL,KAAK;YAAE,CAACb,mBAAmB,EAAEgB;QAAK;QACrD,OAAO;IACT;AACF"}
@@ -0,0 +1,176 @@
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
+ import { type SystemEmailTemplateDefinition } from '@aglyn/shared-util-email';
18
+ import { type ReusableComponentTree } from '@aglyn/aglyn/server';
19
+ import { isPlatformBrandedSend, type SystemEmailBrandOptions } from '@aglyn/aglyn/app-utils/system-email-chrome';
20
+ export { isPlatformBrandedSend };
21
+ export type { SystemEmailBrandOptions };
22
+ /**
23
+ * Which copy a system email carries: a design staff published, or the
24
+ * catalog's built-in copy, which is what every key without a usable design
25
+ * sends (AGL-3322).
26
+ */
27
+ export type SystemEmailSource = 'designed' | 'default';
28
+ export interface RenderedSystemEmail {
29
+ subject: string;
30
+ html: string;
31
+ text: string;
32
+ source: SystemEmailSource;
33
+ }
34
+ /**
35
+ * A system email loaded once, ready to render for any number of recipients
36
+ * without touching Firestore again (AGL-768): the published staff design, or
37
+ * the catalog's built-in copy when there is none.
38
+ */
39
+ export interface LoadedSystemEmail {
40
+ definition: SystemEmailTemplateDefinition;
41
+ /** The design as stored, each email block still a placement; or the built-in copy's node map. */
42
+ nodes: Record<string, unknown>;
43
+ subjectTemplate: string;
44
+ preheaderTemplate: string;
45
+ source: SystemEmailSource;
46
+ /**
47
+ * The published definitions of the email blocks `nodes` places, keyed by
48
+ * id, read from the platform marketing site ONCE with the template
49
+ * (AGL-3318), so a batch composes every recipient's copy without another
50
+ * read. Absent when the design places none, when the deployment names no
51
+ * marketing site, and when the read failed; each placement then renders as
52
+ * nothing.
53
+ */
54
+ components?: Record<string, ReusableComponentTree>;
55
+ }
56
+ /**
57
+ * Loads what a key sends — ONE Firestore read — ready to render per
58
+ * recipient: the published staff design, or the catalog's built-in copy
59
+ * (AGL-3322) when there is none to use (no document, no published version,
60
+ * an empty node map, or a read failure).
61
+ *
62
+ * `null` only for a key that is unknown or not Resend-delivered, which has
63
+ * nothing here to render. Split out from {@link renderSystemEmail} so a batch
64
+ * send resolves the template once and renders it per recipient, rather than
65
+ * one read each (AGL-768): a Firestore read per invite is fine, one per
66
+ * recipient in a usage-email batch is not.
67
+ *
68
+ * Reads through the Admin SDK, which bypasses the staff-only rules on the
69
+ * collection — the send path runs as the server, not as a signed-in user.
70
+ *
71
+ * A design that places email blocks costs one more read per block, from the
72
+ * platform marketing site, made here with the template so a batch pays it
73
+ * once too (AGL-3318). Whether a recipient's copy expands them is decided per
74
+ * send, by {@link renderLoadedSystemEmail}.
75
+ */
76
+ export declare function loadSystemEmail(templateKey: string): Promise<LoadedSystemEmail | null>;
77
+ /**
78
+ * Renders a loaded email for one recipient's merge values (AGL-768). No
79
+ * Firestore access — call {@link loadSystemEmail} once, then this per
80
+ * recipient. `null` only if the renderer produced no document at all.
81
+ *
82
+ * Which header and footer it wears is decided here, per send, because one
83
+ * batch-loaded design goes to recipients of different brands:
84
+ *
85
+ * - A send under the platform's own brand of a design whose email blocks the
86
+ * load read draws those blocks (AGL-3318), and nothing else: they ARE its
87
+ * header and footer, and a second one around them would draw both.
88
+ * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome
89
+ * (AGL-3322): the built-in copy, which places no blocks; a design sent to a
90
+ * white-label org's people, whose blocks stay unexpanded and draw nothing,
91
+ * since they are the platform's; and a design on a deployment that names no
92
+ * marketing site, or whose blocks could not be read. The chrome is in the
93
+ * brand of the send — the org's email logo rides in its header, so a
94
+ * white-label mail carries exactly one logo.
95
+ */
96
+ export declare function renderLoadedSystemEmail(loaded: LoadedSystemEmail, merge?: Record<string, string>, options?: SystemEmailBrandOptions): RenderedSystemEmail | null;
97
+ /**
98
+ * Renders the system email a key sends to one recipient (AGL-750): the
99
+ * published staff design, or the catalog's built-in copy (AGL-3322), in the
100
+ * header and footer of the brand it is sent under.
101
+ *
102
+ * **`null` only for a key that is unknown or not Resend-delivered**, which
103
+ * has nothing here to render. No template document, no published version, an
104
+ * empty node map and a Firestore failure all render the built-in copy — the
105
+ * same copy the editor seeds a first design with — so a missing, half-saved
106
+ * or broken template can never mean a customer receives no email, or a bare
107
+ * one. Every caller still keeps its own hard-coded copy behind this, as the
108
+ * last resort for a render that produced nothing.
109
+ *
110
+ * A single-recipient convenience over {@link loadSystemEmail} +
111
+ * {@link renderLoadedSystemEmail}; a batch should call those two directly so
112
+ * it reads the template once.
113
+ */
114
+ export declare function renderSystemEmail(templateKey: string, merge?: Record<string, string>, options?: SystemEmailBrandOptions): Promise<RenderedSystemEmail | null>;
115
+ /**
116
+ * The email a template sends right now, for the staff test-send endpoint
117
+ * (AGL-766): exactly what a real send of it renders — the published design,
118
+ * or the built-in copy, with the same header and footer — so a test shows
119
+ * what recipients receive. `null` for an unknown or non-Resend key.
120
+ */
121
+ export declare function renderEffectiveSystemEmail(templateKey: string, merge?: Record<string, string>, options?: SystemEmailBrandOptions): Promise<RenderedSystemEmail | null>;
122
+ /**
123
+ * The brand one system email is sent under (AGL-3367): the merge tokens, the
124
+ * chrome options and the sender name, from one place.
125
+ *
126
+ * `null` is the platform's own brand, and it is the answer for everything
127
+ * the platform says on its own behalf: a staff alert, a legal receipt, an
128
+ * enforcement notice. A workspace-context email passes the workspace's org
129
+ * document, and `resolveBrandingProfile` decides from there: a white-label
130
+ * org gets its own name, logo, home and support in the header and footer,
131
+ * and every other org resolves to the platform profile, so the same call is
132
+ * right for both.
133
+ */
134
+ export interface SystemEmailBrand {
135
+ merge: Record<string, string>;
136
+ options: SystemEmailBrandOptions;
137
+ /** The display name to send under; absent sends under the platform's. */
138
+ fromName?: string;
139
+ /** The org's own name, for the `{{org.name}}` its copy may use. */
140
+ orgName?: string;
141
+ }
142
+ /** See {@link SystemEmailBrand}. */
143
+ export declare function systemEmailBrand(org: Readonly<Record<string, unknown>> | null | undefined): SystemEmailBrand;
144
+ /**
145
+ * {@link systemEmailBrand} for an org read by id, for a sender that holds
146
+ * only the id. A failed or missing read is the platform's brand: the email
147
+ * still goes, and a white-label org's people see the platform's chrome on
148
+ * one message rather than no message.
149
+ */
150
+ export declare function orgSystemEmailBrand(orgId: string | null | undefined): Promise<SystemEmailBrand>;
151
+ /** What a send takes from a rendered system email. */
152
+ export interface SystemEmailContent {
153
+ subject: string;
154
+ text: string;
155
+ html?: string;
156
+ }
157
+ /**
158
+ * The subject, text and HTML to send, with the caller's own copy as the
159
+ * last resort for a render that produced nothing: the shape every sender of
160
+ * a system email repeats, written once.
161
+ */
162
+ export declare function systemEmailContent(rendered: RenderedSystemEmail | null, fallback: {
163
+ subject: string;
164
+ text: string;
165
+ }): SystemEmailContent;
166
+ /**
167
+ * Renders a key for one recipient in a brand and returns what to send
168
+ * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in
169
+ * one call, for the senders with one message to build. Never throws; a
170
+ * render that fails sends the fallback copy, which is what it sent before.
171
+ */
172
+ export declare function renderSystemEmailContent(templateKey: string, merge: Record<string, string>, brand: SystemEmailBrand, fallback: {
173
+ subject: string;
174
+ text: string;
175
+ }): Promise<SystemEmailContent>;
176
+ export default renderSystemEmail;
@@ -0,0 +1,315 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ var _process_env_NEXT_PUBLIC_CONSOLE_URL;
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ import { EMAIL_NODE_ROOT_ID, SYSTEM_EMAIL_COLLECTION, buildDefaultEmailNodeMap, getSystemEmailTemplate, renderEmailHtml, substituteMergeTokens } from "@aglyn/shared-util-email";
19
+ import { firebaseAdmin } from "./firebase-admin.js";
20
+ import { platformMarketingHostId } from "./platform-marketing-consent.js";
21
+ import { brandMergeTokens, collectReferencedComponentIds, composeReusableComponentNodes, decodeStoredNodes, resolveBrandingProfile, sanitizeAuthorHtml } from "@aglyn/aglyn/server";
22
+ import { hostComponentReader, loadReferencedComponents } from "@aglyn/aglyn/app-utils/load-referenced-components";
23
+ import { PLATFORM_BRAND_MERGE_TOKENS, buildSystemEmailChrome, isPlatformBrandedSend } from "@aglyn/aglyn/app-utils/system-email-chrome";
24
+ // Defined beside the chrome they decide, and re-exported for the callers and
25
+ // specs that reach them through this module.
26
+ export { isPlatformBrandedSend };
27
+ /**
28
+ * Blanks any `{{token}}` the caller did not supply.
29
+ *
30
+ * `substituteMergeTokens` leaves unknown tokens untouched, which is right for
31
+ * campaigns (an author wants to see the tag they mistyped) but wrong here —
32
+ * a system email goes to a customer, and "You've been invited to
33
+ * {{org.name}}" is worse than a gap. Applied only in this resolver so
34
+ * campaign behaviour is unchanged.
35
+ */ function blankUnresolvedTokens(value) {
36
+ return value.replace(/\{\{[^}]*\}\}/g, '');
37
+ }
38
+ /**
39
+ * Origin a picked image in a system email is fetched from (AGL-1224).
40
+ *
41
+ * A system email is staff-designed and its media lives in the platform
42
+ * library, which the CONSOLE's `/api/media/cdn` route serves — the tenant app
43
+ * mounts the same route, but a recipient of an org invite has no site to
44
+ * resolve against. Same expression `app/layout.tsx` uses for the console's
45
+ * own canonical URL, so the two cannot drift per deploy.
46
+ */ const CONSOLE_ORIGIN = (_process_env_NEXT_PUBLIC_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL) != null ? _process_env_NEXT_PUBLIC_CONSOLE_URL : 'https://app.aglyn.com';
47
+ /**
48
+ * The email blocks a platform email places, read from the site that owns
49
+ * them (AGL-3318), or `undefined` when there is nothing to read or the read
50
+ * failed.
51
+ *
52
+ * WHICH SITE. A block is a reusable component, and a component belongs to a
53
+ * site; a platform email belongs to none. The header and footer Aglyn's own
54
+ * mail wears are the ones on its marketing site, which the deployment names
55
+ * in `PLATFORM_MARKETING_HOST_ID`, the setting product-email consent already
56
+ * reads. An install that names none reads nothing, and a block placed in its
57
+ * system emails renders as nothing.
58
+ *
59
+ * NEVER A REASON NOT TO SEND. A site's own email fails its whole load when a
60
+ * component read fails, and falls back to its built-in copy. Here the copy is
61
+ * staff's design and the blocks are another site's chrome, so a failed read
62
+ * costs the blocks and nothing else: it is logged, and the design renders
63
+ * inside the coded header and footer instead (see
64
+ * {@link renderLoadedSystemEmail}) rather than being traded for the built-in
65
+ * copy. A password reset whose header could not be read still carries its
66
+ * link.
67
+ */ async function loadPlatformEmailBlocks(firestore, templateKey, nodes) {
68
+ // A design that places nothing costs nothing: no setting read, no read.
69
+ if (!collectReferencedComponentIds(nodes).size) return undefined;
70
+ const hostId = platformMarketingHostId();
71
+ if (!hostId) return undefined;
72
+ try {
73
+ return await loadReferencedComponents(nodes, hostComponentReader(firestore, hostId), {
74
+ onSkipped: (skipped)=>console.warn(JSON.stringify({
75
+ tag: 'AGL-3318:platform-email-blocks-skipped',
76
+ templateKey,
77
+ hostId,
78
+ skipped
79
+ }))
80
+ });
81
+ } catch (error) {
82
+ console.error(`system email template ${templateKey}: its email blocks failed to load from ${hostId}`, error);
83
+ return undefined;
84
+ }
85
+ }
86
+ /**
87
+ * Whether one send draws the design's email blocks: a send under the
88
+ * platform's own brand (see {@link isPlatformBrandedSend}) of a design whose
89
+ * blocks the load could read. Every other send renders the stored nodes, each
90
+ * block a placement that draws nothing, inside the coded chrome.
91
+ */ function drawsPlatformBlocks(loaded, merged, options) {
92
+ return Boolean(loaded.components && Object.keys(loaded.components).length) && isPlatformBrandedSend(merged, options);
93
+ }
94
+ /**
95
+ * A key's built-in copy as a loaded email (AGL-3322): the node map the editor
96
+ * seeds a first design with, and the catalog subject. What a key sends until
97
+ * staff publish a design, and whenever the published one cannot be read.
98
+ */ function catalogDefault(definition) {
99
+ return {
100
+ definition,
101
+ nodes: buildDefaultEmailNodeMap(definition),
102
+ subjectTemplate: definition.defaultSubject,
103
+ preheaderTemplate: '',
104
+ source: 'default'
105
+ };
106
+ }
107
+ /**
108
+ * Loads what a key sends — ONE Firestore read — ready to render per
109
+ * recipient: the published staff design, or the catalog's built-in copy
110
+ * (AGL-3322) when there is none to use (no document, no published version,
111
+ * an empty node map, or a read failure).
112
+ *
113
+ * `null` only for a key that is unknown or not Resend-delivered, which has
114
+ * nothing here to render. Split out from {@link renderSystemEmail} so a batch
115
+ * send resolves the template once and renders it per recipient, rather than
116
+ * one read each (AGL-768): a Firestore read per invite is fine, one per
117
+ * recipient in a usage-email batch is not.
118
+ *
119
+ * Reads through the Admin SDK, which bypasses the staff-only rules on the
120
+ * collection — the send path runs as the server, not as a signed-in user.
121
+ *
122
+ * A design that places email blocks costs one more read per block, from the
123
+ * platform marketing site, made here with the template so a batch pays it
124
+ * once too (AGL-3318). Whether a recipient's copy expands them is decided per
125
+ * send, by {@link renderLoadedSystemEmail}.
126
+ */ export async function loadSystemEmail(templateKey) {
127
+ const definition = getSystemEmailTemplate(templateKey);
128
+ if (!definition) return null;
129
+ // A Firebase- or Stripe-delivered email is sent by that service from its
130
+ // own templates, so rendering one here would produce output nothing ever
131
+ // sends (AGL-767).
132
+ if (definition.deliveredBy !== 'resend') return null;
133
+ try {
134
+ var _templateSnapshot_get, _templateSnapshot_get1;
135
+ const firestore = firebaseAdmin.app().firestore();
136
+ const templateRef = firestore.collection(SYSTEM_EMAIL_COLLECTION).doc(templateKey);
137
+ const templateSnapshot = await templateRef.get();
138
+ if (!templateSnapshot.exists) return catalogDefault(definition);
139
+ const versionId = templateSnapshot.get('versionId');
140
+ // Cleared by "reset to default", which nulls the pointer rather than
141
+ // deleting the document so the audit trail survives.
142
+ if (!versionId) return catalogDefault(definition);
143
+ const versionSnapshot = await templateRef.collection('versions').doc(String(versionId)).get();
144
+ // BOTH stored forms (AGL-1223). The email besigner compresses like every
145
+ // other besigner document, and versions written before it did are still
146
+ // plain maps — `decodeStoredNodes` returns those unchanged.
147
+ //
148
+ // Reading raw is the failure the guard below cannot catch: over a
149
+ // `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test
150
+ // passes and a staff-authored email renders empty rather than falling
151
+ // back to its built-in copy.
152
+ const nodes = decodeStoredNodes(versionSnapshot.get('nodes'));
153
+ if (!nodes || !Object.keys(nodes).length) return catalogDefault(definition);
154
+ const components = await loadPlatformEmailBlocks(firestore, templateKey, nodes);
155
+ return _extends({
156
+ definition,
157
+ nodes,
158
+ subjectTemplate: String((_templateSnapshot_get = templateSnapshot.get('subject')) != null ? _templateSnapshot_get : '') || definition.defaultSubject,
159
+ preheaderTemplate: String((_templateSnapshot_get1 = templateSnapshot.get('preheader')) != null ? _templateSnapshot_get1 : ''),
160
+ source: 'designed'
161
+ }, components && {
162
+ components
163
+ });
164
+ } catch (error) {
165
+ // Never let a template problem block the send, or strip it bare: the
166
+ // recipient gets the built-in copy, in the same header and footer.
167
+ console.error(`system email template ${templateKey} failed to load; sending its built-in copy`, error);
168
+ return catalogDefault(definition);
169
+ }
170
+ }
171
+ /**
172
+ * Renders a loaded email for one recipient's merge values (AGL-768). No
173
+ * Firestore access — call {@link loadSystemEmail} once, then this per
174
+ * recipient. `null` only if the renderer produced no document at all.
175
+ *
176
+ * Which header and footer it wears is decided here, per send, because one
177
+ * batch-loaded design goes to recipients of different brands:
178
+ *
179
+ * - A send under the platform's own brand of a design whose email blocks the
180
+ * load read draws those blocks (AGL-3318), and nothing else: they ARE its
181
+ * header and footer, and a second one around them would draw both.
182
+ * - Every other send is drawn inside {@link buildSystemEmailChrome}'s chrome
183
+ * (AGL-3322): the built-in copy, which places no blocks; a design sent to a
184
+ * white-label org's people, whose blocks stay unexpanded and draw nothing,
185
+ * since they are the platform's; and a design on a deployment that names no
186
+ * marketing site, or whose blocks could not be read. The chrome is in the
187
+ * brand of the send — the org's email logo rides in its header, so a
188
+ * white-label mail carries exactly one logo.
189
+ */ export function renderLoadedSystemEmail(loaded, merge = {}, options = {}) {
190
+ var _rendered_text;
191
+ const merged = _extends({}, PLATFORM_BRAND_MERGE_TOKENS, merge);
192
+ const blocks = drawsPlatformBlocks(loaded, merged, options);
193
+ const rendered = renderEmailHtml(_extends({
194
+ nodes: blocks ? composeReusableComponentNodes(loaded.nodes, loaded.components) : loaded.nodes,
195
+ // Besigner maps are rooted at '_@_', not renderEmailHtml's default
196
+ // 'root' — without this a designed template rendered empty and the send
197
+ // fell back to built-in copy, so designing did nothing (AGL-765).
198
+ rootId: EMAIL_NODE_ROOT_ID,
199
+ subject: substituteMergeTokens(loaded.subjectTemplate, merged),
200
+ preheader: substituteMergeTokens(loaded.preheaderTemplate, merged),
201
+ merge: merged,
202
+ // The same policy the console's own preview of these nodes applies —
203
+ // `sanitizeCustomHtml` in the email plugin delegates to this exact
204
+ // function, so what staff review and what the recipient receives are one
205
+ // function of one string rather than two that have to be kept in step.
206
+ sanitize: sanitizeAuthorHtml,
207
+ // Without this a staff-picked image resolves to a site-relative CDN path,
208
+ // which is a broken-image box in the recipient's inbox (AGL-1224). The
209
+ // chrome's logo resolves against it too.
210
+ mediaOrigin: CONSOLE_ORIGIN
211
+ }, blocks ? {} : {
212
+ chrome: buildSystemEmailChrome({
213
+ definition: loaded.definition,
214
+ merged,
215
+ brandLogoUrl: options.brandLogoUrl,
216
+ brandHomeUrl: options.brandHomeUrl
217
+ })
218
+ }));
219
+ if (!(rendered == null ? void 0 : rendered.html)) return null;
220
+ return {
221
+ subject: blankUnresolvedTokens(substituteMergeTokens(loaded.subjectTemplate, merged)),
222
+ html: blankUnresolvedTokens(rendered.html),
223
+ text: blankUnresolvedTokens((_rendered_text = rendered.text) != null ? _rendered_text : ''),
224
+ source: loaded.source
225
+ };
226
+ }
227
+ /**
228
+ * Renders the system email a key sends to one recipient (AGL-750): the
229
+ * published staff design, or the catalog's built-in copy (AGL-3322), in the
230
+ * header and footer of the brand it is sent under.
231
+ *
232
+ * **`null` only for a key that is unknown or not Resend-delivered**, which
233
+ * has nothing here to render. No template document, no published version, an
234
+ * empty node map and a Firestore failure all render the built-in copy — the
235
+ * same copy the editor seeds a first design with — so a missing, half-saved
236
+ * or broken template can never mean a customer receives no email, or a bare
237
+ * one. Every caller still keeps its own hard-coded copy behind this, as the
238
+ * last resort for a render that produced nothing.
239
+ *
240
+ * A single-recipient convenience over {@link loadSystemEmail} +
241
+ * {@link renderLoadedSystemEmail}; a batch should call those two directly so
242
+ * it reads the template once.
243
+ */ export async function renderSystemEmail(templateKey, merge = {}, options = {}) {
244
+ const loaded = await loadSystemEmail(templateKey);
245
+ return loaded ? renderLoadedSystemEmail(loaded, merge, options) : null;
246
+ }
247
+ /**
248
+ * The email a template sends right now, for the staff test-send endpoint
249
+ * (AGL-766): exactly what a real send of it renders — the published design,
250
+ * or the built-in copy, with the same header and footer — so a test shows
251
+ * what recipients receive. `null` for an unknown or non-Resend key.
252
+ */ export async function renderEffectiveSystemEmail(templateKey, merge = {}, options = {}) {
253
+ return renderSystemEmail(templateKey, merge, options);
254
+ }
255
+ /** See {@link SystemEmailBrand}. */ export function systemEmailBrand(org) {
256
+ if (!org) return {
257
+ merge: {},
258
+ options: {}
259
+ };
260
+ const branding = resolveBrandingProfile(org);
261
+ const orgName = typeof org['name'] === 'string' ? org['name'].trim() : '';
262
+ return _extends({}, orgName ? {
263
+ orgName
264
+ } : {}, {
265
+ merge: brandMergeTokens(branding),
266
+ options: {
267
+ brandLogoUrl: branding.emailLogoUrl,
268
+ brandHomeUrl: branding.homeUrl
269
+ },
270
+ fromName: branding.fromName
271
+ });
272
+ }
273
+ /**
274
+ * {@link systemEmailBrand} for an org read by id, for a sender that holds
275
+ * only the id. A failed or missing read is the platform's brand: the email
276
+ * still goes, and a white-label org's people see the platform's chrome on
277
+ * one message rather than no message.
278
+ */ export async function orgSystemEmailBrand(orgId) {
279
+ if (!orgId) return systemEmailBrand(null);
280
+ try {
281
+ var _snapshot_data;
282
+ const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get();
283
+ return systemEmailBrand(snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : null : null);
284
+ } catch (error) {
285
+ console.error(`system email brand for org ${orgId} failed to load`, error);
286
+ return systemEmailBrand(null);
287
+ }
288
+ }
289
+ /**
290
+ * The subject, text and HTML to send, with the caller's own copy as the
291
+ * last resort for a render that produced nothing: the shape every sender of
292
+ * a system email repeats, written once.
293
+ */ export function systemEmailContent(rendered, fallback) {
294
+ return _extends({
295
+ subject: (rendered == null ? void 0 : rendered.subject) || fallback.subject,
296
+ text: (rendered == null ? void 0 : rendered.text) || fallback.text
297
+ }, (rendered == null ? void 0 : rendered.html) ? {
298
+ html: rendered.html
299
+ } : {});
300
+ }
301
+ /**
302
+ * Renders a key for one recipient in a brand and returns what to send
303
+ * (AGL-3367): {@link renderSystemEmail} and {@link systemEmailContent} in
304
+ * one call, for the senders with one message to build. Never throws; a
305
+ * render that fails sends the fallback copy, which is what it sent before.
306
+ */ export async function renderSystemEmailContent(templateKey, merge, brand, fallback) {
307
+ const rendered = await renderSystemEmail(templateKey, _extends({}, brand.merge, merge), brand.options).catch((error)=>{
308
+ console.error(`system email ${templateKey} failed to render`, error);
309
+ return null;
310
+ });
311
+ return systemEmailContent(rendered, fallback);
312
+ }
313
+ export default renderSystemEmail;
314
+
315
+ //# sourceMappingURL=render-system-email.js.map