@aglyn/plugins-outreach 1.0.0-beta.221 → 1.0.0-beta.223

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 (116) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/do-not-contact-domains.d.ts +6 -2
  3. package/src/lib/components/do-not-contact-domains.js +73 -11
  4. package/src/lib/components/do-not-contact-domains.js.map +1 -1
  5. package/src/lib/components/enrollments-table.d.ts +6 -0
  6. package/src/lib/components/enrollments-table.js +77 -4
  7. package/src/lib/components/enrollments-table.js.map +1 -1
  8. package/src/lib/components/sequence-detail.js +3 -1
  9. package/src/lib/components/sequence-detail.js.map +1 -1
  10. package/src/lib/components/sequence-report-card.js +7 -3
  11. package/src/lib/components/sequence-report-card.js.map +1 -1
  12. package/src/lib/components/use-outreach-crm.d.ts +18 -15
  13. package/src/lib/components/use-outreach-crm.js +118 -87
  14. package/src/lib/components/use-outreach-crm.js.map +1 -1
  15. package/src/lib/components/use-outreach-data.d.ts +2 -2
  16. package/src/lib/components/use-outreach-data.js.map +1 -1
  17. package/src/lib/constants/transfer-resources.d.ts +33 -0
  18. package/src/lib/constants/transfer-resources.js +38 -0
  19. package/src/lib/constants/transfer-resources.js.map +1 -0
  20. package/src/lib/declarations.console-server.d.ts +7 -1
  21. package/src/lib/declarations.console-server.js +66 -1
  22. package/src/lib/declarations.console-server.js.map +1 -1
  23. package/src/lib/engine/click-tracking.js +1 -1
  24. package/src/lib/engine/click-tracking.js.map +1 -1
  25. package/src/lib/engine/ip-range.d.ts +26 -0
  26. package/src/lib/engine/ip-range.js +78 -0
  27. package/src/lib/engine/ip-range.js.map +1 -0
  28. package/src/lib/engine/open-source.d.ts +24 -0
  29. package/src/lib/engine/open-source.js +130 -0
  30. package/src/lib/engine/open-source.js.map +1 -0
  31. package/src/lib/engine/open-tracking.d.ts +15 -2
  32. package/src/lib/engine/open-tracking.js +59 -16
  33. package/src/lib/engine/open-tracking.js.map +1 -1
  34. package/src/lib/engine/outreach-access.d.ts +47 -0
  35. package/src/lib/engine/outreach-access.js +56 -0
  36. package/src/lib/engine/outreach-access.js.map +1 -0
  37. package/src/lib/enrollment/enroll-people.d.ts +10 -10
  38. package/src/lib/enrollment/enroll-people.js +30 -25
  39. package/src/lib/enrollment/enroll-people.js.map +1 -1
  40. package/src/lib/enrollment/gate-lookups.js +30 -15
  41. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  42. package/src/lib/enrollment/message-templates.d.ts +24 -0
  43. package/src/lib/enrollment/message-templates.js +35 -0
  44. package/src/lib/enrollment/message-templates.js.map +1 -0
  45. package/src/lib/model/enrollment-engagement.d.ts +16 -0
  46. package/src/lib/model/enrollment-engagement.js +16 -0
  47. package/src/lib/model/enrollment-engagement.js.map +1 -1
  48. package/src/lib/model/enrollment-history.d.ts +8 -1
  49. package/src/lib/model/enrollment-history.js +17 -5
  50. package/src/lib/model/enrollment-history.js.map +1 -1
  51. package/src/lib/model/enrollment-timeline.d.ts +3 -0
  52. package/src/lib/model/enrollment-timeline.js +15 -4
  53. package/src/lib/model/enrollment-timeline.js.map +1 -1
  54. package/src/lib/model/outreach.types.d.ts +12 -3
  55. package/src/lib/model/outreach.types.js +12 -1
  56. package/src/lib/model/outreach.types.js.map +1 -1
  57. package/src/lib/model/sequence-report.d.ts +14 -7
  58. package/src/lib/model/sequence-report.js +12 -8
  59. package/src/lib/model/sequence-report.js.map +1 -1
  60. package/src/lib/plugin.js +18 -0
  61. package/src/lib/plugin.js.map +1 -1
  62. package/src/lib/routes/curate-routes.js +4 -6
  63. package/src/lib/routes/curate-routes.js.map +1 -1
  64. package/src/lib/routes/enroll-routes.d.ts +0 -12
  65. package/src/lib/routes/enroll-routes.js +52 -92
  66. package/src/lib/routes/enroll-routes.js.map +1 -1
  67. package/src/lib/routes/platform-deps.d.ts +4 -16
  68. package/src/lib/routes/platform-deps.js +5 -25
  69. package/src/lib/routes/platform-deps.js.map +1 -1
  70. package/src/lib/routes/preview-routes.js +25 -13
  71. package/src/lib/routes/preview-routes.js.map +1 -1
  72. package/src/lib/routes/register-routes.js +7 -5
  73. package/src/lib/routes/register-routes.js.map +1 -1
  74. package/src/lib/routes/route-gate.js +6 -17
  75. package/src/lib/routes/route-gate.js.map +1 -1
  76. package/src/lib/routes/sequence-routes.d.ts +46 -0
  77. package/src/lib/routes/sequence-routes.js +155 -63
  78. package/src/lib/routes/sequence-routes.js.map +1 -1
  79. package/src/lib/runtime/click-events.js +8 -8
  80. package/src/lib/runtime/click-events.js.map +1 -1
  81. package/src/lib/runtime/click-route.js +5 -2
  82. package/src/lib/runtime/click-route.js.map +1 -1
  83. package/src/lib/runtime/lead-records.d.ts +18 -10
  84. package/src/lib/runtime/lead-records.js +14 -60
  85. package/src/lib/runtime/lead-records.js.map +1 -1
  86. package/src/lib/runtime/open-events.d.ts +8 -0
  87. package/src/lib/runtime/open-events.js +10 -3
  88. package/src/lib/runtime/open-events.js.map +1 -1
  89. package/src/lib/runtime/platform-runtime-deps.d.ts +9 -5
  90. package/src/lib/runtime/platform-runtime-deps.js +31 -24
  91. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  92. package/src/lib/runtime/send-job.js +20 -17
  93. package/src/lib/runtime/send-job.js.map +1 -1
  94. package/src/lib/runtime/sync-job.js +10 -5
  95. package/src/lib/runtime/sync-job.js.map +1 -1
  96. package/src/lib/storage/do-not-contact-store.d.ts +8 -0
  97. package/src/lib/storage/do-not-contact-store.js +13 -0
  98. package/src/lib/storage/do-not-contact-store.js.map +1 -1
  99. package/src/lib/testing/stand-in-record-system.d.ts +54 -0
  100. package/src/lib/testing/stand-in-record-system.js +243 -0
  101. package/src/lib/testing/stand-in-record-system.js.map +1 -0
  102. package/src/lib/transfer/do-not-contact-transfer.d.ts +129 -0
  103. package/src/lib/transfer/do-not-contact-transfer.js +801 -0
  104. package/src/lib/transfer/do-not-contact-transfer.js.map +1 -0
  105. package/src/lib/transfer/plan-gate.d.ts +25 -0
  106. package/src/lib/transfer/plan-gate.js +35 -0
  107. package/src/lib/transfer/plan-gate.js.map +1 -0
  108. package/src/lib/transfer/platform-transfer-deps.d.ts +24 -0
  109. package/src/lib/transfer/platform-transfer-deps.js +39 -0
  110. package/src/lib/transfer/platform-transfer-deps.js.map +1 -0
  111. package/src/lib/transfer/sequences-package.d.ts +48 -0
  112. package/src/lib/transfer/sequences-package.js +93 -0
  113. package/src/lib/transfer/sequences-package.js.map +1 -0
  114. package/src/lib/transfer/sequences-package.server.d.ts +31 -0
  115. package/src/lib/transfer/sequences-package.server.js +207 -0
  116. package/src/lib/transfer/sequences-package.server.js.map +1 -0
@@ -0,0 +1,33 @@
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 { MatchKeySpec } from '@aglyn/aglyn/data-transfer';
18
+ /**
19
+ * What the console's boot and the console registrar name the do-not-contact
20
+ * transfer resource by, kept apart from the resource itself
21
+ * (`transfer/do-not-contact-transfer.ts`) so registering it loads nothing.
22
+ */
23
+ /** The resource key, as `plugins.config.json` declares it. */
24
+ export declare const OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY = "outreach.do-not-contact";
25
+ /** What the hub and the wizard call it. */
26
+ export declare const OUTREACH_DO_NOT_CONTACT_TRANSFER_LABEL = "Do-not-contact list";
27
+ /**
28
+ * The keys a row finds its entry by, read synchronously at registration: the
29
+ * Aglyn ID (`id`, the core's `TRANSFER_ID_FIELD`), then the entry itself —
30
+ * an address or a domain, which the resource's `lookup` resolves to the
31
+ * document it names.
32
+ */
33
+ export declare const OUTREACH_DNC_MATCH_KEYS: readonly MatchKeySpec[];
@@ -0,0 +1,38 @@
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
+ * What the console's boot and the console registrar name the do-not-contact
18
+ * transfer resource by, kept apart from the resource itself
19
+ * (`transfer/do-not-contact-transfer.ts`) so registering it loads nothing.
20
+ */ /** The resource key, as `plugins.config.json` declares it. */ export const OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY = 'outreach.do-not-contact';
21
+ /** What the hub and the wizard call it. */ export const OUTREACH_DO_NOT_CONTACT_TRANSFER_LABEL = 'Do-not-contact list';
22
+ /**
23
+ * The keys a row finds its entry by, read synchronously at registration: the
24
+ * Aglyn ID (`id`, the core's `TRANSFER_ID_FIELD`), then the entry itself —
25
+ * an address or a domain, which the resource's `lookup` resolves to the
26
+ * document it names.
27
+ */ export const OUTREACH_DNC_MATCH_KEYS = [
28
+ {
29
+ fieldId: 'id',
30
+ normalizer: 'aglynId'
31
+ },
32
+ {
33
+ fieldId: 'entry',
34
+ normalizer: 'caseless'
35
+ }
36
+ ];
37
+
38
+ //# sourceMappingURL=transfer-resources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/transfer-resources.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 type { MatchKeySpec } from '@aglyn/aglyn/data-transfer'\n\n/**\n * What the console's boot and the console registrar name the do-not-contact\n * transfer resource by, kept apart from the resource itself\n * (`transfer/do-not-contact-transfer.ts`) so registering it loads nothing.\n */\n\n/** The resource key, as `plugins.config.json` declares it. */\nexport const OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY = 'outreach.do-not-contact'\n\n/** What the hub and the wizard call it. */\nexport const OUTREACH_DO_NOT_CONTACT_TRANSFER_LABEL = 'Do-not-contact list'\n\n/**\n * The keys a row finds its entry by, read synchronously at registration: the\n * Aglyn ID (`id`, the core's `TRANSFER_ID_FIELD`), then the entry itself —\n * an address or a domain, which the resource's `lookup` resolves to the\n * document it names.\n */\nexport const OUTREACH_DNC_MATCH_KEYS: readonly MatchKeySpec[] = [\n { fieldId: 'id', normalizer: 'aglynId' },\n { fieldId: 'entry', normalizer: 'caseless' },\n]\n"],"names":["OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY","OUTREACH_DO_NOT_CONTACT_TRANSFER_LABEL","OUTREACH_DNC_MATCH_KEYS","fieldId","normalizer"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;CAIC,GAED,4DAA4D,GAC5D,OAAO,MAAMA,uCAAuC,0BAAyB;AAE7E,yCAAyC,GACzC,OAAO,MAAMC,yCAAyC,sBAAqB;AAE3E;;;;;CAKC,GACD,OAAO,MAAMC,0BAAmD;IAC9D;QAAEC,SAAS;QAAMC,YAAY;IAAU;IACvC;QAAED,SAAS;QAASC,YAAY;IAAW;CAC5C,CAAA"}
@@ -32,7 +32,13 @@
32
32
  * console's `plugin-console-crons` tick and nowhere else, and the person
33
33
  * eraser removes the enrollments a workspace kept about someone it erases.
34
34
  *
35
+ * The do-not-contact list's transfer resource (`outreach.do-not-contact`) is
36
+ * declared here as well: its import writes the list as a member would, and
37
+ * its export reads it, through the Admin SDK the console's transfer routes
38
+ * run on.
39
+ *
35
40
  * Light at boot: every module that does the work is imported when a job, an
36
- * erasure or a tick first asks for it, so the boot cost is the registration.
41
+ * erasure, a tick or a transfer first asks for it, so the boot cost is the
42
+ * registration.
37
43
  */
38
44
  export declare function registerOutreachConsoleServerDeclarations(): void;
@@ -21,9 +21,12 @@ import { listPluginMembershipDetachers, registerPluginMembershipDetacher } from
21
21
  import { listPluginOrgErasers, registerPluginOrgEraser } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
22
22
  import { listPluginLeadConversionListeners, registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
23
23
  import { registerPluginPersonEraser } from "@aglyn/aglyn/plugin-manager/plugin-person-erasure";
24
+ import { registerPluginTransferResource } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
24
25
  import { listPluginUserErasers, registerPluginUserEraser } from "@aglyn/aglyn/plugin-manager/plugin-user-erasure";
25
26
  import { OUTREACH_PLUGIN_ID } from "./constants/bundle-common.js";
27
+ import { OUTREACH_SEQUENCE_PACKAGE_RULES, OUTREACH_SEQUENCES_TRANSFER_KEY, outreachSequenceDependencies, remapOutreachSequenceIds } from "./transfer/sequences-package.js";
26
28
  import { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from "./constants/runtime-jobs.js";
29
+ import { OUTREACH_DNC_MATCH_KEYS, OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY } from "./constants/transfer-resources.js";
27
30
  /**
28
31
  * Outreach's CONSOLE-ONLY server declarations (AGL-2978), named under
29
32
  * `consoleServerDeclarations` in `plugins.config.json` and run at the
@@ -42,9 +45,16 @@ import { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from "./constants/runtime-
42
45
  * console's `plugin-console-crons` tick and nowhere else, and the person
43
46
  * eraser removes the enrollments a workspace kept about someone it erases.
44
47
  *
48
+ * The do-not-contact list's transfer resource (`outreach.do-not-contact`) is
49
+ * declared here as well: its import writes the list as a member would, and
50
+ * its export reads it, through the Admin SDK the console's transfer routes
51
+ * run on.
52
+ *
45
53
  * Light at boot: every module that does the work is imported when a job, an
46
- * erasure or a tick first asks for it, so the boot cost is the registration.
54
+ * erasure, a tick or a transfer first asks for it, so the boot cost is the
55
+ * registration.
47
56
  */ export function registerOutreachConsoleServerDeclarations() {
57
+ registerOutreachTransferResources();
48
58
  // Idempotent against the REGISTRY, so a reset (a spec) registers again.
49
59
  if (!listPluginOrgErasers().includes(OUTREACH_PLUGIN_ID)) {
50
60
  registerPluginOrgEraser(async (request)=>{
@@ -131,6 +141,61 @@ import { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from "./constants/runtime-
131
141
  }, {
132
142
  pluginId: OUTREACH_PLUGIN_ID
133
143
  });
144
+ // The do-not-contact list as a file: an import adds domains and
145
+ // addresses, an export reads the domains. Each hook loads the resource
146
+ // and the Admin SDK the first time a transfer asks.
147
+ registerPluginTransferResource(OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY, lazyDoNotContactTransfer(), {
148
+ pluginId: OUTREACH_PLUGIN_ID
149
+ });
150
+ }
151
+ /**
152
+ * The do-not-contact resource with every hook deferred to its module; the
153
+ * match keys are read at registration, so they come from the light constants.
154
+ */ function lazyDoNotContactTransfer() {
155
+ const load = async ()=>{
156
+ const [{ createOutreachDoNotContactTransferResource }, { platformOutreachTransferDeps }] = await Promise.all([
157
+ import("./transfer/do-not-contact-transfer.js"),
158
+ import("./transfer/platform-transfer-deps.js")
159
+ ]);
160
+ return createOutreachDoNotContactTransferResource(platformOutreachTransferDeps());
161
+ };
162
+ return {
163
+ matchKeys: OUTREACH_DNC_MATCH_KEYS,
164
+ planGate: outreachTransferPlanGate,
165
+ fields: async (ctx)=>(await load()).fields(ctx),
166
+ count: async (ctx, options)=>(await load()).count(ctx, options),
167
+ readPage: async (ctx, cursor, fieldIds, options)=>(await load()).readPage(ctx, cursor, fieldIds, options),
168
+ lookup: async (ctx, requests)=>(await load()).lookup(ctx, requests),
169
+ plan: async (ctx, input)=>(await load()).plan(ctx, input),
170
+ lockedRules: async (ctx)=>(await load()).lockedRules(ctx),
171
+ apply: async (ctx, chunk, writer)=>(await load()).apply(ctx, chunk, writer),
172
+ revert: async (ctx, snapshot, decisions)=>(await load()).revert(ctx, snapshot, decisions)
173
+ };
174
+ }
175
+ /**
176
+ * Sequences' entitlement, asked by the transfer gate for both resources and
177
+ * every intent (AGL-3548); the answer's module loads with the first ask.
178
+ */ const outreachTransferPlanGate = async (subject, intent)=>(await import("./transfer/plan-gate.js")).outreachTransferPlanGate(subject, intent);
179
+ /** The sequences package's server half (AGL-3535), loaded when an import or export first asks. */ const sequencesPackage = async ()=>(await import("./transfer/sequences-package.server.js")).createOutreachSequencesPackage();
180
+ /**
181
+ * Sequences in a workspace package (AGL-3535): what a sequence names and
182
+ * how a reference moves are answered here; reading and writing load the
183
+ * server half on first use.
184
+ */ function registerOutreachTransferResources() {
185
+ registerPluginTransferResource(OUTREACH_SEQUENCES_TRANSFER_KEY, {
186
+ items: async (ctx)=>(await sequencesPackage()).items(ctx),
187
+ dependencies: (item)=>outreachSequenceDependencies(item),
188
+ remapIds: (item, idMap)=>remapOutreachSequenceIds(item, idMap),
189
+ readItems: async (ctx, ids)=>(await sequencesPackage()).readItems(ctx, ids),
190
+ writeItems: async (ctx, items, writer)=>(await sequencesPackage()).writeItems(ctx, items, writer),
191
+ revertItems: async (ctx, steps)=>(await sequencesPackage()).revertItems(ctx, steps),
192
+ problems: async (ctx, write)=>(await sequencesPackage()).problems(ctx, write),
193
+ referenceTargets: async (ctx, kinds)=>(await sequencesPackage()).referenceTargets(ctx, kinds),
194
+ rules: OUTREACH_SEQUENCE_PACKAGE_RULES,
195
+ planGate: outreachTransferPlanGate
196
+ }, {
197
+ pluginId: OUTREACH_PLUGIN_ID
198
+ });
134
199
  }
135
200
  /** The runtime's platform reach, loaded when a job or an erasure first runs. */ const runtime = ()=>import("./runtime/platform-runtime-deps.js");
136
201
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/outreach/src/lib/declarations.console-server.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// Each registry from its own module, not the `@aglyn/aglyn/server` barrel:\n// boot needs one registry, not the whole server surface.\nimport { registerPluginConsoleCron } from '@aglyn/aglyn/plugin-manager/plugin-console-crons'\nimport {\n listPluginMembershipDetachers,\n registerPluginMembershipDetacher,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport {\n listPluginOrgErasers,\n registerPluginOrgEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport {\n listPluginLeadConversionListeners,\n registerPluginLeadConversionListener,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport {\n listPluginUserErasers,\n registerPluginUserEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { OUTREACH_PLUGIN_ID } from './constants/bundle-common'\nimport { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from './constants/runtime-jobs'\n\n/**\n * Outreach's CONSOLE-ONLY server declarations (AGL-2978), named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * The erasers are registered here rather than from `serverDeclarations`,\n * which the tenant runtime also loads: the workspace eraser revokes each\n * rep's Google grant, and the account eraser (AGL-3106) revokes and deletes\n * the person's own, and both open a sealed token with `OUTREACH_TOKEN_KEY`,\n * which only the console holds. The tenant may not so much as bundle the code\n * that does (`outreach-credential-isolation.spec.ts`). Every erasure runs in\n * the console, so this is where the erasers are needed.\n *\n * The sending runtime (AGL-2981) is declared here too, for the same reason:\n * the send and sync jobs open each rep's sealed grant, so they run on the\n * console's `plugin-console-crons` tick and nowhere else, and the person\n * eraser removes the enrollments a workspace kept about someone it erases.\n *\n * Light at boot: every module that does the work is imported when a job, an\n * erasure or a tick first asks for it, so the boot cost is the registration.\n */\nexport function registerOutreachConsoleServerDeclarations(): void {\n // Idempotent against the REGISTRY, so a reset (a spec) registers again.\n if (!listPluginOrgErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginOrgEraser(\n async (request) => {\n const { createOutreachOrgEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachOrgEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n if (!listPluginUserErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginUserEraser(\n async (request) => {\n const { createOutreachUserEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachUserEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // A person erased from a workspace takes their enrollments with them; the\n // do-not-contact entry stays, stripped of anything that names them.\n registerPluginPersonEraser(\n async (request) => (await runtime()).platformOutreachPersonEraser()(request),\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n // A lead that converts takes its enrollments to the contact it became\n // (AGL-3234): the same document, now naming the contact — and every\n // enrollment made on it credits `converted` to its campaigns (AGL-3254).\n if (!listPluginLeadConversionListeners().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginLeadConversionListener(\n async (request) => {\n const [{ followOutreachLeadToContact }, { creditOutreachLeadConversion }, platform] = await Promise.all([\n import('./runtime/lead-records'),\n import('./runtime/campaign-credit'),\n runtime(),\n ])\n const deps = platform.platformOutreachRuntimeDeps()\n const credited = await creditOutreachLeadConversion(deps, request)\n const followed = await followOutreachLeadToContact(deps.firestore(), request)\n return { ...followed, campaignsCredited: credited }\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // A deleted campaign comes off every sequence and enrollment naming it\n // (AGL-3254), before the marketing plugin removes the container.\n if (!listPluginMembershipDetachers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginMembershipDetacher(\n async (request) => {\n const [{ createOutreachCampaignDetacher }, platform] = await Promise.all([\n import('./runtime/campaign-detach'),\n runtime(),\n ])\n return createOutreachCampaignDetacher({ firestore: platform.platformOutreachRuntimeDeps().firestore })(\n request,\n )\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // The sending runtime, on the console's fifteen-minute tick.\n registerPluginConsoleCron(\n {\n id: OUTREACH_SEND_JOB_ID,\n label: 'Sequence sends',\n drives:\n 'Sends every sequence step that has come due, from the rep’s own connected mailbox, and files each on the contact’s timeline. If it stops, no sequence sends anything and every task step waits.',\n run: async (context) => {\n const [{ runOutreachSendJob }, platform] = await Promise.all([import('./runtime/send-job'), runtime()])\n return runOutreachSendJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n registerPluginConsoleCron(\n {\n id: OUTREACH_SYNC_JOB_ID,\n label: 'Sequence replies and bounces',\n drives:\n 'Reads each connected mailbox for replies, out-of-office answers, opt-outs and bounces, and stops or postpones the sequence each one is about. If it stops, a person who replied or asked to be left alone keeps getting follow-ups, and a mailbox that bounces never pauses itself.',\n run: async (context) => {\n const [{ runOutreachSyncJob }, platform] = await Promise.all([import('./runtime/sync-job'), runtime()])\n return runOutreachSyncJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n}\n\n/** The runtime's platform reach, loaded when a job or an erasure first runs. */\nconst runtime = () => import('./runtime/platform-runtime-deps')\n"],"names":["registerPluginConsoleCron","listPluginMembershipDetachers","registerPluginMembershipDetacher","listPluginOrgErasers","registerPluginOrgEraser","listPluginLeadConversionListeners","registerPluginLeadConversionListener","registerPluginPersonEraser","listPluginUserErasers","registerPluginUserEraser","OUTREACH_PLUGIN_ID","OUTREACH_SEND_JOB_ID","OUTREACH_SYNC_JOB_ID","registerOutreachConsoleServerDeclarations","includes","request","createOutreachOrgEraser","defaultOutreachErasureDeps","pluginId","createOutreachUserEraser","runtime","platformOutreachPersonEraser","followOutreachLeadToContact","creditOutreachLeadConversion","platform","Promise","all","deps","platformOutreachRuntimeDeps","credited","followed","firestore","campaignsCredited","createOutreachCampaignDetacher","id","label","drives","run","context","runOutreachSendJob","runOutreachSyncJob"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/outreach/src/lib/declarations.console-server.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// Each registry from its own module, not the `@aglyn/aglyn/server` barrel:\n// boot needs one registry, not the whole server surface.\nimport { registerPluginConsoleCron } from '@aglyn/aglyn/plugin-manager/plugin-console-crons'\nimport {\n listPluginMembershipDetachers,\n registerPluginMembershipDetacher,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport {\n listPluginOrgErasers,\n registerPluginOrgEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-org-erasure'\nimport {\n listPluginLeadConversionListeners,\n registerPluginLeadConversionListener,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport {\n registerPluginTransferResource,\n type PluginTransferResource,\n} from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport {\n listPluginUserErasers,\n registerPluginUserEraser,\n} from '@aglyn/aglyn/plugin-manager/plugin-user-erasure'\nimport { OUTREACH_PLUGIN_ID } from './constants/bundle-common'\nimport {\n OUTREACH_SEQUENCE_PACKAGE_RULES,\n OUTREACH_SEQUENCES_TRANSFER_KEY,\n outreachSequenceDependencies,\n remapOutreachSequenceIds,\n type OutreachSequencePackageContent,\n} from './transfer/sequences-package'\nimport { OUTREACH_SEND_JOB_ID, OUTREACH_SYNC_JOB_ID } from './constants/runtime-jobs'\nimport { OUTREACH_DNC_MATCH_KEYS, OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY } from './constants/transfer-resources'\n\n/**\n * Outreach's CONSOLE-ONLY server declarations (AGL-2978), named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * The erasers are registered here rather than from `serverDeclarations`,\n * which the tenant runtime also loads: the workspace eraser revokes each\n * rep's Google grant, and the account eraser (AGL-3106) revokes and deletes\n * the person's own, and both open a sealed token with `OUTREACH_TOKEN_KEY`,\n * which only the console holds. The tenant may not so much as bundle the code\n * that does (`outreach-credential-isolation.spec.ts`). Every erasure runs in\n * the console, so this is where the erasers are needed.\n *\n * The sending runtime (AGL-2981) is declared here too, for the same reason:\n * the send and sync jobs open each rep's sealed grant, so they run on the\n * console's `plugin-console-crons` tick and nowhere else, and the person\n * eraser removes the enrollments a workspace kept about someone it erases.\n *\n * The do-not-contact list's transfer resource (`outreach.do-not-contact`) is\n * declared here as well: its import writes the list as a member would, and\n * its export reads it, through the Admin SDK the console's transfer routes\n * run on.\n *\n * Light at boot: every module that does the work is imported when a job, an\n * erasure, a tick or a transfer first asks for it, so the boot cost is the\n * registration.\n */\nexport function registerOutreachConsoleServerDeclarations(): void {\n registerOutreachTransferResources()\n // Idempotent against the REGISTRY, so a reset (a spec) registers again.\n if (!listPluginOrgErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginOrgEraser(\n async (request) => {\n const { createOutreachOrgEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachOrgEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n if (!listPluginUserErasers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginUserEraser(\n async (request) => {\n const { createOutreachUserEraser, defaultOutreachErasureDeps } = await import(\n './mailboxes/mailbox-erasure'\n )\n return createOutreachUserEraser(defaultOutreachErasureDeps())(request)\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // A person erased from a workspace takes their enrollments with them; the\n // do-not-contact entry stays, stripped of anything that names them.\n registerPluginPersonEraser(\n async (request) => (await runtime()).platformOutreachPersonEraser()(request),\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n // A lead that converts takes its enrollments to the contact it became\n // (AGL-3234): the same document, now naming the contact — and every\n // enrollment made on it credits `converted` to its campaigns (AGL-3254).\n if (!listPluginLeadConversionListeners().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginLeadConversionListener(\n async (request) => {\n const [{ followOutreachLeadToContact }, { creditOutreachLeadConversion }, platform] = await Promise.all([\n import('./runtime/lead-records'),\n import('./runtime/campaign-credit'),\n runtime(),\n ])\n const deps = platform.platformOutreachRuntimeDeps()\n const credited = await creditOutreachLeadConversion(deps, request)\n const followed = await followOutreachLeadToContact(deps.firestore(), request)\n return { ...followed, campaignsCredited: credited }\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // A deleted campaign comes off every sequence and enrollment naming it\n // (AGL-3254), before the marketing plugin removes the container.\n if (!listPluginMembershipDetachers().includes(OUTREACH_PLUGIN_ID)) {\n registerPluginMembershipDetacher(\n async (request) => {\n const [{ createOutreachCampaignDetacher }, platform] = await Promise.all([\n import('./runtime/campaign-detach'),\n runtime(),\n ])\n return createOutreachCampaignDetacher({ firestore: platform.platformOutreachRuntimeDeps().firestore })(\n request,\n )\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n }\n // The sending runtime, on the console's fifteen-minute tick.\n registerPluginConsoleCron(\n {\n id: OUTREACH_SEND_JOB_ID,\n label: 'Sequence sends',\n drives:\n 'Sends every sequence step that has come due, from the rep’s own connected mailbox, and files each on the contact’s timeline. If it stops, no sequence sends anything and every task step waits.',\n run: async (context) => {\n const [{ runOutreachSendJob }, platform] = await Promise.all([import('./runtime/send-job'), runtime()])\n return runOutreachSendJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n registerPluginConsoleCron(\n {\n id: OUTREACH_SYNC_JOB_ID,\n label: 'Sequence replies and bounces',\n drives:\n 'Reads each connected mailbox for replies, out-of-office answers, opt-outs and bounces, and stops or postpones the sequence each one is about. If it stops, a person who replied or asked to be left alone keeps getting follow-ups, and a mailbox that bounces never pauses itself.',\n run: async (context) => {\n const [{ runOutreachSyncJob }, platform] = await Promise.all([import('./runtime/sync-job'), runtime()])\n return runOutreachSyncJob(platform.platformOutreachRuntimeDeps(), context)\n },\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n // The do-not-contact list as a file: an import adds domains and\n // addresses, an export reads the domains. Each hook loads the resource\n // and the Admin SDK the first time a transfer asks.\n registerPluginTransferResource(OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY, lazyDoNotContactTransfer(), {\n pluginId: OUTREACH_PLUGIN_ID,\n })\n}\n\n/**\n * The do-not-contact resource with every hook deferred to its module; the\n * match keys are read at registration, so they come from the light constants.\n */\nfunction lazyDoNotContactTransfer(): PluginTransferResource {\n const load = async () => {\n const [{ createOutreachDoNotContactTransferResource }, { platformOutreachTransferDeps }] = await Promise.all([\n import('./transfer/do-not-contact-transfer'),\n import('./transfer/platform-transfer-deps'),\n ])\n return createOutreachDoNotContactTransferResource(platformOutreachTransferDeps())\n }\n return {\n matchKeys: OUTREACH_DNC_MATCH_KEYS,\n planGate: outreachTransferPlanGate,\n fields: async (ctx) => (await load()).fields(ctx),\n count: async (ctx, options) => (await load()).count(ctx, options),\n readPage: async (ctx, cursor, fieldIds, options) => (await load()).readPage(ctx, cursor, fieldIds, options),\n lookup: async (ctx, requests) => (await load()).lookup(ctx, requests),\n plan: async (ctx, input) => (await load()).plan(ctx, input),\n lockedRules: async (ctx) => (await load()).lockedRules(ctx),\n apply: async (ctx, chunk, writer) => (await load()).apply(ctx, chunk, writer),\n revert: async (ctx, snapshot, decisions) => (await load()).revert(ctx, snapshot, decisions),\n }\n}\n\n/**\n * Sequences' entitlement, asked by the transfer gate for both resources and\n * every intent (AGL-3548); the answer's module loads with the first ask.\n */\nconst outreachTransferPlanGate: NonNullable<PluginTransferResource['planGate']> = async (subject, intent) =>\n (await import('./transfer/plan-gate')).outreachTransferPlanGate(subject, intent)\n\n/** The sequences package's server half (AGL-3535), loaded when an import or export first asks. */\nconst sequencesPackage = async () => (await import('./transfer/sequences-package.server')).createOutreachSequencesPackage()\n\n/**\n * Sequences in a workspace package (AGL-3535): what a sequence names and\n * how a reference moves are answered here; reading and writing load the\n * server half on first use.\n */\nfunction registerOutreachTransferResources(): void {\n registerPluginTransferResource(\n OUTREACH_SEQUENCES_TRANSFER_KEY,\n {\n items: async (ctx) => (await sequencesPackage()).items(ctx),\n dependencies: (item) => outreachSequenceDependencies(item as OutreachSequencePackageContent),\n remapIds: (item, idMap) => remapOutreachSequenceIds(item as OutreachSequencePackageContent, idMap),\n readItems: async (ctx, ids) => (await sequencesPackage()).readItems(ctx, ids),\n writeItems: async (ctx, items, writer) => (await sequencesPackage()).writeItems(ctx, items as never, writer),\n revertItems: async (ctx, steps) => (await sequencesPackage()).revertItems(ctx, steps as never),\n problems: async (ctx, write) => (await sequencesPackage()).problems(ctx, write as never),\n referenceTargets: async (ctx, kinds) => (await sequencesPackage()).referenceTargets(ctx, kinds),\n rules: OUTREACH_SEQUENCE_PACKAGE_RULES,\n planGate: outreachTransferPlanGate,\n },\n { pluginId: OUTREACH_PLUGIN_ID },\n )\n}\n\n/** The runtime's platform reach, loaded when a job or an erasure first runs. */\nconst runtime = () => import('./runtime/platform-runtime-deps')\n"],"names":["registerPluginConsoleCron","listPluginMembershipDetachers","registerPluginMembershipDetacher","listPluginOrgErasers","registerPluginOrgEraser","listPluginLeadConversionListeners","registerPluginLeadConversionListener","registerPluginPersonEraser","registerPluginTransferResource","listPluginUserErasers","registerPluginUserEraser","OUTREACH_PLUGIN_ID","OUTREACH_SEQUENCE_PACKAGE_RULES","OUTREACH_SEQUENCES_TRANSFER_KEY","outreachSequenceDependencies","remapOutreachSequenceIds","OUTREACH_SEND_JOB_ID","OUTREACH_SYNC_JOB_ID","OUTREACH_DNC_MATCH_KEYS","OUTREACH_DO_NOT_CONTACT_TRANSFER_KEY","registerOutreachConsoleServerDeclarations","registerOutreachTransferResources","includes","request","createOutreachOrgEraser","defaultOutreachErasureDeps","pluginId","createOutreachUserEraser","runtime","platformOutreachPersonEraser","followOutreachLeadToContact","creditOutreachLeadConversion","platform","Promise","all","deps","platformOutreachRuntimeDeps","credited","followed","firestore","campaignsCredited","createOutreachCampaignDetacher","id","label","drives","run","context","runOutreachSendJob","runOutreachSyncJob","lazyDoNotContactTransfer","load","createOutreachDoNotContactTransferResource","platformOutreachTransferDeps","matchKeys","planGate","outreachTransferPlanGate","fields","ctx","count","options","readPage","cursor","fieldIds","lookup","requests","plan","input","lockedRules","apply","chunk","writer","revert","snapshot","decisions","subject","intent","sequencesPackage","createOutreachSequencesPackage","items","dependencies","item","remapIds","idMap","readItems","ids","writeItems","revertItems","steps","problems","write","referenceTargets","kinds","rules"],"mappings":"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"}
@@ -27,7 +27,7 @@
27
27
  * an HTML part. Giving cold one-to-one email a multipart HTML body is the
28
28
  * deliverability posture this plugin was built to avoid, so opens are not
29
29
  * measured at all — and the report SAYS SO rather than rendering 0%, exactly
30
- * as `campaign-report.ts` withholds a rate whose denominator was never
30
+ * as `send-report.ts` withholds a rate whose denominator was never
31
31
  * recorded. A zero that could only ever have been zero measures our sending
32
32
  * code, not the recipient.
33
33
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/click-tracking.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 * CLICKS, IN A PLAIN-TEXT EMAIL (AGL-3239).\n *\n * A marketing campaign gets its opens and clicks from Resend, which rewrites\n * the links and injects a pixel into the HTML part before the message leaves.\n * A sequence has neither half of that: it goes out through the rep's own\n * Gmail, and it goes out as plain text on purpose.\n *\n * ## Why there is no open rate here, and never a zero one\n *\n * An open is measured by a pixel, a pixel is an `<img>`, and an `<img>` needs\n * an HTML part. Giving cold one-to-one email a multipart HTML body is the\n * deliverability posture this plugin was built to avoid, so opens are not\n * measured at all — and the report SAYS SO rather than rendering 0%, exactly\n * as `campaign-report.ts` withholds a rate whose denominator was never\n * recorded. A zero that could only ever have been zero measures our sending\n * code, not the recipient.\n *\n * A click needs no HTML. It needs the destination in the body to be a link of\n * ours that forwards to theirs, which is what this module writes.\n *\n * ## What it will and will not rewrite\n *\n * Only bare `http:`/`https:` URLs, and only in the STEP'S OWN BODY — the\n * rewrite runs on the rendered body before the footer is appended, so the\n * organization's legal name, its postal address and the way out are never\n * touched. `mailto:` is left alone: the opt-out address is the one link in a\n * cold email that must not route through us.\n *==========================================*/\n\n/**\n * A URL as it appears in a plain-text body, with where it was found.\n *\n * `end` is exclusive, so `text.slice(start, end)` is exactly `url`.\n */\nexport interface OutreachBodyLink {\n url: string\n start: number\n end: number\n}\n\n/**\n * Trailing characters a sentence puts after a URL that are not part of it.\n *\n * A plain-text body is prose, so a link is routinely followed by a full stop,\n * a comma or a closing bracket. Taking those into the URL would rewrite a\n * destination nobody wrote and forward the recipient to a 404.\n */\nconst TRAILING_PUNCTUATION = /[.,;:!?'\"]+$/\n\n/**\n * A bare http(s) URL, up to the first whitespace or bracket that never\n * belongs in one.\n *\n * `(` and `)` are NOT excluded, because a real path can contain them — a\n * Wikipedia article is the everyday example — and {@link trimUrlEnd}\n * decides which trailing one is the URL's and which is the sentence's.\n */\nconst URL_PATTERN = /https?:\\/\\/[^\\s<>[\\]{}\"']+/gi\n\n/**\n * Trims what the sentence put after the URL, not what the URL contains.\n *\n * A closing bracket is the URL's when the URL opened one, and the\n * sentence's when it did not — which is the usual case, a link inside a\n * parenthetical. Counted rather than merely looked for, so a link that is\n * both parenthesised AND contains brackets loses exactly the outer one.\n */\nfunction trimUrlEnd(raw: string): string {\n let url = raw.replace(TRAILING_PUNCTUATION, '')\n while (url.endsWith(')')) {\n const opened = url.split('(').length - 1\n const closed = url.split(')').length - 1\n if (closed <= opened) break\n url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, '')\n }\n return url\n}\n\n/**\n * Every http(s) URL in a plain-text body, in the order they appear.\n *\n * Duplicates are returned as the separate occurrences they are: the same\n * destination written twice is two links to rewrite, and each gets its own\n * index so a click says which one was followed.\n */\nexport function outreachBodyLinks(text: string): OutreachBodyLink[] {\n const body = String(text ?? '')\n const links: OutreachBodyLink[] = []\n for (const match of body.matchAll(URL_PATTERN)) {\n const start = match.index ?? 0\n const url = trimUrlEnd(match[0])\n if (!url) continue\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n continue\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue\n links.push({ url, start, end: start + url.length })\n }\n return links\n}\n\n/** What {@link rewriteOutreachBodyLinks} produced. */\nexport interface OutreachRewrittenBody {\n text: string\n /**\n * The destinations that were rewritten, in the order their tracking links\n * were minted — the index a click token names.\n *\n * Empty when nothing was rewritten, which is what a body with no links, a\n * sequence with tracking off, and a minter that could not sign all read as.\n */\n links: string[]\n}\n\n/**\n * Rewrites every link in a plain-text body to a tracking link.\n *\n * @param text The rendered step body, footer NOT yet appended.\n * @param mint Answers the tracking URL for the link at `index`, or `null` to\n * leave that one alone. A minter that cannot sign returns `null` for every\n * link and the body goes out exactly as it was written — an email whose\n * links do not work is far worse than an email we cannot measure.\n */\nexport function rewriteOutreachBodyLinks(\n text: string,\n mint: (link: { url: string; index: number }) => string | null,\n): OutreachRewrittenBody {\n const body = String(text ?? '')\n const found = outreachBodyLinks(body)\n if (!found.length) return { text: body, links: [] }\n const links: string[] = []\n let out = ''\n let cursor = 0\n for (const link of found) {\n // The index a token carries is the index in `links`, which counts only\n // the links that were actually rewritten. A minter that skips one must\n // not shift the numbering of the ones after it.\n const tracked = mint({ url: link.url, index: links.length })\n out += body.slice(cursor, link.start)\n if (tracked) {\n out += tracked\n links.push(link.url)\n } else {\n out += link.url\n }\n cursor = link.end\n }\n return { text: out + body.slice(cursor), links }\n}\n\n/*==========================================\n * WHO CLICKED: A PERSON, OR A MACHINE.\n *\n * Every corporate mailbox now runs the links in an incoming email before the\n * recipient ever sees it — Microsoft Defender's Safe Links, Proofpoint,\n * Mimecast, Barracuda, and Gmail's own scanning. Each of those fetches is a\n * click by any definition a redirect handler can apply, and on cold B2B mail\n * they outnumber the human ones.\n *\n * Counting them is how a sequence reports a 60% click rate on an email\n * nobody read. So they are counted APART: `clicks` and `uniqueClicks` are\n * the human ones and carry the rate, `machineClicks` is kept beside them and\n * shown, because a number withheld silently is the other way to mislead.\n *\n * This is the same judgement `campaign-revenue.ts` records about Apple's Mail\n * Privacy Protection inflating opens — stated on the screen rather than\n * quietly corrected away.\n *==========================================*/\n\n/**\n * How soon after a send a click is a machine's however it identifies itself.\n *\n * A scanner fetches as the message is delivered. A person has to receive the\n * mail, notice it and read it, and thirty seconds is faster than anyone does\n * that on a cold email — while being long enough to stay wrong only in the\n * rarest case, which is a rate that reads a little low rather than a rate\n * that reads triple.\n */\nexport const OUTREACH_CLICK_HUMAN_DELAY_MS = 30_000\n\n/**\n * Agents that say what they are. Matched case-insensitively as substrings of\n * the user agent, so a version bump does not un-match one.\n *\n * Absence from this list is not evidence of a person — most scanners\n * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}\n * is for.\n */\nexport const OUTREACH_MACHINE_AGENTS: readonly string[] = [\n 'bot',\n 'crawler',\n 'spider',\n 'slurp',\n 'preview',\n 'scanner',\n 'curl',\n 'wget',\n 'python-requests',\n 'java/',\n 'go-http-client',\n 'okhttp',\n 'headlesschrome',\n 'phantomjs',\n 'proofpoint',\n 'mimecast',\n 'barracuda',\n 'symantec',\n 'forcepoint',\n 'microsoft office',\n 'ms-office',\n 'bingpreview',\n 'skypeuripreview',\n 'google-safety',\n 'googleimageproxy',\n 'apache-httpclient',\n 'axios',\n 'node-fetch',\n 'urlresolver',\n 'safelinks',\n]\n\n/** Why a click was read as a machine's, or `null` when it was a person's. */\nexport type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method'\n\n/** What one request to a tracking link was. */\nexport interface OutreachClickJudgement {\n /** Whether it counts toward the click rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachClickMachineReason | null\n}\n\n/**\n * Whether one request to a tracking link is a person's.\n *\n * Read before the redirect is *recorded*, never before it is *answered*: a\n * machine is forwarded to the destination exactly as a person is, because a\n * scanner that gets a refusal reports the link as broken and the mail as\n * suspicious.\n *\n * @param input.method The HTTP method. A `HEAD` is a fetch of the headers\n * alone, which no mail client does for a person following a link.\n * @param input.userAgent The request's agent, `''` when it sent none — which\n * is itself a machine, since every browser sends one.\n * @param input.sinceSentMs Milliseconds between the send and this request,\n * or `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachClick(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachClickJudgement {\n const machine = (machineReason: OutreachClickMachineReason): OutreachClickJudgement => ({\n human: false,\n machineReason,\n })\n if (String(input.method ?? '').toUpperCase() === 'HEAD') return machine('method')\n const agent = String(input.userAgent ?? '').trim().toLowerCase()\n if (!agent) return machine('agent')\n if (OUTREACH_MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n /*\n * A click that arrives BEFORE the send it belongs to is a clock disagreeing\n * with itself, not a time traveller, and it is read as a person's: the one\n * thing worse than counting a scanner is refusing to count a real reply to\n * an email because two machines are a second apart.\n */\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_CLICK_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["TRAILING_PUNCTUATION","URL_PATTERN","trimUrlEnd","raw","url","replace","endsWith","opened","split","length","closed","slice","outreachBodyLinks","text","body","String","links","match","matchAll","start","index","parsed","URL","protocol","push","end","rewriteOutreachBodyLinks","mint","found","out","cursor","link","tracked","OUTREACH_CLICK_HUMAN_DELAY_MS","OUTREACH_MACHINE_AGENTS","judgeOutreachClick","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","sinceSentMs"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/click-tracking.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 * CLICKS, IN A PLAIN-TEXT EMAIL (AGL-3239).\n *\n * A marketing campaign gets its opens and clicks from Resend, which rewrites\n * the links and injects a pixel into the HTML part before the message leaves.\n * A sequence has neither half of that: it goes out through the rep's own\n * Gmail, and it goes out as plain text on purpose.\n *\n * ## Why there is no open rate here, and never a zero one\n *\n * An open is measured by a pixel, a pixel is an `<img>`, and an `<img>` needs\n * an HTML part. Giving cold one-to-one email a multipart HTML body is the\n * deliverability posture this plugin was built to avoid, so opens are not\n * measured at all — and the report SAYS SO rather than rendering 0%, exactly\n * as `send-report.ts` withholds a rate whose denominator was never\n * recorded. A zero that could only ever have been zero measures our sending\n * code, not the recipient.\n *\n * A click needs no HTML. It needs the destination in the body to be a link of\n * ours that forwards to theirs, which is what this module writes.\n *\n * ## What it will and will not rewrite\n *\n * Only bare `http:`/`https:` URLs, and only in the STEP'S OWN BODY — the\n * rewrite runs on the rendered body before the footer is appended, so the\n * organization's legal name, its postal address and the way out are never\n * touched. `mailto:` is left alone: the opt-out address is the one link in a\n * cold email that must not route through us.\n *==========================================*/\n\n/**\n * A URL as it appears in a plain-text body, with where it was found.\n *\n * `end` is exclusive, so `text.slice(start, end)` is exactly `url`.\n */\nexport interface OutreachBodyLink {\n url: string\n start: number\n end: number\n}\n\n/**\n * Trailing characters a sentence puts after a URL that are not part of it.\n *\n * A plain-text body is prose, so a link is routinely followed by a full stop,\n * a comma or a closing bracket. Taking those into the URL would rewrite a\n * destination nobody wrote and forward the recipient to a 404.\n */\nconst TRAILING_PUNCTUATION = /[.,;:!?'\"]+$/\n\n/**\n * A bare http(s) URL, up to the first whitespace or bracket that never\n * belongs in one.\n *\n * `(` and `)` are NOT excluded, because a real path can contain them — a\n * Wikipedia article is the everyday example — and {@link trimUrlEnd}\n * decides which trailing one is the URL's and which is the sentence's.\n */\nconst URL_PATTERN = /https?:\\/\\/[^\\s<>[\\]{}\"']+/gi\n\n/**\n * Trims what the sentence put after the URL, not what the URL contains.\n *\n * A closing bracket is the URL's when the URL opened one, and the\n * sentence's when it did not — which is the usual case, a link inside a\n * parenthetical. Counted rather than merely looked for, so a link that is\n * both parenthesised AND contains brackets loses exactly the outer one.\n */\nfunction trimUrlEnd(raw: string): string {\n let url = raw.replace(TRAILING_PUNCTUATION, '')\n while (url.endsWith(')')) {\n const opened = url.split('(').length - 1\n const closed = url.split(')').length - 1\n if (closed <= opened) break\n url = url.slice(0, -1).replace(TRAILING_PUNCTUATION, '')\n }\n return url\n}\n\n/**\n * Every http(s) URL in a plain-text body, in the order they appear.\n *\n * Duplicates are returned as the separate occurrences they are: the same\n * destination written twice is two links to rewrite, and each gets its own\n * index so a click says which one was followed.\n */\nexport function outreachBodyLinks(text: string): OutreachBodyLink[] {\n const body = String(text ?? '')\n const links: OutreachBodyLink[] = []\n for (const match of body.matchAll(URL_PATTERN)) {\n const start = match.index ?? 0\n const url = trimUrlEnd(match[0])\n if (!url) continue\n let parsed: URL\n try {\n parsed = new URL(url)\n } catch {\n continue\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue\n links.push({ url, start, end: start + url.length })\n }\n return links\n}\n\n/** What {@link rewriteOutreachBodyLinks} produced. */\nexport interface OutreachRewrittenBody {\n text: string\n /**\n * The destinations that were rewritten, in the order their tracking links\n * were minted — the index a click token names.\n *\n * Empty when nothing was rewritten, which is what a body with no links, a\n * sequence with tracking off, and a minter that could not sign all read as.\n */\n links: string[]\n}\n\n/**\n * Rewrites every link in a plain-text body to a tracking link.\n *\n * @param text The rendered step body, footer NOT yet appended.\n * @param mint Answers the tracking URL for the link at `index`, or `null` to\n * leave that one alone. A minter that cannot sign returns `null` for every\n * link and the body goes out exactly as it was written — an email whose\n * links do not work is far worse than an email we cannot measure.\n */\nexport function rewriteOutreachBodyLinks(\n text: string,\n mint: (link: { url: string; index: number }) => string | null,\n): OutreachRewrittenBody {\n const body = String(text ?? '')\n const found = outreachBodyLinks(body)\n if (!found.length) return { text: body, links: [] }\n const links: string[] = []\n let out = ''\n let cursor = 0\n for (const link of found) {\n // The index a token carries is the index in `links`, which counts only\n // the links that were actually rewritten. A minter that skips one must\n // not shift the numbering of the ones after it.\n const tracked = mint({ url: link.url, index: links.length })\n out += body.slice(cursor, link.start)\n if (tracked) {\n out += tracked\n links.push(link.url)\n } else {\n out += link.url\n }\n cursor = link.end\n }\n return { text: out + body.slice(cursor), links }\n}\n\n/*==========================================\n * WHO CLICKED: A PERSON, OR A MACHINE.\n *\n * Every corporate mailbox now runs the links in an incoming email before the\n * recipient ever sees it — Microsoft Defender's Safe Links, Proofpoint,\n * Mimecast, Barracuda, and Gmail's own scanning. Each of those fetches is a\n * click by any definition a redirect handler can apply, and on cold B2B mail\n * they outnumber the human ones.\n *\n * Counting them is how a sequence reports a 60% click rate on an email\n * nobody read. So they are counted APART: `clicks` and `uniqueClicks` are\n * the human ones and carry the rate, `machineClicks` is kept beside them and\n * shown, because a number withheld silently is the other way to mislead.\n *\n * This is the same judgement `campaign-revenue.ts` records about Apple's Mail\n * Privacy Protection inflating opens — stated on the screen rather than\n * quietly corrected away.\n *==========================================*/\n\n/**\n * How soon after a send a click is a machine's however it identifies itself.\n *\n * A scanner fetches as the message is delivered. A person has to receive the\n * mail, notice it and read it, and thirty seconds is faster than anyone does\n * that on a cold email — while being long enough to stay wrong only in the\n * rarest case, which is a rate that reads a little low rather than a rate\n * that reads triple.\n */\nexport const OUTREACH_CLICK_HUMAN_DELAY_MS = 30_000\n\n/**\n * Agents that say what they are. Matched case-insensitively as substrings of\n * the user agent, so a version bump does not un-match one.\n *\n * Absence from this list is not evidence of a person — most scanners\n * impersonate a browser, which is what {@link OUTREACH_CLICK_HUMAN_DELAY_MS}\n * is for.\n */\nexport const OUTREACH_MACHINE_AGENTS: readonly string[] = [\n 'bot',\n 'crawler',\n 'spider',\n 'slurp',\n 'preview',\n 'scanner',\n 'curl',\n 'wget',\n 'python-requests',\n 'java/',\n 'go-http-client',\n 'okhttp',\n 'headlesschrome',\n 'phantomjs',\n 'proofpoint',\n 'mimecast',\n 'barracuda',\n 'symantec',\n 'forcepoint',\n 'microsoft office',\n 'ms-office',\n 'bingpreview',\n 'skypeuripreview',\n 'google-safety',\n 'googleimageproxy',\n 'apache-httpclient',\n 'axios',\n 'node-fetch',\n 'urlresolver',\n 'safelinks',\n]\n\n/** Why a click was read as a machine's, or `null` when it was a person's. */\nexport type OutreachClickMachineReason = 'agent' | 'too_soon' | 'method'\n\n/** What one request to a tracking link was. */\nexport interface OutreachClickJudgement {\n /** Whether it counts toward the click rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachClickMachineReason | null\n}\n\n/**\n * Whether one request to a tracking link is a person's.\n *\n * Read before the redirect is *recorded*, never before it is *answered*: a\n * machine is forwarded to the destination exactly as a person is, because a\n * scanner that gets a refusal reports the link as broken and the mail as\n * suspicious.\n *\n * @param input.method The HTTP method. A `HEAD` is a fetch of the headers\n * alone, which no mail client does for a person following a link.\n * @param input.userAgent The request's agent, `''` when it sent none — which\n * is itself a machine, since every browser sends one.\n * @param input.sinceSentMs Milliseconds between the send and this request,\n * or `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachClick(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachClickJudgement {\n const machine = (machineReason: OutreachClickMachineReason): OutreachClickJudgement => ({\n human: false,\n machineReason,\n })\n if (String(input.method ?? '').toUpperCase() === 'HEAD') return machine('method')\n const agent = String(input.userAgent ?? '').trim().toLowerCase()\n if (!agent) return machine('agent')\n if (OUTREACH_MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n /*\n * A click that arrives BEFORE the send it belongs to is a clock disagreeing\n * with itself, not a time traveller, and it is read as a person's: the one\n * thing worse than counting a scanner is refusing to count a real reply to\n * an email because two machines are a second apart.\n */\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_CLICK_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["TRAILING_PUNCTUATION","URL_PATTERN","trimUrlEnd","raw","url","replace","endsWith","opened","split","length","closed","slice","outreachBodyLinks","text","body","String","links","match","matchAll","start","index","parsed","URL","protocol","push","end","rewriteOutreachBodyLinks","mint","found","out","cursor","link","tracked","OUTREACH_CLICK_HUMAN_DELAY_MS","OUTREACH_MACHINE_AGENTS","judgeOutreachClick","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","sinceSentMs"],"mappings":"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"}
@@ -0,0 +1,26 @@
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
+ /** A parsed block, so a list of them is read once rather than per address. */
18
+ export interface OutreachIpBlock {
19
+ network: bigint;
20
+ prefix: number;
21
+ bits: 32 | 128;
22
+ }
23
+ /** A block from its `address/prefix` text; throws on one that is not, since the lists are hard-coded. */
24
+ export declare function outreachIpBlock(cidr: string): OutreachIpBlock;
25
+ /** Whether `address` is inside any of `blocks`; `false` for an address that is not one. */
26
+ export declare function outreachIpInBlocks(address: string | null | undefined, blocks: readonly OutreachIpBlock[]): boolean;
@@ -0,0 +1,78 @@
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
+ * Whether an address falls inside a CIDR block, for IPv4 and IPv6 alike
18
+ * (AGL-3488). Small on purpose: the open judgement asks only "is this one of
19
+ * a mail provider's blocks?", over a handful of hard-coded ranges, and an
20
+ * address or a block it cannot read is simply not in it.
21
+ */ /** An address as a number and its width in bits, or `null` when it is not one. */ function parseAddress(raw) {
22
+ const text = raw.trim().toLowerCase();
23
+ if (!text) return null;
24
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text)) {
25
+ const parts = text.split('.').map(Number);
26
+ if (parts.some((part)=>part > 255)) return null;
27
+ return {
28
+ value: parts.reduce((total, part)=>total << 8n | BigInt(part), 0n),
29
+ bits: 32
30
+ };
31
+ }
32
+ if (!text.includes(':')) return null;
33
+ // An IPv4-mapped IPv6 address (`::ffff:1.2.3.4`) is the IPv4 address.
34
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
35
+ if (mapped) return parseAddress(mapped[1]);
36
+ const halves = text.split('::');
37
+ if (halves.length > 2) return null;
38
+ const groups = (half)=>half ? half.split(':') : [];
39
+ const head = groups(halves[0]);
40
+ const tail = halves.length === 2 ? groups(halves[1]) : [];
41
+ const missing = 8 - head.length - tail.length;
42
+ if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
43
+ const all = [
44
+ ...head,
45
+ ...Array(halves.length === 2 ? missing : 0).fill('0'),
46
+ ...tail
47
+ ];
48
+ if (all.some((group)=>!/^[0-9a-f]{1,4}$/.test(group))) return null;
49
+ return {
50
+ value: all.reduce((total, group)=>total << 16n | BigInt(parseInt(group, 16)), 0n),
51
+ bits: 128
52
+ };
53
+ }
54
+ /** A block from its `address/prefix` text; throws on one that is not, since the lists are hard-coded. */ export function outreachIpBlock(cidr) {
55
+ const [address, prefixText] = cidr.split('/');
56
+ const parsed = parseAddress(address != null ? address : '');
57
+ const prefix = Number(prefixText);
58
+ if (!parsed || !Number.isInteger(prefix) || prefix < 0 || prefix > parsed.bits) {
59
+ throw new Error(`Not a CIDR block: ${cidr}`);
60
+ }
61
+ const shift = BigInt(parsed.bits - prefix);
62
+ return {
63
+ network: parsed.value >> shift << shift,
64
+ prefix,
65
+ bits: parsed.bits
66
+ };
67
+ }
68
+ /** Whether `address` is inside any of `blocks`; `false` for an address that is not one. */ export function outreachIpInBlocks(address, blocks) {
69
+ const parsed = parseAddress(String(address != null ? address : ''));
70
+ if (!parsed) return false;
71
+ return blocks.some((block)=>{
72
+ if (block.bits !== parsed.bits) return false;
73
+ const shift = BigInt(block.bits - block.prefix);
74
+ return parsed.value >> shift << shift === block.network;
75
+ });
76
+ }
77
+
78
+ //# sourceMappingURL=ip-range.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/ip-range.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 * Whether an address falls inside a CIDR block, for IPv4 and IPv6 alike\n * (AGL-3488). Small on purpose: the open judgement asks only \"is this one of\n * a mail provider's blocks?\", over a handful of hard-coded ranges, and an\n * address or a block it cannot read is simply not in it.\n */\n\n/** An address as a number and its width in bits, or `null` when it is not one. */\nfunction parseAddress(raw: string): { value: bigint; bits: 32 | 128 } | null {\n const text = raw.trim().toLowerCase()\n if (!text) return null\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(text)) {\n const parts = text.split('.').map(Number)\n if (parts.some((part) => part > 255)) return null\n return { value: parts.reduce((total, part) => (total << 8n) | BigInt(part), 0n), bits: 32 }\n }\n if (!text.includes(':')) return null\n // An IPv4-mapped IPv6 address (`::ffff:1.2.3.4`) is the IPv4 address.\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/.exec(text)\n if (mapped) return parseAddress(mapped[1])\n const halves = text.split('::')\n if (halves.length > 2) return null\n const groups = (half: string) => (half ? half.split(':') : [])\n const head = groups(halves[0])\n const tail = halves.length === 2 ? groups(halves[1]) : []\n const missing = 8 - head.length - tail.length\n if (halves.length === 2 ? missing < 1 : missing !== 0) return null\n const all = [...head, ...Array<string>(halves.length === 2 ? missing : 0).fill('0'), ...tail]\n if (all.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) return null\n return { value: all.reduce((total, group) => (total << 16n) | BigInt(parseInt(group, 16)), 0n), bits: 128 }\n}\n\n/** A parsed block, so a list of them is read once rather than per address. */\nexport interface OutreachIpBlock {\n network: bigint\n prefix: number\n bits: 32 | 128\n}\n\n/** A block from its `address/prefix` text; throws on one that is not, since the lists are hard-coded. */\nexport function outreachIpBlock(cidr: string): OutreachIpBlock {\n const [address, prefixText] = cidr.split('/')\n const parsed = parseAddress(address ?? '')\n const prefix = Number(prefixText)\n if (!parsed || !Number.isInteger(prefix) || prefix < 0 || prefix > parsed.bits) {\n throw new Error(`Not a CIDR block: ${cidr}`)\n }\n const shift = BigInt(parsed.bits - prefix)\n return { network: (parsed.value >> shift) << shift, prefix, bits: parsed.bits }\n}\n\n/** Whether `address` is inside any of `blocks`; `false` for an address that is not one. */\nexport function outreachIpInBlocks(address: string | null | undefined, blocks: readonly OutreachIpBlock[]): boolean {\n const parsed = parseAddress(String(address ?? ''))\n if (!parsed) return false\n return blocks.some((block) => {\n if (block.bits !== parsed.bits) return false\n const shift = BigInt(block.bits - block.prefix)\n return (parsed.value >> shift) << shift === block.network\n })\n}\n"],"names":["parseAddress","raw","text","trim","toLowerCase","test","parts","split","map","Number","some","part","value","reduce","total","BigInt","bits","includes","mapped","exec","halves","length","groups","half","head","tail","missing","all","Array","fill","group","parseInt","outreachIpBlock","cidr","address","prefixText","parsed","prefix","isInteger","Error","shift","network","outreachIpInBlocks","blocks","String","block"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;CAKC,GAED,gFAAgF,GAChF,SAASA,aAAaC,GAAW;IAC/B,MAAMC,OAAOD,IAAIE,IAAI,GAAGC,WAAW;IACnC,IAAI,CAACF,MAAM,OAAO;IAClB,IAAI,0BAA0BG,IAAI,CAACH,OAAO;QACxC,MAAMI,QAAQJ,KAAKK,KAAK,CAAC,KAAKC,GAAG,CAACC;QAClC,IAAIH,MAAMI,IAAI,CAAC,CAACC,OAASA,OAAO,MAAM,OAAO;QAC7C,OAAO;YAAEC,OAAON,MAAMO,MAAM,CAAC,CAACC,OAAOH,OAAS,AAACG,SAAS,EAAE,GAAIC,OAAOJ,OAAO,EAAE;YAAGK,MAAM;QAAG;IAC5F;IACA,IAAI,CAACd,KAAKe,QAAQ,CAAC,MAAM,OAAO;IAChC,sEAAsE;IACtE,MAAMC,SAAS,qCAAqCC,IAAI,CAACjB;IACzD,IAAIgB,QAAQ,OAAOlB,aAAakB,MAAM,CAAC,EAAE;IACzC,MAAME,SAASlB,KAAKK,KAAK,CAAC;IAC1B,IAAIa,OAAOC,MAAM,GAAG,GAAG,OAAO;IAC9B,MAAMC,SAAS,CAACC,OAAkBA,OAAOA,KAAKhB,KAAK,CAAC,OAAO,EAAE;IAC7D,MAAMiB,OAAOF,OAAOF,MAAM,CAAC,EAAE;IAC7B,MAAMK,OAAOL,OAAOC,MAAM,KAAK,IAAIC,OAAOF,MAAM,CAAC,EAAE,IAAI,EAAE;IACzD,MAAMM,UAAU,IAAIF,KAAKH,MAAM,GAAGI,KAAKJ,MAAM;IAC7C,IAAID,OAAOC,MAAM,KAAK,IAAIK,UAAU,IAAIA,YAAY,GAAG,OAAO;IAC9D,MAAMC,MAAM;WAAIH;WAASI,MAAcR,OAAOC,MAAM,KAAK,IAAIK,UAAU,GAAGG,IAAI,CAAC;WAASJ;KAAK;IAC7F,IAAIE,IAAIjB,IAAI,CAAC,CAACoB,QAAU,CAAC,kBAAkBzB,IAAI,CAACyB,SAAS,OAAO;IAChE,OAAO;QAAElB,OAAOe,IAAId,MAAM,CAAC,CAACC,OAAOgB,QAAU,AAAChB,SAAS,GAAG,GAAIC,OAAOgB,SAASD,OAAO,MAAM,EAAE;QAAGd,MAAM;IAAI;AAC5G;AASA,uGAAuG,GACvG,OAAO,SAASgB,gBAAgBC,IAAY;IAC1C,MAAM,CAACC,SAASC,WAAW,GAAGF,KAAK1B,KAAK,CAAC;IACzC,MAAM6B,SAASpC,aAAakC,kBAAAA,UAAW;IACvC,MAAMG,SAAS5B,OAAO0B;IACtB,IAAI,CAACC,UAAU,CAAC3B,OAAO6B,SAAS,CAACD,WAAWA,SAAS,KAAKA,SAASD,OAAOpB,IAAI,EAAE;QAC9E,MAAM,IAAIuB,MAAM,CAAC,kBAAkB,EAAEN,MAAM;IAC7C;IACA,MAAMO,QAAQzB,OAAOqB,OAAOpB,IAAI,GAAGqB;IACnC,OAAO;QAAEI,SAAS,AAACL,OAAOxB,KAAK,IAAI4B,SAAUA;QAAOH;QAAQrB,MAAMoB,OAAOpB,IAAI;IAAC;AAChF;AAEA,yFAAyF,GACzF,OAAO,SAAS0B,mBAAmBR,OAAkC,EAAES,MAAkC;IACvG,MAAMP,SAASpC,aAAa4C,OAAOV,kBAAAA,UAAW;IAC9C,IAAI,CAACE,QAAQ,OAAO;IACpB,OAAOO,OAAOjC,IAAI,CAAC,CAACmC;QAClB,IAAIA,MAAM7B,IAAI,KAAKoB,OAAOpB,IAAI,EAAE,OAAO;QACvC,MAAMwB,QAAQzB,OAAO8B,MAAM7B,IAAI,GAAG6B,MAAMR,MAAM;QAC9C,OAAO,AAACD,OAAOxB,KAAK,IAAI4B,SAAUA,UAAUK,MAAMJ,OAAO;IAC3D;AACF"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** The networks a fetch is told apart by; `other` is every address that is none of them. */
18
+ export type OutreachOpenSource = 'google' | 'microsoft_filter' | 'microsoft' | 'apple' | 'yahoo' | 'other';
19
+ export declare const OUTREACH_OPEN_SOURCES: readonly OutreachOpenSource[];
20
+ /**
21
+ * The network a fetch came from, or `null` when no address could be read —
22
+ * which is "unknown", a different answer from `other`.
23
+ */
24
+ export declare function outreachOpenSourceOf(address: string | null | undefined): OutreachOpenSource | null;
@@ -0,0 +1,130 @@
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 { outreachIpBlock, outreachIpInBlocks } from "./ip-range.js";
17
+ export const OUTREACH_OPEN_SOURCES = [
18
+ 'google',
19
+ 'microsoft_filter',
20
+ 'microsoft',
21
+ 'apple',
22
+ 'yahoo',
23
+ 'other'
24
+ ];
25
+ const blocks = (cidrs)=>cidrs.map(outreachIpBlock);
26
+ const GOOGLE = blocks([
27
+ '64.233.160.0/19',
28
+ '66.102.0.0/20',
29
+ '66.249.64.0/19',
30
+ '72.14.192.0/18',
31
+ '74.125.0.0/16',
32
+ '108.170.192.0/18',
33
+ '108.177.0.0/17',
34
+ '142.250.0.0/15',
35
+ '172.217.0.0/16',
36
+ '172.253.0.0/16',
37
+ '173.194.0.0/16',
38
+ '209.85.128.0/17',
39
+ '216.58.192.0/19',
40
+ '216.239.32.0/19',
41
+ '2001:4860::/32',
42
+ '2404:6800::/32',
43
+ '2a00:1450::/32'
44
+ ]);
45
+ const MICROSOFT_FILTER = blocks([
46
+ '40.92.0.0/15',
47
+ '40.107.0.0/16',
48
+ '52.100.0.0/14',
49
+ '104.47.0.0/17',
50
+ '2a01:111:f400::/48',
51
+ '2a01:111:f403::/48'
52
+ ]);
53
+ const MICROSOFT = blocks([
54
+ '13.107.128.0/22',
55
+ '23.103.160.0/20',
56
+ '40.96.0.0/13',
57
+ '40.104.0.0/15',
58
+ '52.96.0.0/14',
59
+ '132.245.0.0/16',
60
+ '150.171.32.0/22',
61
+ '2603:1000::/24',
62
+ '2620:1ec:8f0::/46',
63
+ '2620:1ec:900::/46'
64
+ ]);
65
+ const APPLE = blocks([
66
+ '17.0.0.0/8',
67
+ '104.28.0.0/16',
68
+ '140.248.0.0/16',
69
+ '146.75.0.0/16',
70
+ '172.224.0.0/15',
71
+ '172.226.0.0/16',
72
+ '2606:54c0::/32',
73
+ '2606:54c3::/32',
74
+ '2a02:26f7::/32',
75
+ '2a04:4e41::/32',
76
+ '2a09:bac2::/31'
77
+ ]);
78
+ const YAHOO = blocks([
79
+ '27.123.32.0/19',
80
+ '67.195.0.0/16',
81
+ '69.147.64.0/18',
82
+ '87.248.96.0/19',
83
+ '98.138.0.0/15',
84
+ '106.10.128.0/17',
85
+ '115.178.0.0/20',
86
+ '119.161.0.0/19',
87
+ '200.152.160.0/20',
88
+ '202.43.192.0/19',
89
+ '209.73.160.0/19',
90
+ '212.82.96.0/19',
91
+ '216.115.96.0/20',
92
+ '2001:4998::/32',
93
+ '2406:2000::/32',
94
+ '2406:6e00::/32',
95
+ '2804:1bc::/32',
96
+ '2a00:1288::/32'
97
+ ]);
98
+ /** Checked in this order; the filter before the rest of Microsoft, which does not contain it. */ const SOURCES = [
99
+ [
100
+ 'google',
101
+ GOOGLE
102
+ ],
103
+ [
104
+ 'microsoft_filter',
105
+ MICROSOFT_FILTER
106
+ ],
107
+ [
108
+ 'microsoft',
109
+ MICROSOFT
110
+ ],
111
+ [
112
+ 'apple',
113
+ APPLE
114
+ ],
115
+ [
116
+ 'yahoo',
117
+ YAHOO
118
+ ]
119
+ ];
120
+ /**
121
+ * The network a fetch came from, or `null` when no address could be read —
122
+ * which is "unknown", a different answer from `other`.
123
+ */ export function outreachOpenSourceOf(address) {
124
+ const text = String(address != null ? address : '').trim();
125
+ if (!text) return null;
126
+ for (const [source, ranges] of SOURCES)if (outreachIpInBlocks(text, ranges)) return source;
127
+ return 'other';
128
+ }
129
+
130
+ //# sourceMappingURL=open-source.js.map