@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.205

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 (54) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/capture-email-check.d.ts +109 -0
  3. package/src/lib/server/capture-email-check.js +362 -0
  4. package/src/lib/server/capture-email-check.js.map +1 -0
  5. package/src/lib/server/card-payment-velocity.d.ts +11 -16
  6. package/src/lib/server/card-payment-velocity.js +24 -28
  7. package/src/lib/server/card-payment-velocity.js.map +1 -1
  8. package/src/lib/server/email-deliverability.d.ts +19 -3
  9. package/src/lib/server/email-deliverability.js +34 -16
  10. package/src/lib/server/email-deliverability.js.map +1 -1
  11. package/src/lib/server/held-page-subject.d.ts +93 -0
  12. package/src/lib/server/held-page-subject.js +206 -0
  13. package/src/lib/server/held-page-subject.js.map +1 -0
  14. package/src/lib/server/host-email-tokens.d.ts +42 -1
  15. package/src/lib/server/host-email-tokens.js +100 -12
  16. package/src/lib/server/host-email-tokens.js.map +1 -1
  17. package/src/lib/server/host-visitor-records.js +21 -5
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/hosted-page-review.d.ts +16 -5
  20. package/src/lib/server/hosted-page-review.js +242 -61
  21. package/src/lib/server/hosted-page-review.js.map +1 -1
  22. package/src/lib/server/list-members.js +16 -0
  23. package/src/lib/server/list-members.js.map +1 -1
  24. package/src/lib/server/notifications.d.ts +7 -0
  25. package/src/lib/server/notifications.js +27 -4
  26. package/src/lib/server/notifications.js.map +1 -1
  27. package/src/lib/server/outbound-send-review.d.ts +22 -0
  28. package/src/lib/server/outbound-send-review.js +74 -6
  29. package/src/lib/server/outbound-send-review.js.map +1 -1
  30. package/src/lib/server/payment-fraud-signal.d.ts +20 -12
  31. package/src/lib/server/payment-fraud-signal.js +41 -11
  32. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  33. package/src/lib/server/payment-risk-record.d.ts +37 -13
  34. package/src/lib/server/payment-risk-record.js +23 -8
  35. package/src/lib/server/payment-risk-record.js.map +1 -1
  36. package/src/lib/server/render-system-email.d.ts +3 -0
  37. package/src/lib/server/render-system-email.js +39 -11
  38. package/src/lib/server/render-system-email.js.map +1 -1
  39. package/src/lib/server/risk-notice.d.ts +327 -0
  40. package/src/lib/server/risk-notice.js +1240 -0
  41. package/src/lib/server/risk-notice.js.map +1 -0
  42. package/src/lib/server/sender-readiness.d.ts +36 -0
  43. package/src/lib/server/sender-readiness.js +99 -0
  44. package/src/lib/server/sender-readiness.js.map +1 -0
  45. package/src/lib/server/staff-alert-email.d.ts +29 -15
  46. package/src/lib/server/staff-alert-email.js +131 -28
  47. package/src/lib/server/staff-alert-email.js.map +1 -1
  48. package/src/lib/server/test-firestore.js +1 -0
  49. package/src/lib/server/test-firestore.js.map +1 -1
  50. package/src/lib/server/upsert-contact.js +19 -3
  51. package/src/lib/server/upsert-contact.js.map +1 -1
  52. package/src/tenancy.d.ts +2 -0
  53. package/src/tenancy.js +3 -0
  54. package/src/tenancy.js.map +1 -1
@@ -16,33 +16,113 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { sendEmail } from "@aglyn/shared-util-email";
18
18
  /**
19
- * A staff alert by email, from LIBRARY code (AGL-2925).
19
+ * Staff alerts by email, from LIBRARY code (AGL-2925).
20
20
  *
21
- * `STAFF_ALERT_EMAIL` is the one inbox every subsystem's alert lands in —
22
- * the console's `emailStaffAlert` in `apps/console/app/api/_lib/
23
- * usage-alert-email.ts` sends the cron's alerts there. A library cannot
24
- * call that, because a library cannot import from an app. So a library's
25
- * own alert — the AI meter's platform-wide free-spend ceiling is one —
26
- * sends through this, to the same address, with the same posture: unset is
27
- * the ordinary answer outside production and reports `unconfigured`, and
28
- * nothing here throws, because the mail is a courtesy and the refusal it
29
- * announces is the control.
21
+ * One destination for every subsystem's alarm — the console's
22
+ * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends
23
+ * through here too — resolved by {@link resolveStaffAlertRecipients}
24
+ * (AGL-3375). Nothing here throws, because the mail is a courtesy and the
25
+ * refusal it announces is the control; with no address anywhere the answer
26
+ * is `unconfigured`.
30
27
  *
31
28
  * Platform email metering is loaded lazily and only after a send, so that
32
29
  * importing this module — which every unit test of the meter does — never
33
30
  * pulls the Firestore-backed meter, and with it the admin app, into a test
34
- * that only wanted to count credits. The system-email renderer, which reads
35
- * its template through the same admin app, is loaded the same way.
36
- */ export async function sendStaffAlertEmail(input) {
37
- var _process_env_STAFF_ALERT_EMAIL;
38
- const to = String((_process_env_STAFF_ALERT_EMAIL = process.env.STAFF_ALERT_EMAIL) != null ? _process_env_STAFF_ALERT_EMAIL : '').trim();
39
- if (!to.includes('@')) return {
31
+ * that only wanted to count credits. The system-email renderer and the staff
32
+ * roster, which read through the same admin app, are loaded the same way.
33
+ */ /** At most this many staff accounts are mailed when no inbox is configured. */ const STAFF_FALLBACK_MAX = 10;
34
+ /**
35
+ * Where a staff alarm goes (AGL-3375), first answer wins:
36
+ *
37
+ * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.
38
+ * 2. The operator's published support address,
39
+ * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is
40
+ * asked for and most set.
41
+ * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.
42
+ *
43
+ * Only the first applies on a preview or development deployment.
44
+ *
45
+ * The chain exists because the alarms that matter most are the ones an
46
+ * install nobody tuned would otherwise send to no one: card testing on its
47
+ * checkout, a stolen card on its first subscription, a phishing campaign
48
+ * held on its sending domain. An unset variable used to mean silence.
49
+ *
50
+ * Empty only when none of the three yields an address.
51
+ */ export async function resolveStaffAlertRecipients() {
52
+ var _process_env_STAFF_ALERT_EMAIL, _process_env_VERCEL_ENV, _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL;
53
+ const configured = String((_process_env_STAFF_ALERT_EMAIL = process.env.STAFF_ALERT_EMAIL) != null ? _process_env_STAFF_ALERT_EMAIL : '').trim().toLowerCase();
54
+ if (configured.includes('@')) return [
55
+ configured
56
+ ];
57
+ // A preview or development deployment says what it is; only a production
58
+ // one, or a self-hosted install that sets no such thing, falls back. A
59
+ // preview's alarms reaching the operator's real inbox would be noise.
60
+ const deployment = String((_process_env_VERCEL_ENV = process.env.VERCEL_ENV) != null ? _process_env_VERCEL_ENV : '').trim();
61
+ if (deployment && deployment !== 'production') return [];
62
+ const operator = String((_process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL = process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL) != null ? _process_env_NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL : '').trim().toLowerCase();
63
+ if (operator.includes('@')) return [
64
+ operator
65
+ ];
66
+ try {
67
+ const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import("./auth-pools.js");
68
+ const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX);
69
+ const addresses = new Set();
70
+ for (const uid of uids){
71
+ var _ref;
72
+ var _pooled_record;
73
+ const pooled = await findUserByUidAcrossPools(uid).catch(()=>null);
74
+ const address = String((_ref = pooled == null ? void 0 : (_pooled_record = pooled.record) == null ? void 0 : _pooled_record.email) != null ? _ref : '').trim().toLowerCase();
75
+ if (address.includes('@')) addresses.add(address);
76
+ }
77
+ return [
78
+ ...addresses
79
+ ];
80
+ } catch (error) {
81
+ console.error('[staff-alert-email] staff roster unavailable', error);
82
+ return [];
83
+ }
84
+ }
85
+ /**
86
+ * Sends one rendered alert to each operator recipient, one message each so
87
+ * no staff member is shown another's address. Metered to the platform: our
88
+ * own alarm is our own cost (AGL-1438). Never throws.
89
+ */ async function sendToOperators(content, context) {
90
+ const recipients = await resolveStaffAlertRecipients();
91
+ if (!recipients.length) return {
40
92
  sent: false,
41
93
  reason: 'unconfigured'
42
94
  };
95
+ let delivered = null;
96
+ let failure = {
97
+ sent: false,
98
+ reason: 'unconfigured'
99
+ };
100
+ let sent = 0;
101
+ for (const to of recipients){
102
+ const result = await sendEmail(_extends({
103
+ to
104
+ }, content, {
105
+ context
106
+ }));
107
+ if (result.sent) {
108
+ sent += 1;
109
+ delivered != null ? delivered : delivered = result;
110
+ } else {
111
+ failure = result;
112
+ }
113
+ }
114
+ if (sent) {
115
+ const { meterPlatformEmail } = await import("./email-metering.js");
116
+ await meterPlatformEmail(sent).catch(()=>undefined);
117
+ }
118
+ // Delivered to anyone is delivered: one dead staff mailbox does not make
119
+ // the alert a failure for the caller.
120
+ return delivered != null ? delivered : failure;
121
+ }
122
+ export async function sendStaffAlertEmail(input) {
43
123
  try {
44
124
  // In the platform's header and footer, as the `staff-alert` system email
45
- // (AGL-3367). Loaded lazily for the reason the meter below is.
125
+ // (AGL-3367). Loaded lazily for the reason the meter is.
46
126
  const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
47
127
  const content = await renderSystemEmailContent('staff-alert', {
48
128
  'alert.subject': input.subject,
@@ -51,17 +131,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
51
131
  subject: input.subject,
52
132
  text: input.text
53
133
  });
54
- const result = await sendEmail(_extends({
55
- to
56
- }, content, {
57
- context: input.context
58
- }));
59
- if (result.sent) {
60
- // Platform-scoped (AGL-1438): our own alert is our own cost.
61
- const { meterPlatformEmail } = await import("./email-metering.js");
62
- await meterPlatformEmail().catch(()=>undefined);
63
- }
64
- return result;
134
+ return await sendToOperators(content, input.context);
65
135
  } catch (error) {
66
136
  console.error('[staff-alert-email] send failed', error);
67
137
  return {
@@ -70,5 +140,38 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
70
140
  };
71
141
  }
72
142
  }
143
+ /**
144
+ * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`
145
+ * system email: the alert, a button to the item it is about, in the
146
+ * operator's own brand. Sent by `notifyStaff` for every
147
+ * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.
148
+ * Never throws.
149
+ */ export async function sendOperatorAlertEmail(input) {
150
+ try {
151
+ var _input_body, _input_url;
152
+ const { renderSystemEmailContent, systemEmailBrand } = await import("./render-system-email.js");
153
+ const body = String((_input_body = input.body) != null ? _input_body : '').trim();
154
+ const url = String((_input_url = input.url) != null ? _input_url : '').trim();
155
+ const content = await renderSystemEmailContent('operator-alert', {
156
+ 'alert.title': input.title,
157
+ 'alert.body': body,
158
+ 'alert.url': url
159
+ }, systemEmailBrand(null), {
160
+ subject: input.title,
161
+ text: [
162
+ input.title,
163
+ body,
164
+ url
165
+ ].filter(Boolean).join('\n\n')
166
+ });
167
+ return await sendToOperators(content, input.context);
168
+ } catch (error) {
169
+ console.error('[staff-alert-email] operator alert failed', error);
170
+ return {
171
+ sent: false,
172
+ reason: 'network'
173
+ };
174
+ }
175
+ }
73
176
 
74
177
  //# sourceMappingURL=staff-alert-email.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * A staff alert by email, from LIBRARY code (AGL-2925).\n *\n * `STAFF_ALERT_EMAIL` is the one inbox every subsystem's alert lands in —\n * the console's `emailStaffAlert` in `apps/console/app/api/_lib/\n * usage-alert-email.ts` sends the cron's alerts there. A library cannot\n * call that, because a library cannot import from an app. So a library's\n * own alert — the AI meter's platform-wide free-spend ceiling is one —\n * sends through this, to the same address, with the same posture: unset is\n * the ordinary answer outside production and reports `unconfigured`, and\n * nothing here throws, because the mail is a courtesy and the refusal it\n * announces is the control.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer, which reads\n * its template through the same admin app, is loaded the same way.\n */\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n const to = String(process.env.STAFF_ALERT_EMAIL ?? '').trim()\n if (!to.includes('@')) return { sent: false, reason: 'unconfigured' }\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter below is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n const result = await sendEmail({ to, ...content, context: input.context })\n if (result.sent) {\n // Platform-scoped (AGL-1438): our own alert is our own cost.\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail().catch(() => undefined)\n }\n return result\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","sendStaffAlertEmail","input","process","to","String","env","STAFF_ALERT_EMAIL","trim","includes","sent","reason","renderSystemEmailContent","systemEmailBrand","content","subject","text","result","context","meterPlatformEmail","catch","undefined","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,SAAS,QAA8B,2BAA0B;AAE1E;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,eAAeC,oBAAoBC,KAKzC;QACmBC;IAAlB,MAAMC,KAAKC,QAAOF,iCAAAA,QAAQG,GAAG,CAACC,iBAAiB,YAA7BJ,iCAAiC,IAAIK,IAAI;IAC3D,IAAI,CAACJ,GAAGK,QAAQ,CAAC,MAAM,OAAO;QAAEC,MAAM;QAAOC,QAAQ;IAAe;IACpE,IAAI;QACF,yEAAyE;QACzE,+DAA+D;QAC/D,MAAM,EAAEC,wBAAwB,EAAEC,gBAAgB,EAAE,GAAG,MAAM,MAAM,CACjE;QAEF,MAAMC,UAAU,MAAMF,yBACpB,eACA;YAAE,iBAAiBV,MAAMa,OAAO;YAAE,cAAcb,MAAMc,IAAI;QAAC,GAC3DH,iBAAiB,OACjB;YAAEE,SAASb,MAAMa,OAAO;YAAEC,MAAMd,MAAMc,IAAI;QAAC;QAE7C,MAAMC,SAAS,MAAMjB,UAAU;YAAEI;WAAOU;YAASI,SAAShB,MAAMgB,OAAO;;QACvE,IAAID,OAAOP,IAAI,EAAE;YACf,6DAA6D;YAC7D,MAAM,EAAES,kBAAkB,EAAE,GAAG,MAAM,MAAM,CAAC;YAC5C,MAAMA,qBAAqBC,KAAK,CAAC,IAAMC;QACzC;QACA,OAAOJ;IACT,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,mCAAmCA;QACjD,OAAO;YAAEZ,MAAM;YAAOC,QAAQ;QAAU;IAC1C;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/staff-alert-email.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\nimport { sendEmail, type SendEmailResult } from '@aglyn/shared-util-email'\n\n/**\n * Staff alerts by email, from LIBRARY code (AGL-2925).\n *\n * One destination for every subsystem's alarm — the console's\n * `emailStaffAlert` in `apps/console/app/api/_lib/usage-alert-email.ts` sends\n * through here too — resolved by {@link resolveStaffAlertRecipients}\n * (AGL-3375). Nothing here throws, because the mail is a courtesy and the\n * refusal it announces is the control; with no address anywhere the answer\n * is `unconfigured`.\n *\n * Platform email metering is loaded lazily and only after a send, so that\n * importing this module — which every unit test of the meter does — never\n * pulls the Firestore-backed meter, and with it the admin app, into a test\n * that only wanted to count credits. The system-email renderer and the staff\n * roster, which read through the same admin app, are loaded the same way.\n */\n\n/** At most this many staff accounts are mailed when no inbox is configured. */\nconst STAFF_FALLBACK_MAX = 10\n\n/**\n * Where a staff alarm goes (AGL-3375), first answer wins:\n *\n * 1. `STAFF_ALERT_EMAIL`, the operator's one alerts inbox.\n * 2. The operator's published support address,\n * `NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL`, which every self-hosted install is\n * asked for and most set.\n * 3. Every staff account's own address, up to {@link STAFF_FALLBACK_MAX}.\n *\n * Only the first applies on a preview or development deployment.\n *\n * The chain exists because the alarms that matter most are the ones an\n * install nobody tuned would otherwise send to no one: card testing on its\n * checkout, a stolen card on its first subscription, a phishing campaign\n * held on its sending domain. An unset variable used to mean silence.\n *\n * Empty only when none of the three yields an address.\n */\nexport async function resolveStaffAlertRecipients(): Promise<string[]> {\n const configured = String(process.env.STAFF_ALERT_EMAIL ?? '').trim().toLowerCase()\n if (configured.includes('@')) return [configured]\n // A preview or development deployment says what it is; only a production\n // one, or a self-hosted install that sets no such thing, falls back. A\n // preview's alarms reaching the operator's real inbox would be noise.\n const deployment = String(process.env.VERCEL_ENV ?? '').trim()\n if (deployment && deployment !== 'production') return []\n const operator = String(process.env.NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL ?? '')\n .trim()\n .toLowerCase()\n if (operator.includes('@')) return [operator]\n try {\n const { findUserByUidAcrossPools, listStaffUidsAcrossPools } = await import(\n './auth-pools'\n )\n const uids = (await listStaffUidsAcrossPools()).slice(0, STAFF_FALLBACK_MAX)\n const addresses = new Set<string>()\n for (const uid of uids) {\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n const address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n if (address.includes('@')) addresses.add(address)\n }\n return [...addresses]\n } catch (error) {\n console.error('[staff-alert-email] staff roster unavailable', error)\n return []\n }\n}\n\n/**\n * Sends one rendered alert to each operator recipient, one message each so\n * no staff member is shown another's address. Metered to the platform: our\n * own alarm is our own cost (AGL-1438). Never throws.\n */\nasync function sendToOperators(\n content: { subject: string; text: string; html?: string },\n context: string,\n): Promise<SendEmailResult> {\n const recipients = await resolveStaffAlertRecipients()\n if (!recipients.length) return { sent: false, reason: 'unconfigured' }\n let delivered: SendEmailResult | null = null\n let failure: SendEmailResult = { sent: false, reason: 'unconfigured' }\n let sent = 0\n for (const to of recipients) {\n const result = await sendEmail({ to, ...content, context })\n if (result.sent) {\n sent += 1\n delivered ??= result\n } else {\n failure = result\n }\n }\n if (sent) {\n const { meterPlatformEmail } = await import('./email-metering')\n await meterPlatformEmail(sent).catch(() => undefined)\n }\n // Delivered to anyone is delivered: one dead staff mailbox does not make\n // the alert a failure for the caller.\n return delivered ?? failure\n}\n\nexport async function sendStaffAlertEmail(input: {\n subject: string\n text: string\n /** Resend tag / log label. */\n context: string\n}): Promise<SendEmailResult> {\n try {\n // In the platform's header and footer, as the `staff-alert` system email\n // (AGL-3367). Loaded lazily for the reason the meter is.\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const content = await renderSystemEmailContent(\n 'staff-alert',\n { 'alert.subject': input.subject, 'alert.body': input.text },\n systemEmailBrand(null),\n { subject: input.subject, text: input.text },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] send failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n\n/**\n * A fraud or risk alert for the operator (AGL-3375), as the `operator-alert`\n * system email: the alert, a button to the item it is about, in the\n * operator's own brand. Sent by `notifyStaff` for every\n * `OPERATOR_ALERT_NOTIFICATION_TYPES` notification, beside the console one.\n * Never throws.\n */\nexport async function sendOperatorAlertEmail(input: {\n title: string\n body?: string | null\n /** Absolute link to the item, when there is one. */\n url?: string | null\n context: string\n}): Promise<SendEmailResult> {\n try {\n const { renderSystemEmailContent, systemEmailBrand } = await import(\n './render-system-email'\n )\n const body = String(input.body ?? '').trim()\n const url = String(input.url ?? '').trim()\n const content = await renderSystemEmailContent(\n 'operator-alert',\n { 'alert.title': input.title, 'alert.body': body, 'alert.url': url },\n systemEmailBrand(null),\n { subject: input.title, text: [input.title, body, url].filter(Boolean).join('\\n\\n') },\n )\n return await sendToOperators(content, input.context)\n } catch (error) {\n console.error('[staff-alert-email] operator alert failed', error)\n return { sent: false, reason: 'network' }\n }\n}\n"],"names":["sendEmail","STAFF_FALLBACK_MAX","resolveStaffAlertRecipients","process","configured","String","env","STAFF_ALERT_EMAIL","trim","toLowerCase","includes","deployment","VERCEL_ENV","operator","NEXT_PUBLIC_OPERATOR_SUPPORT_EMAIL","findUserByUidAcrossPools","listStaffUidsAcrossPools","uids","slice","addresses","Set","uid","pooled","catch","address","record","email","add","error","console","sendToOperators","content","context","recipients","length","sent","reason","delivered","failure","to","result","meterPlatformEmail","undefined","sendStaffAlertEmail","input","renderSystemEmailContent","systemEmailBrand","subject","text","sendOperatorAlertEmail","body","url","title","filter","Boolean","join"],"mappings":"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"}
@@ -101,6 +101,7 @@ export function fakeFirestore(seed = {}) {
101
101
  }),
102
102
  // Ordering and limits are not what these specs are about; the queue
103
103
  // read just needs to return everything it stored.
104
+ where: ()=>api,
104
105
  orderBy: ()=>api,
105
106
  limit: ()=>api,
106
107
  get: async ()=>({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/test-firestore.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 * A Firestore stand-in for specs that span MORE THAN ONE COLLECTION\n * (AGL-1592). Test-only; deliberately not exported from the barrel.\n *\n * The one-collection fakes elsewhere in this directory return the same\n * document whatever collection name they are handed, which is fine when the\n * code under test touches one document and actively misleading when it does\n * not: the phone opt-out path reads `users/{uid}` and `contactSuppressions/{n}`\n * and the whole point is that they are different records. A fake that conflated\n * them would pass while the real thing wrote the marker on top of the\n * suppression.\n *\n * `FieldValue.delete()` is honoured for real, because \"the field is gone\" and\n * \"the field holds a sentinel object\" are different states and only the first\n * one makes `seedUserProfile`'s blank check fire.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n\nconst DELETE = FieldValue.delete()\n\nexport interface FakeFirestore {\n /**\n * Every document in a collection, keyed by id.\n *\n * A SUBcollection is addressed by its full path — `hosts/host-1/suppressions`\n * — which is also how it is seeded.\n */\n docs: (collection: string) => Record<string, any>\n collection: (name: string) => any\n /**\n * Several documents in one round trip, the shape the real `getAll` has:\n * the results come back POSITIONALLY, one per reference, so a caller that\n * zips them back against its own input list gets what it asked for.\n */\n getAll: (...refs: any[]) => Promise<any[]>\n /**\n * A transaction, real enough for a read-then-write body.\n *\n * Reads pass straight through and writes are held until the body resolves,\n * which is the property the code under test depends on: a writer that\n * decides what to store FROM what it read must not observe its own\n * half-finished write. It does not model contention or retries — nothing in\n * this repo's transaction bodies branches on a retry, and a fake that\n * pretended to would be asserting the fake.\n */\n runTransaction: <T>(body: (transaction: any) => Promise<T>) => Promise<T>\n}\n\nexport function fakeFirestore(\n seed: Record<string, Record<string, any>> = {},\n): FakeFirestore {\n const store: Record<string, Record<string, any>> = JSON.parse(\n JSON.stringify(seed),\n )\n\n const snapshotFor = (collection: string, id: string) => {\n const data = store[collection]?.[id]\n return {\n id,\n exists: data !== undefined,\n get: (field: string) => data?.[field],\n data: () => data,\n }\n }\n\n const fake: FakeFirestore = {\n docs: (collection: string) => store[collection] ?? {},\n getAll: async (...refs: any[]) =>\n refs.map((ref) => snapshotFor(ref.collectionPath, ref.id)),\n async runTransaction<T>(body: (transaction: any) => Promise<T>): Promise<T> {\n const pending: Array<() => Promise<void>> = []\n const transaction = {\n get: (ref: any) => ref.get(),\n set: (ref: any, data: Record<string, any>, options?: { merge?: boolean }) => {\n pending.push(() => ref.set(data, options))\n return transaction\n },\n }\n const result = await body(transaction)\n for (const write of pending) await write()\n return result\n },\n collection(name: string) {\n store[name] = store[name] ?? {}\n const api: any = {\n doc: (id: string) => ({\n id,\n /**\n * Which collection this reference came from, so `getAll` can resolve\n * a bare reference the way the real client does.\n */\n collectionPath: name,\n /**\n * A subcollection is a collection at the joined path, so the fake\n * stays one flat map. Nesting matters here because a per-site list\n * lives under the site — a fake that returned the same documents for\n * `hosts/a/suppressions` and `hosts/b/suppressions` would certify a\n * sender that leaks one site's unsubscribes into another's.\n */\n collection: (sub: string) => fake.collection(`${name}/${id}/${sub}`),\n get: async () => snapshotFor(name, id),\n set: async (data: Record<string, any>, options?: { merge?: boolean }) => {\n const base = options?.merge ? (store[name][id] ?? {}) : {}\n const next: Record<string, any> = { ...base }\n for (const [key, value] of Object.entries(data)) {\n // A real delete, not a stored sentinel — see the header.\n if (value && typeof value === 'object' && DELETE.isEqual(value as any)) {\n delete next[key]\n continue\n }\n // Server timestamps arrive as sentinels too. Freeze them into\n // something a spec can assert on and a date can be read from.\n next[key] =\n value && typeof value === 'object' && typeof (value as any).isEqual === 'function'\n ? { seconds: Math.floor(Date.now() / 1000) }\n : value\n }\n store[name][id] = next\n },\n }),\n // Ordering and limits are not what these specs are about; the queue\n // read just needs to return everything it stored.\n orderBy: () => api,\n limit: () => api,\n get: async () => ({\n docs: Object.keys(store[name]).map((id) => snapshotFor(name, id)),\n }),\n add: async (data: Record<string, any>) => {\n const id = `auto-${Object.keys(store[name]).length + 1}`\n store[name][id] = data\n return { id }\n },\n }\n return api\n },\n }\n return fake\n}\n\nexport default fakeFirestore\n"],"names":["FieldValue","DELETE","delete","fakeFirestore","seed","store","JSON","parse","stringify","snapshotFor","collection","id","data","exists","undefined","get","field","fake","docs","getAll","refs","map","ref","collectionPath","runTransaction","body","pending","transaction","set","options","push","result","write","name","api","doc","sub","base","merge","next","key","value","Object","entries","isEqual","seconds","Math","floor","Date","now","orderBy","limit","keys","add","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/test-firestore.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 * A Firestore stand-in for specs that span MORE THAN ONE COLLECTION\n * (AGL-1592). Test-only; deliberately not exported from the barrel.\n *\n * The one-collection fakes elsewhere in this directory return the same\n * document whatever collection name they are handed, which is fine when the\n * code under test touches one document and actively misleading when it does\n * not: the phone opt-out path reads `users/{uid}` and `contactSuppressions/{n}`\n * and the whole point is that they are different records. A fake that conflated\n * them would pass while the real thing wrote the marker on top of the\n * suppression.\n *\n * `FieldValue.delete()` is honoured for real, because \"the field is gone\" and\n * \"the field holds a sentinel object\" are different states and only the first\n * one makes `seedUserProfile`'s blank check fire.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n\nconst DELETE = FieldValue.delete()\n\nexport interface FakeFirestore {\n /**\n * Every document in a collection, keyed by id.\n *\n * A SUBcollection is addressed by its full path — `hosts/host-1/suppressions`\n * — which is also how it is seeded.\n */\n docs: (collection: string) => Record<string, any>\n collection: (name: string) => any\n /**\n * Several documents in one round trip, the shape the real `getAll` has:\n * the results come back POSITIONALLY, one per reference, so a caller that\n * zips them back against its own input list gets what it asked for.\n */\n getAll: (...refs: any[]) => Promise<any[]>\n /**\n * A transaction, real enough for a read-then-write body.\n *\n * Reads pass straight through and writes are held until the body resolves,\n * which is the property the code under test depends on: a writer that\n * decides what to store FROM what it read must not observe its own\n * half-finished write. It does not model contention or retries — nothing in\n * this repo's transaction bodies branches on a retry, and a fake that\n * pretended to would be asserting the fake.\n */\n runTransaction: <T>(body: (transaction: any) => Promise<T>) => Promise<T>\n}\n\nexport function fakeFirestore(\n seed: Record<string, Record<string, any>> = {},\n): FakeFirestore {\n const store: Record<string, Record<string, any>> = JSON.parse(\n JSON.stringify(seed),\n )\n\n const snapshotFor = (collection: string, id: string) => {\n const data = store[collection]?.[id]\n return {\n id,\n exists: data !== undefined,\n get: (field: string) => data?.[field],\n data: () => data,\n }\n }\n\n const fake: FakeFirestore = {\n docs: (collection: string) => store[collection] ?? {},\n getAll: async (...refs: any[]) =>\n refs.map((ref) => snapshotFor(ref.collectionPath, ref.id)),\n async runTransaction<T>(body: (transaction: any) => Promise<T>): Promise<T> {\n const pending: Array<() => Promise<void>> = []\n const transaction = {\n get: (ref: any) => ref.get(),\n set: (ref: any, data: Record<string, any>, options?: { merge?: boolean }) => {\n pending.push(() => ref.set(data, options))\n return transaction\n },\n }\n const result = await body(transaction)\n for (const write of pending) await write()\n return result\n },\n collection(name: string) {\n store[name] = store[name] ?? {}\n const api: any = {\n doc: (id: string) => ({\n id,\n /**\n * Which collection this reference came from, so `getAll` can resolve\n * a bare reference the way the real client does.\n */\n collectionPath: name,\n /**\n * A subcollection is a collection at the joined path, so the fake\n * stays one flat map. Nesting matters here because a per-site list\n * lives under the site — a fake that returned the same documents for\n * `hosts/a/suppressions` and `hosts/b/suppressions` would certify a\n * sender that leaks one site's unsubscribes into another's.\n */\n collection: (sub: string) => fake.collection(`${name}/${id}/${sub}`),\n get: async () => snapshotFor(name, id),\n set: async (data: Record<string, any>, options?: { merge?: boolean }) => {\n const base = options?.merge ? (store[name][id] ?? {}) : {}\n const next: Record<string, any> = { ...base }\n for (const [key, value] of Object.entries(data)) {\n // A real delete, not a stored sentinel — see the header.\n if (value && typeof value === 'object' && DELETE.isEqual(value as any)) {\n delete next[key]\n continue\n }\n // Server timestamps arrive as sentinels too. Freeze them into\n // something a spec can assert on and a date can be read from.\n next[key] =\n value && typeof value === 'object' && typeof (value as any).isEqual === 'function'\n ? { seconds: Math.floor(Date.now() / 1000) }\n : value\n }\n store[name][id] = next\n },\n }),\n // Ordering and limits are not what these specs are about; the queue\n // read just needs to return everything it stored.\n where: () => api,\n orderBy: () => api,\n limit: () => api,\n get: async () => ({\n docs: Object.keys(store[name]).map((id) => snapshotFor(name, id)),\n }),\n add: async (data: Record<string, any>) => {\n const id = `auto-${Object.keys(store[name]).length + 1}`\n store[name][id] = data\n return { id }\n },\n }\n return api\n },\n }\n return fake\n}\n\nexport default fakeFirestore\n"],"names":["FieldValue","DELETE","delete","fakeFirestore","seed","store","JSON","parse","stringify","snapshotFor","collection","id","data","exists","undefined","get","field","fake","docs","getAll","refs","map","ref","collectionPath","runTransaction","body","pending","transaction","set","options","push","result","write","name","api","doc","sub","base","merge","next","key","value","Object","entries","isEqual","seconds","Math","floor","Date","now","where","orderBy","limit","keys","add","length"],"mappings":"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"}
@@ -20,6 +20,7 @@ import { firebaseAdmin } from "./firebase-admin.js";
20
20
  import { countCrmRecords, restampCrmListFieldsAt } from "./crm-records.js";
21
21
  import { attributeOrderToEmail } from "./email-revenue-attribution.js";
22
22
  import { hostRefusesCaptureForErasure } from "./email-suppression.js";
23
+ import { scheduleCapturedEmailCheck } from "./capture-email-check.js";
23
24
  import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
24
25
  import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
25
26
  import { consentGroupForGrant } from "@aglyn/aglyn/app-utils/consent-groups";
@@ -117,8 +118,8 @@ import { consentGroupForSite, getOrgForHost, orgDataCollectionForHost } from "./
117
118
  }
118
119
  export async function upsertHostContact(options) {
119
120
  try {
120
- var _options_interaction_atMs, _options_campaignIds, _ref, _contactsRef_parent, _ref1, _ref2;
121
- var _options_facet, _orgBilling_org;
121
+ var _options_interaction_atMs, _options_campaignIds, _ref, _contactsRef_parent, _ref1, _ref2, _ref3;
122
+ var _options_facet, _orgBilling_org, _contactsRef_parent1;
122
123
  const email = normalizeContactEmail(options.email);
123
124
  if (!email) return {
124
125
  refused: 'invalid-email'
@@ -246,7 +247,8 @@ export async function upsertHostContact(options) {
246
247
  * read once and needs no backfill.
247
248
  *=========================================*/ const docSnapshot = await findContactByEmail(contactsRef, email);
248
249
  if (docSnapshot) {
249
- var _facet_name, _profile_lifecycleStage;
250
+ var _facet_name, _profile_lifecycleStage, _ref4;
251
+ var _contactsRef_parent2;
250
252
  /*
251
253
  * MERGED INTO THIS GROUP'S FACET, not into the top of the document.
252
254
  *
@@ -365,6 +367,13 @@ export async function upsertHostContact(options) {
365
367
  // The merged facet, name and scope are what the Contacts list
366
368
  // searches and filters by (AGL-3321): restamped from the stored row.
367
369
  await restampCrmListFieldsAt(docSnapshot.ref, 'contacts');
370
+ // The address's domain, checked after the response (AGL-3328).
371
+ scheduleCapturedEmailCheck({
372
+ orgId: (_ref4 = (_contactsRef_parent2 = contactsRef.parent) == null ? void 0 : _contactsRef_parent2.id) != null ? _ref4 : null,
373
+ hostId: options.hostId,
374
+ email,
375
+ predicted: docSnapshot.get('emailStatus') === 'undeliverable'
376
+ });
368
377
  return {
369
378
  contactId: docSnapshot.id,
370
379
  created: false
@@ -556,6 +565,13 @@ export async function upsertHostContact(options) {
556
565
  console.error('upsertHostContact onCreated failed', error);
557
566
  });
558
567
  }
568
+ // A new person's address, checked after the response (AGL-3328): a
569
+ // domain that takes no mail reads "Would bounce" on the record.
570
+ scheduleCapturedEmailCheck({
571
+ orgId: (_ref3 = (_contactsRef_parent1 = contactsRef.parent) == null ? void 0 : _contactsRef_parent1.id) != null ? _ref3 : null,
572
+ hostId: options.hostId,
573
+ email
574
+ });
559
575
  return {
560
576
  contactId: created.id,
561
577
  created: true