@aglyn/aglyn 1.0.0-beta.199 → 1.0.0-beta.201

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 (84) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/activity-search.d.ts +39 -0
  3. package/src/lib/app-utils/activity-search.js +85 -0
  4. package/src/lib/app-utils/activity-search.js.map +1 -0
  5. package/src/lib/app-utils/admin-audit-index.d.ts +104 -0
  6. package/src/lib/app-utils/admin-audit-index.js +177 -0
  7. package/src/lib/app-utils/admin-audit-index.js.map +1 -0
  8. package/src/lib/app-utils/artifact-list-keys.d.ts +51 -0
  9. package/src/lib/app-utils/artifact-list-keys.js +154 -0
  10. package/src/lib/app-utils/artifact-list-keys.js.map +1 -0
  11. package/src/lib/app-utils/content-query-fields.d.ts +69 -0
  12. package/src/lib/app-utils/content-query-fields.js +79 -0
  13. package/src/lib/app-utils/content-query-fields.js.map +1 -0
  14. package/src/lib/app-utils/crm.d.ts +170 -1
  15. package/src/lib/app-utils/crm.js +414 -1
  16. package/src/lib/app-utils/crm.js.map +1 -1
  17. package/src/lib/app-utils/dataset-models.d.ts +75 -24
  18. package/src/lib/app-utils/dataset-models.js +107 -40
  19. package/src/lib/app-utils/dataset-models.js.map +1 -1
  20. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  21. package/src/lib/app-utils/docs-help.generated.js +10 -0
  22. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  23. package/src/lib/app-utils/docs-index.generated.js +89 -75
  24. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  25. package/src/lib/app-utils/email-search.d.ts +36 -0
  26. package/src/lib/app-utils/email-search.js +53 -0
  27. package/src/lib/app-utils/email-search.js.map +1 -0
  28. package/src/lib/app-utils/email-state.d.ts +7 -3
  29. package/src/lib/app-utils/email-state.js +22 -6
  30. package/src/lib/app-utils/email-state.js.map +1 -1
  31. package/src/lib/app-utils/forms.d.ts +178 -6
  32. package/src/lib/app-utils/forms.js +197 -0
  33. package/src/lib/app-utils/forms.js.map +1 -1
  34. package/src/lib/app-utils/health-report.d.ts +3 -3
  35. package/src/lib/app-utils/health-report.js +9 -9
  36. package/src/lib/app-utils/health-report.js.map +1 -1
  37. package/src/lib/app-utils/host-naming.d.ts +12 -7
  38. package/src/lib/app-utils/host-naming.js +18 -3
  39. package/src/lib/app-utils/host-naming.js.map +1 -1
  40. package/src/lib/app-utils/lockdown.d.ts +19 -0
  41. package/src/lib/app-utils/lockdown.js +21 -0
  42. package/src/lib/app-utils/lockdown.js.map +1 -1
  43. package/src/lib/app-utils/marketplace-listing-visibility.d.ts +7 -6
  44. package/src/lib/app-utils/marketplace-listing-visibility.js +12 -10
  45. package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
  46. package/src/lib/app-utils/media-embedded-fields.d.ts +40 -0
  47. package/src/lib/app-utils/media-embedded-fields.js +77 -0
  48. package/src/lib/app-utils/media-embedded-fields.js.map +1 -1
  49. package/src/lib/app-utils/media-metadata.d.ts +95 -0
  50. package/src/lib/app-utils/media-metadata.js +94 -0
  51. package/src/lib/app-utils/media-metadata.js.map +1 -1
  52. package/src/lib/app-utils/message-search.d.ts +40 -0
  53. package/src/lib/app-utils/message-search.js +168 -0
  54. package/src/lib/app-utils/message-search.js.map +1 -0
  55. package/src/lib/app-utils/name-search.d.ts +83 -0
  56. package/src/lib/app-utils/name-search.js +97 -0
  57. package/src/lib/app-utils/name-search.js.map +1 -1
  58. package/src/lib/app-utils/payment-risk.d.ts +75 -0
  59. package/src/lib/app-utils/payment-risk.js +139 -0
  60. package/src/lib/app-utils/payment-risk.js.map +1 -0
  61. package/src/lib/app-utils/plugin-api-request-host.d.ts +29 -0
  62. package/src/lib/app-utils/plugin-api-request-host.js +90 -0
  63. package/src/lib/app-utils/plugin-api-request-host.js.map +1 -0
  64. package/src/lib/app-utils/plugin-bundle-checks.d.ts +3 -2
  65. package/src/lib/app-utils/plugin-bundle-checks.js +76 -5
  66. package/src/lib/app-utils/plugin-bundle-checks.js.map +1 -1
  67. package/src/lib/app-utils/server.d.ts +2 -0
  68. package/src/lib/app-utils/server.js +2 -0
  69. package/src/lib/app-utils/server.js.map +1 -1
  70. package/src/lib/app-utils/webhook-delivery.js +4 -1
  71. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  72. package/src/lib/plugin-manager/billing-webhook-hooks.d.ts +9 -0
  73. package/src/lib/plugin-manager/billing-webhook-hooks.js +5 -1
  74. package/src/lib/plugin-manager/billing-webhook-hooks.js.map +1 -1
  75. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +9 -1
  76. package/src/lib/plugin-manager/plugin-activity-actions.js +11 -1
  77. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  78. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  79. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  80. package/src/lib/plugin-manager/plugin-events.d.ts +22 -0
  81. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  82. package/src/lib/plugin-manager/plugin-jobs.d.ts +8 -4
  83. package/src/lib/plugin-manager/plugin-jobs.js +8 -4
  84. package/src/lib/plugin-manager/plugin-jobs.js.map +1 -1
@@ -0,0 +1,36 @@
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
+ /**
18
+ * The `array-contains` search tokens for an address (AGL-3321).
19
+ *
20
+ * Every prefix, up to `NAME_TOKEN_MAX_PREFIX` characters, of each of: the
21
+ * whole address, the domain, the domain behind an `@`, and each piece of the
22
+ * local part and of the domain. So `jane.doe@mail.example.com` is found by
23
+ * `jane`, `doe`, `jane.doe@ma`, `example`, `mail.example` and `@mail`, and
24
+ * `dana@example.com` by `example.com`. A query becomes one token through
25
+ * `nameSearchToken`, which caps it at the same length, so a typed address
26
+ * longer than that narrows by its first twelve characters rather than
27
+ * matching nothing.
28
+ *
29
+ * Pure and SDK-free, so a browser list and a server writer stamp the same
30
+ * array: the staff suppressions list (`emailTokens`) and the commerce orders
31
+ * list (`customerEmailTokens`) both search an address through it. Each has a
32
+ * backfill that restates it for the documents written before its field —
33
+ * `tools/scripts/backfill-email-suppression-filters.mjs` and
34
+ * `tools/scripts/backfill-orders-list-fields.mjs` — held to shared fixtures.
35
+ */
36
+ export declare function emailSearchTokens(email: string | null | undefined): string[];
@@ -0,0 +1,53 @@
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
+ */ import { nameSearchTokens } from "./name-search.js";
17
+ /**
18
+ * The `array-contains` search tokens for an address (AGL-3321).
19
+ *
20
+ * Every prefix, up to `NAME_TOKEN_MAX_PREFIX` characters, of each of: the
21
+ * whole address, the domain, the domain behind an `@`, and each piece of the
22
+ * local part and of the domain. So `jane.doe@mail.example.com` is found by
23
+ * `jane`, `doe`, `jane.doe@ma`, `example`, `mail.example` and `@mail`, and
24
+ * `dana@example.com` by `example.com`. A query becomes one token through
25
+ * `nameSearchToken`, which caps it at the same length, so a typed address
26
+ * longer than that narrows by its first twelve characters rather than
27
+ * matching nothing.
28
+ *
29
+ * Pure and SDK-free, so a browser list and a server writer stamp the same
30
+ * array: the staff suppressions list (`emailTokens`) and the commerce orders
31
+ * list (`customerEmailTokens`) both search an address through it. Each has a
32
+ * backfill that restates it for the documents written before its field —
33
+ * `tools/scripts/backfill-email-suppression-filters.mjs` and
34
+ * `tools/scripts/backfill-orders-list-fields.mjs` — held to shared fixtures.
35
+ */ export function emailSearchTokens(email) {
36
+ const address = String(email != null ? email : '').trim().toLowerCase();
37
+ if (!address) return [];
38
+ const at = address.lastIndexOf('@');
39
+ const local = at === -1 ? address : address.slice(0, at);
40
+ const domain = at === -1 ? '' : address.slice(at + 1);
41
+ const words = [
42
+ address,
43
+ ...domain ? [
44
+ domain,
45
+ `@${domain}`
46
+ ] : [],
47
+ ...local.split(/[._+-]+/),
48
+ ...domain.split('.')
49
+ ];
50
+ return nameSearchTokens(words.filter(Boolean).join(' '));
51
+ }
52
+
53
+ //# sourceMappingURL=email-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-search.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 { nameSearchTokens } from './name-search'\n\n/**\n * The `array-contains` search tokens for an address (AGL-3321).\n *\n * Every prefix, up to `NAME_TOKEN_MAX_PREFIX` characters, of each of: the\n * whole address, the domain, the domain behind an `@`, and each piece of the\n * local part and of the domain. So `jane.doe@mail.example.com` is found by\n * `jane`, `doe`, `jane.doe@ma`, `example`, `mail.example` and `@mail`, and\n * `dana@example.com` by `example.com`. A query becomes one token through\n * `nameSearchToken`, which caps it at the same length, so a typed address\n * longer than that narrows by its first twelve characters rather than\n * matching nothing.\n *\n * Pure and SDK-free, so a browser list and a server writer stamp the same\n * array: the staff suppressions list (`emailTokens`) and the commerce orders\n * list (`customerEmailTokens`) both search an address through it. Each has a\n * backfill that restates it for the documents written before its field —\n * `tools/scripts/backfill-email-suppression-filters.mjs` and\n * `tools/scripts/backfill-orders-list-fields.mjs` — held to shared fixtures.\n */\nexport function emailSearchTokens(email: string | null | undefined): string[] {\n const address = String(email ?? '')\n .trim()\n .toLowerCase()\n if (!address) return []\n const at = address.lastIndexOf('@')\n const local = at === -1 ? address : address.slice(0, at)\n const domain = at === -1 ? '' : address.slice(at + 1)\n const words = [\n address,\n ...(domain ? [domain, `@${domain}`] : []),\n ...local.split(/[._+-]+/),\n ...domain.split('.'),\n ]\n return nameSearchTokens(words.filter(Boolean).join(' '))\n}\n"],"names":["nameSearchTokens","emailSearchTokens","email","address","String","trim","toLowerCase","at","lastIndexOf","local","slice","domain","words","split","filter","Boolean","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,mBAAe;AAEhD;;;;;;;;;;;;;;;;;;CAkBC,GACD,OAAO,SAASC,kBAAkBC,KAAgC;IAChE,MAAMC,UAAUC,OAAOF,gBAAAA,QAAS,IAC7BG,IAAI,GACJC,WAAW;IACd,IAAI,CAACH,SAAS,OAAO,EAAE;IACvB,MAAMI,KAAKJ,QAAQK,WAAW,CAAC;IAC/B,MAAMC,QAAQF,OAAO,CAAC,IAAIJ,UAAUA,QAAQO,KAAK,CAAC,GAAGH;IACrD,MAAMI,SAASJ,OAAO,CAAC,IAAI,KAAKJ,QAAQO,KAAK,CAACH,KAAK;IACnD,MAAMK,QAAQ;QACZT;WACIQ,SAAS;YAACA;YAAQ,CAAC,CAAC,EAAEA,QAAQ;SAAC,GAAG,EAAE;WACrCF,MAAMI,KAAK,CAAC;WACZF,OAAOE,KAAK,CAAC;KACjB;IACD,OAAOb,iBAAiBY,MAAME,MAAM,CAACC,SAASC,IAAI,CAAC;AACrD"}
@@ -15,10 +15,14 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /** Every state, weakest first. */
18
- export declare const EMAIL_STATE_STATUSES: readonly ["ok", "bounced", "blocked", "unsubscribed", "complained", "do_not_contact"];
18
+ export declare const EMAIL_STATE_STATUSES: readonly ["ok", "undeliverable", "bounced", "blocked", "unsubscribed", "complained", "do_not_contact"];
19
19
  export type EmailStateStatus = (typeof EMAIL_STATE_STATUSES)[number];
20
- /** Which sender's verdict it is. */
21
- export type EmailStateSource = 'sequence' | 'campaign' | 'member';
20
+ /**
21
+ * Which sender's verdict it is. `check` is the deliverability check
22
+ * (AGL-3328), which reads the domain's DNS when the record is captured and
23
+ * before a send, and says what it found before anything bounces.
24
+ */
25
+ export type EmailStateSource = 'sequence' | 'campaign' | 'member' | 'check';
22
26
  /** The field on the lead and the contact. */
23
27
  export declare const EMAIL_STATE_FIELD = "emailState";
24
28
  export interface EmailState {
@@ -37,15 +37,28 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
37
37
  * overwrites a refusal on its own: the ranking below decides.
38
38
  *==========================================*/ /** Every state, weakest first. */ export const EMAIL_STATE_STATUSES = [
39
39
  /** The address may be emailed: released, or confirmed again. */ 'ok',
40
+ /**
41
+ * Mail to it WOULD bounce: its domain has no mail server, or publishes a
42
+ * null MX (AGL-3328). A prediction from DNS rather than a bounce, so the
43
+ * weakest refusal: any real verdict replaces it, and the check clears it
44
+ * when the domain starts taking mail.
45
+ */ 'undeliverable',
40
46
  /** Mail to it bounced for good: the mailbox does not exist. */ 'bounced',
41
47
  /** The recipient's mail gateway refused the sender on policy or reputation. */ 'blocked',
42
48
  /** The unsubscribe link or header was used. */ 'unsubscribed',
43
49
  /** A reply called the email spam, or the recipient reported it. */ 'complained',
44
50
  /** On the organization's do-not-contact list — a member's or the runtime's entry. */ 'do_not_contact'
45
51
  ];
52
+ const EMAIL_STATE_SOURCES = [
53
+ 'sequence',
54
+ 'campaign',
55
+ 'member',
56
+ 'check'
57
+ ];
46
58
  /** The field on the lead and the contact. */ export const EMAIL_STATE_FIELD = 'emailState';
47
59
  /** How a state reads on the chip — typed so a state cannot ship unlabeled. */ export const EMAIL_STATE_LABELS = {
48
60
  ok: 'Email OK',
61
+ undeliverable: 'Would bounce',
49
62
  bounced: 'Bounced',
50
63
  blocked: 'Blocked by their mail gateway',
51
64
  unsubscribed: 'Unsubscribed',
@@ -60,11 +73,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
60
73
  * over nothing — a release is written on purpose, by a path that forces it.
61
74
  */ const EMAIL_STATE_RANK = {
62
75
  ok: 0,
63
- bounced: 1,
64
- blocked: 2,
65
- unsubscribed: 3,
66
- complained: 4,
67
- do_not_contact: 5
76
+ undeliverable: 1,
77
+ bounced: 2,
78
+ blocked: 3,
79
+ unsubscribed: 4,
80
+ complained: 5,
81
+ do_not_contact: 6
68
82
  };
69
83
  export function isEmailStateStatus(value) {
70
84
  return typeof value === 'string' && EMAIL_STATE_STATUSES.includes(value);
@@ -80,7 +94,7 @@ export function isEmailStateStatus(value) {
80
94
  return _extends({
81
95
  status: state['status'],
82
96
  atMs: Number.isFinite(atMs) && atMs > 0 ? atMs : 0,
83
- source: source === 'sequence' || source === 'campaign' || source === 'member' ? source : 'campaign',
97
+ source: EMAIL_STATE_SOURCES.includes(source) ? source : 'campaign',
84
98
  detail: typeof state['detail'] === 'string' && state['detail'] ? state['detail'] : null
85
99
  }, enrollmentId ? {
86
100
  enrollmentId
@@ -118,6 +132,8 @@ export function isEmailStateStatus(value) {
118
132
  const since = when ? ` on ${when}` : '';
119
133
  const said = state.detail ? ` ${state.detail}` : '';
120
134
  switch(state.status){
135
+ case 'undeliverable':
136
+ return `Email to this address would bounce: its domain has no mail server${since ? ` (checked${since})` : ''}.${said}`;
121
137
  case 'bounced':
122
138
  return `Email to this address bounced${since}: the mailbox does not exist.${said}`;
123
139
  case 'blocked':
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-state.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 RECORD'S EMAIL STATE (AGL-3245).\n *\n * The lists the senders consult — the platform suppression list, a site's\n * suppressions, an organization's do-not-contact list — are keyed by a\n * hash nobody can read. The record a person opens is the lead or the\n * contact, and until now it said nothing: an address that hard-bounced at\n * 10:30 still read as \"Sent\" at 12:30, and a member reading it would have\n * emailed the person again by hand.\n *\n * So the record carries `emailState`: what the last verdict on its address\n * was, when, from which sender, and in the sender's own words. It is\n * WRITTEN BY THE PLATFORM ONLY — the outreach runtime, the campaign\n * webhook, a member's do-not-contact mark — never by a form, an import or\n * the record's own editor, and the rules hold clients off the field. It\n * is a mirror of the lists, not a list: a send still asks the lists, and\n * the record says what they hold in words a person can act on.\n *\n * Absent means nothing is known. `ok` is a verdict too — a release, a\n * confirmed re-subscription — and it is the one state that never\n * overwrites a refusal on its own: the ranking below decides.\n *==========================================*/\n\n/** Every state, weakest first. */\nexport const EMAIL_STATE_STATUSES = [\n /** The address may be emailed: released, or confirmed again. */\n 'ok',\n /** Mail to it bounced for good: the mailbox does not exist. */\n 'bounced',\n /** The recipient's mail gateway refused the sender on policy or reputation. */\n 'blocked',\n /** The unsubscribe link or header was used. */\n 'unsubscribed',\n /** A reply called the email spam, or the recipient reported it. */\n 'complained',\n /** On the organization's do-not-contact list — a member's or the runtime's entry. */\n 'do_not_contact',\n] as const\n\nexport type EmailStateStatus = (typeof EMAIL_STATE_STATUSES)[number]\n\n/** Which sender's verdict it is. */\nexport type EmailStateSource = 'sequence' | 'campaign' | 'member'\n\n/** The field on the lead and the contact. */\nexport const EMAIL_STATE_FIELD = 'emailState'\n\nexport interface EmailState {\n status: EmailStateStatus\n /** When the verdict was given, epoch ms. */\n atMs: number\n source: EmailStateSource\n /** The sender's own words: the bounce's diagnostic, a member's note. */\n detail: string | null\n /** The sequence enrollment it came from, when one did. */\n enrollmentId?: string\n}\n\n/** How a state reads on the chip — typed so a state cannot ship unlabeled. */\nexport const EMAIL_STATE_LABELS: Record<EmailStateStatus, string> = {\n ok: 'Email OK',\n bounced: 'Bounced',\n blocked: 'Blocked by their mail gateway',\n unsubscribed: 'Unsubscribed',\n complained: 'Marked as spam',\n do_not_contact: 'Do not contact',\n}\n\n/**\n * The rank a later verdict must reach to replace an earlier one. A member's\n * do-not-contact mark stands over every automatic verdict; a complaint\n * over an unsubscribe, which is the person's own act over a server's; a\n * block over a bounce, since the block names the whole domain; and `ok`\n * over nothing — a release is written on purpose, by a path that forces it.\n */\nconst EMAIL_STATE_RANK: Record<EmailStateStatus, number> = {\n ok: 0,\n bounced: 1,\n blocked: 2,\n unsubscribed: 3,\n complained: 4,\n do_not_contact: 5,\n}\n\nexport function isEmailStateStatus(value: unknown): value is EmailStateStatus {\n return typeof value === 'string' && (EMAIL_STATE_STATUSES as readonly string[]).includes(value)\n}\n\n/** The state a record holds, held to its shape, or `null` when it holds none. */\nexport function readEmailState(record: Record<string, unknown> | null | undefined): EmailState | null {\n const raw = record?.[EMAIL_STATE_FIELD]\n if (!raw || typeof raw !== 'object') return null\n const state = raw as Record<string, unknown>\n if (!isEmailStateStatus(state['status'])) return null\n const atMs = Number(state['atMs'])\n const source = state['source']\n const enrollmentId = typeof state['enrollmentId'] === 'string' && state['enrollmentId'] ? state['enrollmentId'] : undefined\n return {\n status: state['status'],\n atMs: Number.isFinite(atMs) && atMs > 0 ? atMs : 0,\n source: source === 'sequence' || source === 'campaign' || source === 'member' ? source : 'campaign',\n detail: typeof state['detail'] === 'string' && state['detail'] ? state['detail'] : null,\n ...(enrollmentId ? { enrollmentId } : {}),\n }\n}\n\n/** Whether the state means the address must not be emailed by hand. */\nexport function emailStateForbidsEmail(state: EmailState | null | undefined): boolean {\n return Boolean(state) && state?.status !== 'ok'\n}\n\n/**\n * The state a record holds after one more verdict: the incoming one when it\n * ranks at least as high as the current — a fresh bounce replaces an old\n * one, a block replaces a bounce, a member's mark replaces anything — else\n * the current. `force` writes the incoming state whatever stands, which is\n * how a release lands.\n */\nexport function nextEmailState(\n current: EmailState | null | undefined,\n incoming: EmailState,\n options: { force?: boolean } = {},\n): EmailState {\n if (!current || options.force) return incoming\n return EMAIL_STATE_RANK[incoming.status] >= EMAIL_STATE_RANK[current.status] ? incoming : current\n}\n\n/** When the verdict was given, as the chip's tooltip and the refusal say it. */\nexport function emailStateWhen(state: Pick<EmailState, 'atMs'>): string {\n if (!state.atMs) return ''\n try {\n return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(state.atMs)\n } catch {\n return new Date(state.atMs).toDateString()\n }\n}\n\n/**\n * Why the record must not be emailed, in one sentence a disabled button or\n * a refused enrollment can show — or `null` when it may be.\n */\nexport function emailStateRefusal(state: EmailState | null | undefined): string | null {\n if (!state || !emailStateForbidsEmail(state)) return null\n const when = emailStateWhen(state)\n const since = when ? ` on ${when}` : ''\n const said = state.detail ? ` ${state.detail}` : ''\n switch (state.status) {\n case 'bounced':\n return `Email to this address bounced${since}: the mailbox does not exist.${said}`\n case 'blocked':\n return `Their mail gateway blocked email to this address${since}.${said}`\n case 'unsubscribed':\n return `They unsubscribed${since}.${said}`\n case 'complained':\n return `They marked an email as spam${since}.${said}`\n case 'do_not_contact':\n return `On the do-not-contact list${since}.${said}`\n default:\n return null\n }\n}\n"],"names":["EMAIL_STATE_STATUSES","EMAIL_STATE_FIELD","EMAIL_STATE_LABELS","ok","bounced","blocked","unsubscribed","complained","do_not_contact","EMAIL_STATE_RANK","isEmailStateStatus","value","includes","readEmailState","record","raw","state","atMs","Number","source","enrollmentId","undefined","status","isFinite","detail","emailStateForbidsEmail","Boolean","nextEmailState","current","incoming","options","force","emailStateWhen","Intl","DateTimeFormat","dateStyle","format","Date","toDateString","emailStateRefusal","when","since","said"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-state.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 RECORD'S EMAIL STATE (AGL-3245).\n *\n * The lists the senders consult — the platform suppression list, a site's\n * suppressions, an organization's do-not-contact list — are keyed by a\n * hash nobody can read. The record a person opens is the lead or the\n * contact, and until now it said nothing: an address that hard-bounced at\n * 10:30 still read as \"Sent\" at 12:30, and a member reading it would have\n * emailed the person again by hand.\n *\n * So the record carries `emailState`: what the last verdict on its address\n * was, when, from which sender, and in the sender's own words. It is\n * WRITTEN BY THE PLATFORM ONLY — the outreach runtime, the campaign\n * webhook, a member's do-not-contact mark — never by a form, an import or\n * the record's own editor, and the rules hold clients off the field. It\n * is a mirror of the lists, not a list: a send still asks the lists, and\n * the record says what they hold in words a person can act on.\n *\n * Absent means nothing is known. `ok` is a verdict too — a release, a\n * confirmed re-subscription — and it is the one state that never\n * overwrites a refusal on its own: the ranking below decides.\n *==========================================*/\n\n/** Every state, weakest first. */\nexport const EMAIL_STATE_STATUSES = [\n /** The address may be emailed: released, or confirmed again. */\n 'ok',\n /**\n * Mail to it WOULD bounce: its domain has no mail server, or publishes a\n * null MX (AGL-3328). A prediction from DNS rather than a bounce, so the\n * weakest refusal: any real verdict replaces it, and the check clears it\n * when the domain starts taking mail.\n */\n 'undeliverable',\n /** Mail to it bounced for good: the mailbox does not exist. */\n 'bounced',\n /** The recipient's mail gateway refused the sender on policy or reputation. */\n 'blocked',\n /** The unsubscribe link or header was used. */\n 'unsubscribed',\n /** A reply called the email spam, or the recipient reported it. */\n 'complained',\n /** On the organization's do-not-contact list — a member's or the runtime's entry. */\n 'do_not_contact',\n] as const\n\nexport type EmailStateStatus = (typeof EMAIL_STATE_STATUSES)[number]\n\n/**\n * Which sender's verdict it is. `check` is the deliverability check\n * (AGL-3328), which reads the domain's DNS when the record is captured and\n * before a send, and says what it found before anything bounces.\n */\nexport type EmailStateSource = 'sequence' | 'campaign' | 'member' | 'check'\n\nconst EMAIL_STATE_SOURCES: readonly EmailStateSource[] = ['sequence', 'campaign', 'member', 'check']\n\n/** The field on the lead and the contact. */\nexport const EMAIL_STATE_FIELD = 'emailState'\n\nexport interface EmailState {\n status: EmailStateStatus\n /** When the verdict was given, epoch ms. */\n atMs: number\n source: EmailStateSource\n /** The sender's own words: the bounce's diagnostic, a member's note. */\n detail: string | null\n /** The sequence enrollment it came from, when one did. */\n enrollmentId?: string\n}\n\n/** How a state reads on the chip — typed so a state cannot ship unlabeled. */\nexport const EMAIL_STATE_LABELS: Record<EmailStateStatus, string> = {\n ok: 'Email OK',\n undeliverable: 'Would bounce',\n bounced: 'Bounced',\n blocked: 'Blocked by their mail gateway',\n unsubscribed: 'Unsubscribed',\n complained: 'Marked as spam',\n do_not_contact: 'Do not contact',\n}\n\n/**\n * The rank a later verdict must reach to replace an earlier one. A member's\n * do-not-contact mark stands over every automatic verdict; a complaint\n * over an unsubscribe, which is the person's own act over a server's; a\n * block over a bounce, since the block names the whole domain; and `ok`\n * over nothing — a release is written on purpose, by a path that forces it.\n */\nconst EMAIL_STATE_RANK: Record<EmailStateStatus, number> = {\n ok: 0,\n undeliverable: 1,\n bounced: 2,\n blocked: 3,\n unsubscribed: 4,\n complained: 5,\n do_not_contact: 6,\n}\n\nexport function isEmailStateStatus(value: unknown): value is EmailStateStatus {\n return typeof value === 'string' && (EMAIL_STATE_STATUSES as readonly string[]).includes(value)\n}\n\n/** The state a record holds, held to its shape, or `null` when it holds none. */\nexport function readEmailState(record: Record<string, unknown> | null | undefined): EmailState | null {\n const raw = record?.[EMAIL_STATE_FIELD]\n if (!raw || typeof raw !== 'object') return null\n const state = raw as Record<string, unknown>\n if (!isEmailStateStatus(state['status'])) return null\n const atMs = Number(state['atMs'])\n const source = state['source']\n const enrollmentId = typeof state['enrollmentId'] === 'string' && state['enrollmentId'] ? state['enrollmentId'] : undefined\n return {\n status: state['status'],\n atMs: Number.isFinite(atMs) && atMs > 0 ? atMs : 0,\n source: (EMAIL_STATE_SOURCES as readonly unknown[]).includes(source) ? (source as EmailStateSource) : 'campaign',\n detail: typeof state['detail'] === 'string' && state['detail'] ? state['detail'] : null,\n ...(enrollmentId ? { enrollmentId } : {}),\n }\n}\n\n/** Whether the state means the address must not be emailed by hand. */\nexport function emailStateForbidsEmail(state: EmailState | null | undefined): boolean {\n return Boolean(state) && state?.status !== 'ok'\n}\n\n/**\n * The state a record holds after one more verdict: the incoming one when it\n * ranks at least as high as the current — a fresh bounce replaces an old\n * one, a block replaces a bounce, a member's mark replaces anything — else\n * the current. `force` writes the incoming state whatever stands, which is\n * how a release lands.\n */\nexport function nextEmailState(\n current: EmailState | null | undefined,\n incoming: EmailState,\n options: { force?: boolean } = {},\n): EmailState {\n if (!current || options.force) return incoming\n return EMAIL_STATE_RANK[incoming.status] >= EMAIL_STATE_RANK[current.status] ? incoming : current\n}\n\n/** When the verdict was given, as the chip's tooltip and the refusal say it. */\nexport function emailStateWhen(state: Pick<EmailState, 'atMs'>): string {\n if (!state.atMs) return ''\n try {\n return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(state.atMs)\n } catch {\n return new Date(state.atMs).toDateString()\n }\n}\n\n/**\n * Why the record must not be emailed, in one sentence a disabled button or\n * a refused enrollment can show — or `null` when it may be.\n */\nexport function emailStateRefusal(state: EmailState | null | undefined): string | null {\n if (!state || !emailStateForbidsEmail(state)) return null\n const when = emailStateWhen(state)\n const since = when ? ` on ${when}` : ''\n const said = state.detail ? ` ${state.detail}` : ''\n switch (state.status) {\n case 'undeliverable':\n return `Email to this address would bounce: its domain has no mail server${since ? ` (checked${since})` : ''}.${said}`\n case 'bounced':\n return `Email to this address bounced${since}: the mailbox does not exist.${said}`\n case 'blocked':\n return `Their mail gateway blocked email to this address${since}.${said}`\n case 'unsubscribed':\n return `They unsubscribed${since}.${said}`\n case 'complained':\n return `They marked an email as spam${since}.${said}`\n case 'do_not_contact':\n return `On the do-not-contact list${since}.${said}`\n default:\n return null\n }\n}\n"],"names":["EMAIL_STATE_STATUSES","EMAIL_STATE_SOURCES","EMAIL_STATE_FIELD","EMAIL_STATE_LABELS","ok","undeliverable","bounced","blocked","unsubscribed","complained","do_not_contact","EMAIL_STATE_RANK","isEmailStateStatus","value","includes","readEmailState","record","raw","state","atMs","Number","source","enrollmentId","undefined","status","isFinite","detail","emailStateForbidsEmail","Boolean","nextEmailState","current","incoming","options","force","emailStateWhen","Intl","DateTimeFormat","dateStyle","format","Date","toDateString","emailStateRefusal","when","since","said"],"mappings":"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"}
@@ -170,6 +170,30 @@ export interface FormDocument<N = AglynNodeSchema> {
170
170
  * about presence rather than about what kind of clock wrote it.
171
171
  */
172
172
  archivedAt?: unknown;
173
+ /**
174
+ * Whether the form is retired, as a boolean the Forms list can QUERY
175
+ * (AGL-3330). {@link archivedAt} stays the fact every other reader asks,
176
+ * through {@link isFormArchived}; this mirrors it because Firestore cannot
177
+ * find a document by the absence of a field, and "in use" is an absence
178
+ * there. Written beside `archivedAt` by the one writer that retires a form,
179
+ * `false` on every create — see {@link newFormListFields}.
180
+ */
181
+ retired?: boolean;
182
+ /** The campaigns the form is filed under — see {@link formCampaignFields}. */
183
+ campaignIds?: string[];
184
+ /**
185
+ * Whether the form is in any campaign, as a boolean the Forms list can
186
+ * QUERY (AGL-3330): "in no campaign" is an empty array, and Firestore can
187
+ * neither find a document by an absent field nor ask an array for its
188
+ * length. Written beside `campaignIds` by every writer of it, through
189
+ * {@link formCampaignFields}.
190
+ */
191
+ inCampaign?: boolean;
192
+ /** The Forms list's search fields — see {@link formListFields}. */
193
+ nameLower?: string;
194
+ nameTokens?: string[];
195
+ nameReversed?: string;
196
+ searchTokens?: string[];
173
197
  rootId?: NodeId;
174
198
  nodes?: Record<NodeId, N>;
175
199
  /**
@@ -204,6 +228,150 @@ export interface FormDocument<N = AglynNodeSchema> {
204
228
  * must not re-derive this from a display flag; the field is the fact.
205
229
  */
206
230
  export declare function isFormArchived(form: Pick<FormDocument, 'archivedAt'> | null | undefined): boolean;
231
+ /** The search fields the Forms list queries — see {@link formListFields}. */
232
+ export interface FormListSearchFields {
233
+ nameLower: string;
234
+ nameTokens: string[];
235
+ nameReversed: string;
236
+ searchTokens: string[];
237
+ }
238
+ /**
239
+ * The fields the Forms list FILTERS AND SEARCHES BY ON THE QUERY (AGL-3330).
240
+ *
241
+ * The list pages a site's catalog, so a filter that matched the rows already
242
+ * read would answer "no such form" for one on a later page. Firestore has no
243
+ * case-insensitive or substring match, so the name travels with the keys
244
+ * every document named by `displayName` carries
245
+ * (`displayNameSearchFields`): `nameLower`, `nameTokens`, `nameReversed`.
246
+ * The Display name filter asks `nameTokens`.
247
+ *
248
+ * `searchTokens` is what the list's search box asks with one
249
+ * `array-contains`: the word prefixes of the name AND of the slug (its words
250
+ * are hyphen-joined) AND the lower-cased prefixes of the form's id, because
251
+ * the box finds a form by any of the three. A slug usually repeats its name's
252
+ * words, so the union mostly costs nothing; it earns its place on a form
253
+ * renamed after its slug was minted.
254
+ *
255
+ * `tools/scripts/lib/site-form-list-fields.mjs` is the script-side twin, and both
256
+ * answer `tools/scripts/lib/site-form-list-fields.fixtures.json`.
257
+ *
258
+ * ⚠️ Spread at EVERY write that sets `displayName` or `slug`, and at create.
259
+ * A rename that skips it leaves the keys naming the old name, and the form is
260
+ * then findable only by what it used to be called, while it still lists
261
+ * normally.
262
+ */
263
+ export declare function formListFields(form: {
264
+ id: string;
265
+ displayName?: unknown;
266
+ slug?: unknown;
267
+ }): FormListSearchFields;
268
+ /**
269
+ * The campaign fields a form is written with, from the campaigns it is filed
270
+ * under (AGL-3330): the normalized ids, and `inCampaign`, the boolean the
271
+ * Forms list's "In a campaign" filter asks by equality.
272
+ *
273
+ * ⚠️ The ONE way to write a form's `campaignIds`. A writer that sets the ids
274
+ * without `inCampaign` leaves the form answering the filter by what it used
275
+ * to be filed under; `apps/console/specs/form-writers-use-the-helpers.spec.ts`
276
+ * refuses one. The campaign deletion pass removes an id with `arrayRemove`
277
+ * and restamps `inCampaign` from the form as it then stands.
278
+ */
279
+ export declare function formCampaignFields(selected: unknown): {
280
+ campaignIds: string[];
281
+ inCampaign: boolean;
282
+ };
283
+ /**
284
+ * The `stats.leads` a form holds when nothing has been counted for it.
285
+ *
286
+ * A form that routes leads holds `0` until its first lead, so "Leads = 0"
287
+ * finds the lead surfaces that have produced nothing yet. A form that does
288
+ * not route leads holds `null`: it has no leads measurement, which is what
289
+ * "Leads is empty" asks. The recount and every writer of the switch agree
290
+ * through this and {@link formCountersFromSource}.
291
+ */
292
+ export declare function formLeadsStatWhenUncounted(routesLeads: boolean): 0 | null;
293
+ /**
294
+ * What a NEW form is written with so the Forms list can query every filter
295
+ * it offers (AGL-3330): its search keys, and an explicit value for each
296
+ * field a query would otherwise have to find by its absence.
297
+ *
298
+ * - `retired: false`, the queryable mirror of an unset `archivedAt`.
299
+ * - `routing.lead` as a boolean, `false` unless the form routes to leads, so
300
+ * "Lead routing is off" is an equality rather than a missing field.
301
+ * - `inCampaign`, from the campaigns the form is created in.
302
+ * - `stats` with a NULL for each counter, except `leads` on a form that
303
+ * routes leads, which starts at `0` (see {@link formLeadsStatWhenUncounted}).
304
+ * Null, never zero, where nothing is measured: a zero would claim a
305
+ * measurement. `/api/forms/submit` increments these, and an increment on
306
+ * null starts from nothing, so it needs no change.
307
+ *
308
+ * `routing` keeps whatever else it carries (a dataset binding).
309
+ */
310
+ export declare function newFormListFields(form: {
311
+ id: string;
312
+ displayName?: unknown;
313
+ slug?: unknown;
314
+ routing?: FormRouting | null;
315
+ campaignIds?: unknown;
316
+ }): FormListSearchFields & {
317
+ retired: false;
318
+ routing: FormRouting;
319
+ inCampaign: boolean;
320
+ stats: {
321
+ submissions: null;
322
+ leads: 0 | null;
323
+ lastSubmissionAtMs: null;
324
+ };
325
+ };
326
+ /** The source a lead filed by a form carries in `sources`. */
327
+ export declare const FORM_LEAD_SOURCE_PREFIX = "form:";
328
+ /** `form:{formId}` — the lead source a form's capture writes. */
329
+ export declare function formLeadSource(formId: string): string;
330
+ /** The form ids a lead's `sources` name, in order, deduplicated. */
331
+ export declare function formIdsOfLeadSources(sources: unknown): string[];
332
+ /** The three counters the Forms list filters by. */
333
+ export interface FormCounterStats {
334
+ submissions: number | null;
335
+ leads: number | null;
336
+ lastSubmissionAtMs: number | null;
337
+ }
338
+ /** The counter fields, in reading order. */
339
+ export declare const FORM_COUNTER_FIELDS: readonly (keyof FormCounterStats)[];
340
+ /**
341
+ * The counters a form should hold, from the rows counted.
342
+ *
343
+ * Zero submissions is `null`, the list's "none yet", matching a new form.
344
+ * Leads follow {@link formLeadsStatWhenUncounted} when there are none — and
345
+ * a form that stopped routing leads keeps the leads it did file, because
346
+ * those people are still on the list.
347
+ */
348
+ export declare function formCountersFromSource(source: {
349
+ submissions: number;
350
+ leads: number;
351
+ newestSubmissionAtMs: number | null;
352
+ routesLeads: boolean;
353
+ }): FormCounterStats;
354
+ /**
355
+ * How far the stored `lastSubmissionAtMs` may sit from the newest
356
+ * submission's `createdAt` and still agree.
357
+ *
358
+ * The submit path stamps the counter from its own clock after it has written
359
+ * the submission, whose `createdAt` is the server's commit time, and a lead
360
+ * capture runs between the two — so a counter that is right reads a little
361
+ * after the row. Ten minutes is far past that gap and far short of two
362
+ * submissions a merchant would tell apart.
363
+ */
364
+ export declare const FORM_LAST_SUBMISSION_TOLERANCE_MS: number;
365
+ /**
366
+ * The counters on which `stored` disagrees with `recounted`, in reading
367
+ * order; empty when the form holds what its rows say.
368
+ *
369
+ * An ABSENT counter disagrees with a `null` one: "is empty" asks
370
+ * `== null`, which a missing field does not answer.
371
+ */
372
+ export declare function formCounterDrift(stored: Record<string, unknown> | null | undefined, recounted: FormCounterStats): (keyof FormCounterStats)[];
373
+ /** The dotted-path update that puts `recounted` on a form. */
374
+ export declare function formCounterPatch(recounted: FormCounterStats): Record<string, number | null>;
207
375
  /**
208
376
  * One entry in a form's `versions` subcollection.
209
377
  *
@@ -226,12 +394,15 @@ export interface FormVersion<N = AglynNodeSchema> {
226
394
  * bound and is the one the customer is billed on; a console surface that
227
395
  * counted it on render would be the expensive-read shape this product has
228
396
  * created repeatedly. `hosts/{hostId}/overlays/{overlayId}.stats` is the same
229
- * pattern with the same reasoning.
397
+ * pattern with the same reasoning. What an increment cannot see — a deleted
398
+ * submission, an erased lead, a failed write — is put back by the RECOUNT
399
+ * ({@link formCountersFromSource}), which runs after a removal and over every
400
+ * form from `tools/scripts/recount-form-stats.mjs`; never on render.
230
401
  */
231
402
  export interface FormStats {
232
- submissions?: number;
233
- leads?: number;
234
- lastSubmissionAtMs?: number;
403
+ submissions?: number | null;
404
+ leads?: number | null;
405
+ lastSubmissionAtMs?: number | null;
235
406
  /**
236
407
  * Form views, counted by the beacon at `/api/analytics/collect` — one per
237
408
  * rendered form on a live page, the same shape and the same cost as an
@@ -352,9 +523,10 @@ export declare function formPeriodKey(atMs: number | null | undefined): string |
352
523
  *
353
524
  * Every counter is `number | null` and the distinction is load-bearing. A
354
525
  * counter is `null` when nothing was recorded for it at all: `leads` is
355
- * incremented only by a form whose `routing.lead` is set, so a form that does
526
+ * counted only for a form whose `routing.lead` is set, so a form that does
356
527
  * not route leads has no leads measurement rather than a measured zero, and a
357
- * `0` in that slot states a result nobody took.
528
+ * `0` in that slot states a result nobody took. A form that routes leads and
529
+ * has filed none holds `0`, which is a measurement.
358
530
  */
359
531
  export interface FormStatsTotals {
360
532
  views: number | null;