@aglyn/plugins-outreach 1.0.0-beta.220 → 1.0.0-beta.222
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.
- package/package.json +14 -14
- package/src/lib/components/do-not-contact-domains.d.ts +6 -2
- package/src/lib/components/do-not-contact-domains.js +73 -11
- package/src/lib/components/do-not-contact-domains.js.map +1 -1
- package/src/lib/components/enrollments-table.d.ts +6 -0
- package/src/lib/components/enrollments-table.js +77 -4
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/sequence-detail.js +3 -1
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.js +7 -3
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/use-outreach-crm.d.ts +18 -15
- package/src/lib/components/use-outreach-crm.js +118 -87
- package/src/lib/components/use-outreach-crm.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +2 -2
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/transfer-resources.d.ts +33 -0
- package/src/lib/constants/transfer-resources.js +38 -0
- package/src/lib/constants/transfer-resources.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +7 -1
- package/src/lib/declarations.console-server.js +66 -1
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/engine/click-tracking.js +1 -1
- package/src/lib/engine/click-tracking.js.map +1 -1
- package/src/lib/engine/ip-range.d.ts +26 -0
- package/src/lib/engine/ip-range.js +78 -0
- package/src/lib/engine/ip-range.js.map +1 -0
- package/src/lib/engine/open-source.d.ts +24 -0
- package/src/lib/engine/open-source.js +130 -0
- package/src/lib/engine/open-source.js.map +1 -0
- package/src/lib/engine/open-tracking.d.ts +15 -2
- package/src/lib/engine/open-tracking.js +59 -16
- package/src/lib/engine/open-tracking.js.map +1 -1
- package/src/lib/engine/outreach-access.d.ts +47 -0
- package/src/lib/engine/outreach-access.js +56 -0
- package/src/lib/engine/outreach-access.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +10 -10
- package/src/lib/enrollment/enroll-people.js +30 -25
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.js +30 -15
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/enrollment/message-templates.d.ts +24 -0
- package/src/lib/enrollment/message-templates.js +35 -0
- package/src/lib/enrollment/message-templates.js.map +1 -0
- package/src/lib/model/enrollment-engagement.d.ts +16 -0
- package/src/lib/model/enrollment-engagement.js +16 -0
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +8 -1
- package/src/lib/model/enrollment-history.js +17 -5
- package/src/lib/model/enrollment-history.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +3 -0
- package/src/lib/model/enrollment-timeline.js +15 -4
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +12 -3
- package/src/lib/model/outreach.types.js +12 -1
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-report.d.ts +14 -7
- package/src/lib/model/sequence-report.js +12 -8
- package/src/lib/model/sequence-report.js.map +1 -1
- package/src/lib/plugin.js +18 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/curate-routes.js +4 -6
- package/src/lib/routes/curate-routes.js.map +1 -1
- package/src/lib/routes/enroll-routes.d.ts +0 -12
- package/src/lib/routes/enroll-routes.js +52 -92
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +4 -16
- package/src/lib/routes/platform-deps.js +5 -25
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.js +25 -13
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +7 -5
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-gate.js +6 -17
- package/src/lib/routes/route-gate.js.map +1 -1
- package/src/lib/routes/sequence-routes.d.ts +46 -0
- package/src/lib/routes/sequence-routes.js +155 -63
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.js +8 -8
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-route.js +5 -2
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/lead-records.d.ts +18 -10
- package/src/lib/runtime/lead-records.js +14 -60
- package/src/lib/runtime/lead-records.js.map +1 -1
- package/src/lib/runtime/open-events.d.ts +8 -0
- package/src/lib/runtime/open-events.js +10 -3
- package/src/lib/runtime/open-events.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.d.ts +9 -5
- package/src/lib/runtime/platform-runtime-deps.js +31 -24
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +20 -17
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +10 -5
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.d.ts +8 -0
- package/src/lib/storage/do-not-contact-store.js +13 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
- package/src/lib/testing/stand-in-record-system.d.ts +54 -0
- package/src/lib/testing/stand-in-record-system.js +243 -0
- package/src/lib/testing/stand-in-record-system.js.map +1 -0
- package/src/lib/transfer/do-not-contact-transfer.d.ts +129 -0
- package/src/lib/transfer/do-not-contact-transfer.js +801 -0
- package/src/lib/transfer/do-not-contact-transfer.js.map +1 -0
- package/src/lib/transfer/plan-gate.d.ts +25 -0
- package/src/lib/transfer/plan-gate.js +35 -0
- package/src/lib/transfer/plan-gate.js.map +1 -0
- package/src/lib/transfer/platform-transfer-deps.d.ts +24 -0
- package/src/lib/transfer/platform-transfer-deps.js +39 -0
- package/src/lib/transfer/platform-transfer-deps.js.map +1 -0
- package/src/lib/transfer/sequences-package.d.ts +48 -0
- package/src/lib/transfer/sequences-package.js +93 -0
- package/src/lib/transfer/sequences-package.js.map +1 -0
- package/src/lib/transfer/sequences-package.server.d.ts +31 -0
- package/src/lib/transfer/sequences-package.server.js +207 -0
- package/src/lib/transfer/sequences-package.server.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/open-source.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 { outreachIpBlock, outreachIpInBlocks, type OutreachIpBlock } from './ip-range'\n\n/*==========================================\n * WHOSE NETWORK A FETCH OF THE TRACKING IMAGE CAME FROM (AGL-3488).\n *\n * A coarse hint, read from the fetch's address and stored on the open's\n * history row in place of the address itself: the address of a person\n * reading at home is theirs, and the only thing the judgement needs from it\n * is whether it is one of the few networks that fetch mail images on a\n * provider's behalf.\n *\n * The blocks are the published ones, collapsed to their aggregates and\n * checked on 2026-10-02:\n *\n * - **Google** — Google's own space from `gstatic.com/ipranges/goog.json`,\n * none of it Google Cloud customer space (`cloud.json`): Gmail's image\n * proxy and Google's mail scanning fetch from here, a stranger's VM on\n * GCP does not.\n * - **Microsoft's mail filter** — Exchange Online Protection\n * (`*.protection.outlook.com`, `*.mail.protection.outlook.com`) from the\n * Microsoft 365 endpoints service: the filter every Microsoft 365 message\n * passes through, and nothing a person reads mail on.\n * - **Microsoft** — the rest of Exchange Online (`outlook.office.com`,\n * `outlook.office365.com`). Outlook on the web loads a message's images\n * through Microsoft's servers when the reader opens it, so this is not a\n * scanner by itself.\n * - **Apple** — `17.0.0.0/8`, and the relay egress Apple publishes in\n * `mask-api.icloud.com/egress-ip-ranges.csv`, through which Mail Privacy\n * Protection fetches: the Akamai, Cloudflare and Fastly blocks it\n * collapses to.\n * - **Yahoo** — the ranges its mail proxy page lists\n * (`senders.yahooinc.com/mail-proxy-servers`).\n *\n * A range that moves later costs a hint, never an image: every fetch is\n * answered the same either way.\n *==========================================*/\n\n/** The networks a fetch is told apart by; `other` is every address that is none of them. */\nexport type OutreachOpenSource = 'google' | 'microsoft_filter' | 'microsoft' | 'apple' | 'yahoo' | 'other'\n\nexport const OUTREACH_OPEN_SOURCES: readonly OutreachOpenSource[] = [\n 'google',\n 'microsoft_filter',\n 'microsoft',\n 'apple',\n 'yahoo',\n 'other',\n]\n\nconst blocks = (cidrs: readonly string[]): OutreachIpBlock[] => cidrs.map(outreachIpBlock)\n\nconst GOOGLE = blocks([\n '64.233.160.0/19',\n '66.102.0.0/20',\n '66.249.64.0/19',\n '72.14.192.0/18',\n '74.125.0.0/16',\n '108.170.192.0/18',\n '108.177.0.0/17',\n '142.250.0.0/15',\n '172.217.0.0/16',\n '172.253.0.0/16',\n '173.194.0.0/16',\n '209.85.128.0/17',\n '216.58.192.0/19',\n '216.239.32.0/19',\n '2001:4860::/32',\n '2404:6800::/32',\n '2a00:1450::/32',\n])\n\nconst MICROSOFT_FILTER = blocks([\n '40.92.0.0/15',\n '40.107.0.0/16',\n '52.100.0.0/14',\n '104.47.0.0/17',\n '2a01:111:f400::/48',\n '2a01:111:f403::/48',\n])\n\nconst MICROSOFT = blocks([\n '13.107.128.0/22',\n '23.103.160.0/20',\n '40.96.0.0/13',\n '40.104.0.0/15',\n '52.96.0.0/14',\n '132.245.0.0/16',\n '150.171.32.0/22',\n '2603:1000::/24',\n '2620:1ec:8f0::/46',\n '2620:1ec:900::/46',\n])\n\nconst APPLE = blocks([\n '17.0.0.0/8',\n '104.28.0.0/16',\n '140.248.0.0/16',\n '146.75.0.0/16',\n '172.224.0.0/15',\n '172.226.0.0/16',\n '2606:54c0::/32',\n '2606:54c3::/32',\n '2a02:26f7::/32',\n '2a04:4e41::/32',\n '2a09:bac2::/31',\n])\n\nconst YAHOO = blocks([\n '27.123.32.0/19',\n '67.195.0.0/16',\n '69.147.64.0/18',\n '87.248.96.0/19',\n '98.138.0.0/15',\n '106.10.128.0/17',\n '115.178.0.0/20',\n '119.161.0.0/19',\n '200.152.160.0/20',\n '202.43.192.0/19',\n '209.73.160.0/19',\n '212.82.96.0/19',\n '216.115.96.0/20',\n '2001:4998::/32',\n '2406:2000::/32',\n '2406:6e00::/32',\n '2804:1bc::/32',\n '2a00:1288::/32',\n])\n\n/** Checked in this order; the filter before the rest of Microsoft, which does not contain it. */\nconst SOURCES: ReadonlyArray<[OutreachOpenSource, readonly OutreachIpBlock[]]> = [\n ['google', GOOGLE],\n ['microsoft_filter', MICROSOFT_FILTER],\n ['microsoft', MICROSOFT],\n ['apple', APPLE],\n ['yahoo', YAHOO],\n]\n\n/**\n * The network a fetch came from, or `null` when no address could be read —\n * which is \"unknown\", a different answer from `other`.\n */\nexport function outreachOpenSourceOf(address: string | null | undefined): OutreachOpenSource | null {\n const text = String(address ?? '').trim()\n if (!text) return null\n for (const [source, ranges] of SOURCES) if (outreachIpInBlocks(text, ranges)) return source\n return 'other'\n}\n"],"names":["outreachIpBlock","outreachIpInBlocks","OUTREACH_OPEN_SOURCES","blocks","cidrs","map","GOOGLE","MICROSOFT_FILTER","MICROSOFT","APPLE","YAHOO","SOURCES","outreachOpenSourceOf","address","text","String","trim","source","ranges"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,eAAe,EAAEC,kBAAkB,QAA8B,gBAAY;AAwCtF,OAAO,MAAMC,wBAAuD;IAClE;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,MAAMC,SAAS,CAACC,QAAgDA,MAAMC,GAAG,CAACL;AAE1E,MAAMM,SAASH,OAAO;IACpB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,MAAMI,mBAAmBJ,OAAO;IAC9B;IACA;IACA;IACA;IACA;IACA;CACD;AAED,MAAMK,YAAYL,OAAO;IACvB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,MAAMM,QAAQN,OAAO;IACnB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,MAAMO,QAAQP,OAAO;IACnB;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,+FAA+F,GAC/F,MAAMQ,UAA2E;IAC/E;QAAC;QAAUL;KAAO;IAClB;QAAC;QAAoBC;KAAiB;IACtC;QAAC;QAAaC;KAAU;IACxB;QAAC;QAASC;KAAM;IAChB;QAAC;QAASC;KAAM;CACjB;AAED;;;CAGC,GACD,OAAO,SAASE,qBAAqBC,OAAkC;IACrE,MAAMC,OAAOC,OAAOF,kBAAAA,UAAW,IAAIG,IAAI;IACvC,IAAI,CAACF,MAAM,OAAO;IAClB,KAAK,MAAM,CAACG,QAAQC,OAAO,IAAIP,QAAS,IAAIV,mBAAmBa,MAAMI,SAAS,OAAOD;IACrF,OAAO;AACT"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { OutreachOpenSource } from './open-source';
|
|
17
18
|
/** Text safe to place in HTML content or a double-quoted attribute. */
|
|
18
19
|
export declare function escapeOutreachHtml(value: string): string;
|
|
19
20
|
/**
|
|
@@ -34,8 +35,14 @@ export declare function outreachOpenTrackedHtml(text: string, pixelUrl: string):
|
|
|
34
35
|
* email does not.
|
|
35
36
|
*/
|
|
36
37
|
export declare const OUTREACH_OPEN_HUMAN_DELAY_MS = 30000;
|
|
37
|
-
/**
|
|
38
|
-
export
|
|
38
|
+
/** The longest agent kept on a history row: evidence, not a store for whatever a caller sends. */
|
|
39
|
+
export declare const OUTREACH_OPEN_AGENT_MAX = 400;
|
|
40
|
+
/**
|
|
41
|
+
* Why an open was read as a machine's. `scanner` (AGL-3488) is a mail
|
|
42
|
+
* provider's or a security product's fetch, known by where it came from;
|
|
43
|
+
* `agent` is one known by what it called itself.
|
|
44
|
+
*/
|
|
45
|
+
export type OutreachOpenMachineReason = 'agent' | 'too_soon' | 'method' | 'image_proxy' | 'privacy_proxy' | 'scanner';
|
|
39
46
|
/** The reasons that are a mail provider's proxy rather than a scanner. */
|
|
40
47
|
export declare const OUTREACH_PROXY_OPEN_REASONS: readonly OutreachOpenMachineReason[];
|
|
41
48
|
/** What one fetch of a tracking image was. */
|
|
@@ -45,6 +52,8 @@ export interface OutreachOpenJudgement {
|
|
|
45
52
|
/** Why it does not; `null` when it does. */
|
|
46
53
|
machineReason: OutreachOpenMachineReason | null;
|
|
47
54
|
}
|
|
55
|
+
/** The agent as a history row keeps it: trimmed, on one line, and no longer than {@link OUTREACH_OPEN_AGENT_MAX}. */
|
|
56
|
+
export declare function outreachOpenAgentEvidence(userAgent: string | null | undefined): string | null;
|
|
48
57
|
/**
|
|
49
58
|
* Whether one fetch of a tracking image is a person's open.
|
|
50
59
|
*
|
|
@@ -53,9 +62,13 @@ export interface OutreachOpenJudgement {
|
|
|
53
62
|
*
|
|
54
63
|
* @param input.sinceSentMs Milliseconds between the send and this fetch, or
|
|
55
64
|
* `null` when the send it belongs to could not be found.
|
|
65
|
+
* @param input.source The network the fetch came from (`./open-source.ts`),
|
|
66
|
+
* or `null` when no address was read — judged then by the agent alone,
|
|
67
|
+
* as every fetch was before AGL-3488.
|
|
56
68
|
*/
|
|
57
69
|
export declare function judgeOutreachOpen(input: {
|
|
58
70
|
method: string;
|
|
59
71
|
userAgent: string | null | undefined;
|
|
60
72
|
sinceSentMs: number | null;
|
|
73
|
+
source?: OutreachOpenSource | null;
|
|
61
74
|
}): OutreachOpenJudgement;
|
|
@@ -84,23 +84,37 @@
|
|
|
84
84
|
* WHO OPENED: A PERSON, OR A MACHINE.
|
|
85
85
|
*
|
|
86
86
|
* The same judgement scanner clicks get (`./click-tracking.ts`), with the
|
|
87
|
-
* machines that fetch images rather than links
|
|
87
|
+
* machines that fetch images rather than links — told apart by what a fetch
|
|
88
|
+
* says it is AND where it came from (AGL-3488), because the agent alone
|
|
89
|
+
* cannot separate them:
|
|
88
90
|
*
|
|
89
91
|
* - **Apple Mail Privacy Protection** fetches every remote image of every
|
|
90
|
-
* message
|
|
91
|
-
*
|
|
92
|
+
* message as it arrives, whether or not anyone reads it, naming itself
|
|
93
|
+
* only as `Mozilla/5.0` — and from Apple's relay egress, never from
|
|
94
|
+
* Google's or Microsoft's mail servers. The bare agent from anywhere else
|
|
95
|
+
* is a scanner borrowing it, and is called one: a sequence mailing
|
|
96
|
+
* Google Workspace addresses once read 128 such fetches as Apple's.
|
|
97
|
+
* - **Gmail's `GoogleImageProxy`** is NOT a machine here. Gmail fetches
|
|
98
|
+
* through it when the reader opens the message, so outside the delivery
|
|
99
|
+
* window its fetch is the reader's open, and setting it apart would leave
|
|
100
|
+
* the rate blind to every Gmail and Workspace reader. The proxy caches the
|
|
101
|
+
* image, so a reader's later opens may not arrive at all; the rate is
|
|
102
|
+
* taken over first opens per person either way. Gmail's PREFETCH, which
|
|
103
|
+
* fetches on delivery for some signed-in recipients, names a 2015 Edge
|
|
104
|
+
* instead — no reader's browser — and is a scanner.
|
|
105
|
+
* - **Google's and Microsoft's mail scanning** fetches from their mail
|
|
106
|
+
* networks with whatever agent it likes. Anything from Google's own
|
|
107
|
+
* network that is not the image proxy is a scanner, and so is anything
|
|
108
|
+
* from Exchange Online Protection, Microsoft's mail filter. The rest of
|
|
109
|
+
* Microsoft's mail network is NOT judged by itself: Outlook on the web
|
|
110
|
+
* loads images through it when the reader opens the message.
|
|
92
111
|
* - **Yahoo's image proxy** fetches on the provider's behalf, and is set
|
|
93
112
|
* apart as a proxy.
|
|
113
|
+
* - **Gateways and scanners** that name themselves as the click scanners
|
|
114
|
+
* do, or that arrive within seconds of delivery.
|
|
94
115
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* — so outside the delivery window its fetch is the reader's open, and
|
|
98
|
-
* setting it apart would leave the rate blind to every Gmail and Workspace
|
|
99
|
-
* reader. The proxy caches the image, so a reader's later opens may not
|
|
100
|
-
* arrive at all; the rate is taken over first opens per person either way.
|
|
101
|
-
* - **Gateways and scanners** that open the message to inspect it, which
|
|
102
|
-
* name themselves as the click scanners do, or arrive within seconds of
|
|
103
|
-
* delivery.
|
|
116
|
+
* Every fetch's agent and network are kept on its history row, so a
|
|
117
|
+
* judgement can be checked against what actually arrived.
|
|
104
118
|
*
|
|
105
119
|
* Counted apart, never dropped: `machineOpens` beside `opens`, as
|
|
106
120
|
* `machineClicks` beside `clicks`.
|
|
@@ -121,15 +135,31 @@
|
|
|
121
135
|
'googleimageproxy',
|
|
122
136
|
'ggpht.com'
|
|
123
137
|
];
|
|
138
|
+
/**
|
|
139
|
+
* Gmail's prefetch: Chrome 42 and Edge 12 in one agent, both from 2015,
|
|
140
|
+
* matched together so neither browser alone is ever read as a machine.
|
|
141
|
+
*/ const GMAIL_PREFETCH_AGENT_PARTS = [
|
|
142
|
+
'chrome/42.0.2311.135',
|
|
143
|
+
'edge/12.246'
|
|
144
|
+
];
|
|
124
145
|
/**
|
|
125
146
|
* The agent Apple's Mail Privacy Protection proxy sends: the bare product
|
|
126
147
|
* token, with no platform after it — which no browser or mail client that
|
|
127
148
|
* shows an image to a person sends.
|
|
128
|
-
*/ const
|
|
149
|
+
*/ const BARE_AGENT = 'mozilla/5.0';
|
|
150
|
+
/** The longest agent kept on a history row: evidence, not a store for whatever a caller sends. */ export const OUTREACH_OPEN_AGENT_MAX = 400;
|
|
129
151
|
/** The reasons that are a mail provider's proxy rather than a scanner. */ export const OUTREACH_PROXY_OPEN_REASONS = [
|
|
130
152
|
'image_proxy',
|
|
131
153
|
'privacy_proxy'
|
|
132
154
|
];
|
|
155
|
+
/** The agent as a history row keeps it: trimmed, on one line, and no longer than {@link OUTREACH_OPEN_AGENT_MAX}. */ export function outreachOpenAgentEvidence(userAgent) {
|
|
156
|
+
// Every control character, a line break included, read as a space.
|
|
157
|
+
const agent = Array.from(String(userAgent != null ? userAgent : ''), (character)=>{
|
|
158
|
+
const code = character.charCodeAt(0);
|
|
159
|
+
return code < 0x20 || code === 0x7f ? ' ' : character;
|
|
160
|
+
}).join('').replace(/ {2,}/g, ' ').trim();
|
|
161
|
+
return agent ? agent.slice(0, OUTREACH_OPEN_AGENT_MAX) : null;
|
|
162
|
+
}
|
|
133
163
|
/**
|
|
134
164
|
* Whether one fetch of a tracking image is a person's open.
|
|
135
165
|
*
|
|
@@ -138,19 +168,32 @@
|
|
|
138
168
|
*
|
|
139
169
|
* @param input.sinceSentMs Milliseconds between the send and this fetch, or
|
|
140
170
|
* `null` when the send it belongs to could not be found.
|
|
171
|
+
* @param input.source The network the fetch came from (`./open-source.ts`),
|
|
172
|
+
* or `null` when no address was read — judged then by the agent alone,
|
|
173
|
+
* as every fetch was before AGL-3488.
|
|
141
174
|
*/ export function judgeOutreachOpen(input) {
|
|
142
|
-
var _input_method, _input_userAgent;
|
|
175
|
+
var _input_source, _input_method, _input_userAgent;
|
|
143
176
|
const machine = (machineReason)=>({
|
|
144
177
|
human: false,
|
|
145
178
|
machineReason
|
|
146
179
|
});
|
|
180
|
+
const source = (_input_source = input.source) != null ? _input_source : null;
|
|
147
181
|
if (String((_input_method = input.method) != null ? _input_method : '').toUpperCase() === 'HEAD') return machine('method');
|
|
148
182
|
const agent = String((_input_userAgent = input.userAgent) != null ? _input_userAgent : '').trim().toLowerCase();
|
|
149
183
|
if (!agent) return machine('agent');
|
|
150
|
-
if (IMAGE_PROXY_AGENTS.some((needle)=>agent.includes(needle))) return machine('image_proxy');
|
|
151
|
-
if (agent ===
|
|
184
|
+
if (source === 'yahoo' || IMAGE_PROXY_AGENTS.some((needle)=>agent.includes(needle))) return machine('image_proxy');
|
|
185
|
+
if (agent === BARE_AGENT) {
|
|
186
|
+
// Apple's agent from Apple's relay — or from nowhere we could read,
|
|
187
|
+
// which is how every such fetch was judged before the address was.
|
|
188
|
+
return machine(source === null || source === 'apple' ? 'privacy_proxy' : 'scanner');
|
|
189
|
+
}
|
|
190
|
+
if (GMAIL_PREFETCH_AGENT_PARTS.every((part)=>agent.includes(part))) return machine('scanner');
|
|
152
191
|
const gmail = GMAIL_PROXY_AGENTS.some((needle)=>agent.includes(needle));
|
|
192
|
+
// Gmail's proxy fetches from Google's network; the same words from any
|
|
193
|
+
// other network that could be read are someone else's.
|
|
194
|
+
if (gmail && source !== null && source !== 'google') return machine('agent');
|
|
153
195
|
if (!gmail && OUTREACH_MACHINE_AGENTS.some((needle)=>agent.includes(needle))) return machine('agent');
|
|
196
|
+
if (!gmail && (source === 'google' || source === 'microsoft_filter')) return machine('scanner');
|
|
154
197
|
// A fetch before its send is two clocks disagreeing, and read as a person's.
|
|
155
198
|
if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_OPEN_HUMAN_DELAY_MS) {
|
|
156
199
|
return machine('too_soon');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/open-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\nimport { OUTREACH_MACHINE_AGENTS, outreachBodyLinks } from './click-tracking'\n\n/*==========================================\n * OPENS, WHEN A SEQUENCE ASKS FOR THEM (AGL-3395).\n *\n * A sequence email is plain text, and a plain-text email cannot report an\n * open: an open is a fetch of an image, and an image needs an HTML part\n * (`./click-tracking.ts` says why that is the default). A sequence that\n * turns on \"Count opens\" accepts the cost of one, for the sends it makes\n * from then on.\n *\n * ## What the HTML part is\n *\n * The plain-text part is sent exactly as it would have been. The HTML part\n * beside it is the SAME TEXT — escaped, its line breaks as `<br>`, its bare\n * links made into anchors to the same address — with a 1×1 image at the end.\n * No styling, no layout, no second copy of anything: a client that shows\n * the HTML part shows what the plain-text part says, in the reader's own\n * font, and the image is the only thing the text does not carry.\n *\n * The image is a short link (AGL-3297) on the same host the clicks use, so\n * the email names no address a click does not already name.\n *\n * ## What it costs\n *\n * A one-to-one email from a person has no HTML part; one with a remote\n * image reads, to a filter, as marketing. Some gateways score it. That is\n * why the setting is per sequence, off by default, and said in the editor\n * where it is turned on.\n *==========================================*/\n\n/** Every character HTML gives a meaning to, as the entity that does not. */\nconst HTML_ESCAPES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '\"': '"',\n \"'\": ''',\n}\n\n/** Text safe to place in HTML content or a double-quoted attribute. */\nexport function escapeOutreachHtml(value: string): string {\n return String(value ?? '').replace(/[&<>\"']/g, (character) => HTML_ESCAPES[character] ?? character)\n}\n\n/** One run of text, escaped, with its line breaks as `<br>`. */\nconst escapedLines = (text: string): string =>\n escapeOutreachHtml(text).replace(/\\r\\n|\\r|\\n/g, '<br>\\n')\n\n/**\n * The HTML part for a plain-text email and its tracking image.\n *\n * @param text The plain-text body as it is sent, footer included — so the\n * HTML part carries the organization's identification and the way out\n * word for word, and any link click tracking already rewrote stays the\n * rewritten link.\n * @param pixelUrl The image's address. Written into a quoted attribute\n * after escaping, so no value can close the attribute.\n */\nexport function outreachOpenTrackedHtml(text: string, pixelUrl: string): string {\n const body = String(text ?? '')\n let html = ''\n let cursor = 0\n // Linked by the same reader click tracking rewrites by, so a link the\n // plain text offers is exactly a link the HTML offers.\n for (const link of outreachBodyLinks(body)) {\n html += escapedLines(body.slice(cursor, link.start))\n const href = escapeOutreachHtml(link.url)\n html += `<a href=\"${href}\">${href}</a>`\n cursor = link.end\n }\n html += escapedLines(body.slice(cursor))\n const src = escapeOutreachHtml(pixelUrl)\n return (\n '<!DOCTYPE html>\\n<html>\\n<head><meta charset=\"utf-8\"></head>\\n<body>\\n' +\n `<div dir=\"auto\">${html}</div>\\n` +\n `<img src=\"${src}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;width:1px;height:1px;border:0\">\\n` +\n '</body>\\n</html>\\n'\n )\n}\n\n/*==========================================\n * WHO OPENED: A PERSON, OR A MACHINE.\n *\n * The same judgement scanner clicks get (`./click-tracking.ts`), with the\n * machines that fetch images rather than links:\n *\n * - **Apple Mail Privacy Protection** fetches every remote image of every\n * message through Apple's proxy when the message arrives, whether or not\n * anyone reads it. Its fetch names itself only as `Mozilla/5.0`.\n * - **Yahoo's image proxy** fetches on the provider's behalf, and is set\n * apart as a proxy.\n *\n * Gmail's `GoogleImageProxy` is NOT a machine here. Gmail fetches through it\n * only when the reader opens the message — it does not prefetch on delivery\n * — so outside the delivery window its fetch is the reader's open, and\n * setting it apart would leave the rate blind to every Gmail and Workspace\n * reader. The proxy caches the image, so a reader's later opens may not\n * arrive at all; the rate is taken over first opens per person either way.\n * - **Gateways and scanners** that open the message to inspect it, which\n * name themselves as the click scanners do, or arrive within seconds of\n * delivery.\n *\n * Counted apart, never dropped: `machineOpens` beside `opens`, as\n * `machineClicks` beside `clicks`.\n *==========================================*/\n\n/**\n * How soon after a send an open is a machine's however it identifies\n * itself — the click window's reasoning, for the same reason: a proxy or a\n * gateway fetches as the message is delivered, and a person reading a cold\n * email does not.\n */\nexport const OUTREACH_OPEN_HUMAN_DELAY_MS = 30_000\n\n/** Image proxies that fetch on a mail provider's behalf, matched as substrings of the agent. */\nconst IMAGE_PROXY_AGENTS = ['yahoomailproxy']\n\n/**\n * Gmail's image proxy, which fetches when the reader opens the message and\n * so counts as their open — see the note above. It names itself in an agent\n * the scanner list would otherwise match (`googleimageproxy`).\n */\nconst GMAIL_PROXY_AGENTS = ['googleimageproxy', 'ggpht.com']\n\n/**\n * The agent Apple's Mail Privacy Protection proxy sends: the bare product\n * token, with no platform after it — which no browser or mail client that\n * shows an image to a person sends.\n */\nconst APPLE_PRIVACY_AGENT = 'mozilla/5.0'\n\n/** Why an open was read as a machine's. */\nexport type OutreachOpenMachineReason = 'agent' | 'too_soon' | 'method' | 'image_proxy' | 'privacy_proxy'\n\n/** The reasons that are a mail provider's proxy rather than a scanner. */\nexport const OUTREACH_PROXY_OPEN_REASONS: readonly OutreachOpenMachineReason[] = ['image_proxy', 'privacy_proxy']\n\n/** What one fetch of a tracking image was. */\nexport interface OutreachOpenJudgement {\n /** Whether it counts toward the open rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachOpenMachineReason | null\n}\n\n/**\n * Whether one fetch of a tracking image is a person's open.\n *\n * Read before the open is recorded, never before the image is answered:\n * every fetch gets the same image.\n *\n * @param input.sinceSentMs Milliseconds between the send and this fetch, or\n * `null` when the send it belongs to could not be found.\n */\nexport function judgeOutreachOpen(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n}): OutreachOpenJudgement {\n const machine = (machineReason: OutreachOpenMachineReason): OutreachOpenJudgement => ({\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 (IMAGE_PROXY_AGENTS.some((needle) => agent.includes(needle))) return machine('image_proxy')\n if (agent === APPLE_PRIVACY_AGENT) return machine('privacy_proxy')\n const gmail = GMAIL_PROXY_AGENTS.some((needle) => agent.includes(needle))\n if (!gmail && OUTREACH_MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n // A fetch before its send is two clocks disagreeing, and read as a person's.\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_OPEN_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["OUTREACH_MACHINE_AGENTS","outreachBodyLinks","HTML_ESCAPES","escapeOutreachHtml","value","String","replace","character","escapedLines","text","outreachOpenTrackedHtml","pixelUrl","body","html","cursor","link","slice","start","href","url","end","src","OUTREACH_OPEN_HUMAN_DELAY_MS","IMAGE_PROXY_AGENTS","GMAIL_PROXY_AGENTS","APPLE_PRIVACY_AGENT","OUTREACH_PROXY_OPEN_REASONS","judgeOutreachOpen","input","machine","machineReason","human","method","toUpperCase","agent","userAgent","trim","toLowerCase","some","needle","includes","gmail","sinceSentMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,uBAAuB,EAAEC,iBAAiB,QAAQ,sBAAkB;AAE7E;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA2B4C,GAE5C,0EAA0E,GAC1E,MAAMC,eAAuC;IAC3C,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;AACP;AAEA,qEAAqE,GACrE,OAAO,SAASC,mBAAmBC,KAAa;IAC9C,OAAOC,OAAOD,gBAAAA,QAAS,IAAIE,OAAO,CAAC,YAAY,CAACC;YAAcL;gBAAAA,0BAAAA,YAAY,CAACK,UAAU,YAAvBL,0BAA2BK;;AAC3F;AAEA,8DAA8D,GAC9D,MAAMC,eAAe,CAACC,OACpBN,mBAAmBM,MAAMH,OAAO,CAAC,eAAe;AAElD;;;;;;;;;CASC,GACD,OAAO,SAASI,wBAAwBD,IAAY,EAAEE,QAAgB;IACpE,MAAMC,OAAOP,OAAOI,eAAAA,OAAQ;IAC5B,IAAII,OAAO;IACX,IAAIC,SAAS;IACb,sEAAsE;IACtE,uDAAuD;IACvD,KAAK,MAAMC,QAAQd,kBAAkBW,MAAO;QAC1CC,QAAQL,aAAaI,KAAKI,KAAK,CAACF,QAAQC,KAAKE,KAAK;QAClD,MAAMC,OAAOf,mBAAmBY,KAAKI,GAAG;QACxCN,QAAQ,CAAC,SAAS,EAAEK,KAAK,EAAE,EAAEA,KAAK,IAAI,CAAC;QACvCJ,SAASC,KAAKK,GAAG;IACnB;IACAP,QAAQL,aAAaI,KAAKI,KAAK,CAACF;IAChC,MAAMO,MAAMlB,mBAAmBQ;IAC/B,OACE,2EACA,CAAC,gBAAgB,EAAEE,KAAK,QAAQ,CAAC,GACjC,CAAC,UAAU,EAAEQ,IAAI,oFAAoF,CAAC,GACtG;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;;;;;;4CAwB4C,GAE5C;;;;;CAKC,GACD,OAAO,MAAMC,+BAA+B,MAAM;AAElD,8FAA8F,GAC9F,MAAMC,qBAAqB;IAAC;CAAiB;AAE7C;;;;CAIC,GACD,MAAMC,qBAAqB;IAAC;IAAoB;CAAY;AAE5D;;;;CAIC,GACD,MAAMC,sBAAsB;AAK5B,wEAAwE,GACxE,OAAO,MAAMC,8BAAoE;IAAC;IAAe;CAAgB,CAAA;AAUjH;;;;;;;;CAQC,GACD,OAAO,SAASC,kBAAkBC,KAIjC;QAKYA,eACUA;IALrB,MAAMC,UAAU,CAACC,gBAAqE,CAAA;YACpFC,OAAO;YACPD;QACF,CAAA;IACA,IAAIzB,QAAOuB,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB,IAAIK,WAAW,OAAO,QAAQ,OAAOJ,QAAQ;IACxE,MAAMK,QAAQ7B,QAAOuB,mBAAAA,MAAMO,SAAS,YAAfP,mBAAmB,IAAIQ,IAAI,GAAGC,WAAW;IAC9D,IAAI,CAACH,OAAO,OAAOL,QAAQ;IAC3B,IAAIN,mBAAmBe,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD,UAAU,OAAOV,QAAQ;IAChF,IAAIK,UAAUT,qBAAqB,OAAOI,QAAQ;IAClD,MAAMY,QAAQjB,mBAAmBc,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD;IACjE,IAAI,CAACE,SAASzC,wBAAwBsC,IAAI,CAAC,CAACC,SAAWL,MAAMM,QAAQ,CAACD,UAAU,OAAOV,QAAQ;IAC/F,6EAA6E;IAC7E,IAAID,MAAMc,WAAW,KAAK,QAAQd,MAAMc,WAAW,IAAI,KAAKd,MAAMc,WAAW,GAAGpB,8BAA8B;QAC5G,OAAOO,QAAQ;IACjB;IACA,OAAO;QAAEE,OAAO;QAAMD,eAAe;IAAK;AAC5C"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/open-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\nimport { OUTREACH_MACHINE_AGENTS, outreachBodyLinks } from './click-tracking'\nimport type { OutreachOpenSource } from './open-source'\n\n/*==========================================\n * OPENS, WHEN A SEQUENCE ASKS FOR THEM (AGL-3395).\n *\n * A sequence email is plain text, and a plain-text email cannot report an\n * open: an open is a fetch of an image, and an image needs an HTML part\n * (`./click-tracking.ts` says why that is the default). A sequence that\n * turns on \"Count opens\" accepts the cost of one, for the sends it makes\n * from then on.\n *\n * ## What the HTML part is\n *\n * The plain-text part is sent exactly as it would have been. The HTML part\n * beside it is the SAME TEXT — escaped, its line breaks as `<br>`, its bare\n * links made into anchors to the same address — with a 1×1 image at the end.\n * No styling, no layout, no second copy of anything: a client that shows\n * the HTML part shows what the plain-text part says, in the reader's own\n * font, and the image is the only thing the text does not carry.\n *\n * The image is a short link (AGL-3297) on the same host the clicks use, so\n * the email names no address a click does not already name.\n *\n * ## What it costs\n *\n * A one-to-one email from a person has no HTML part; one with a remote\n * image reads, to a filter, as marketing. Some gateways score it. That is\n * why the setting is per sequence, off by default, and said in the editor\n * where it is turned on.\n *==========================================*/\n\n/** Every character HTML gives a meaning to, as the entity that does not. */\nconst HTML_ESCAPES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '\"': '"',\n \"'\": ''',\n}\n\n/** Text safe to place in HTML content or a double-quoted attribute. */\nexport function escapeOutreachHtml(value: string): string {\n return String(value ?? '').replace(/[&<>\"']/g, (character) => HTML_ESCAPES[character] ?? character)\n}\n\n/** One run of text, escaped, with its line breaks as `<br>`. */\nconst escapedLines = (text: string): string =>\n escapeOutreachHtml(text).replace(/\\r\\n|\\r|\\n/g, '<br>\\n')\n\n/**\n * The HTML part for a plain-text email and its tracking image.\n *\n * @param text The plain-text body as it is sent, footer included — so the\n * HTML part carries the organization's identification and the way out\n * word for word, and any link click tracking already rewrote stays the\n * rewritten link.\n * @param pixelUrl The image's address. Written into a quoted attribute\n * after escaping, so no value can close the attribute.\n */\nexport function outreachOpenTrackedHtml(text: string, pixelUrl: string): string {\n const body = String(text ?? '')\n let html = ''\n let cursor = 0\n // Linked by the same reader click tracking rewrites by, so a link the\n // plain text offers is exactly a link the HTML offers.\n for (const link of outreachBodyLinks(body)) {\n html += escapedLines(body.slice(cursor, link.start))\n const href = escapeOutreachHtml(link.url)\n html += `<a href=\"${href}\">${href}</a>`\n cursor = link.end\n }\n html += escapedLines(body.slice(cursor))\n const src = escapeOutreachHtml(pixelUrl)\n return (\n '<!DOCTYPE html>\\n<html>\\n<head><meta charset=\"utf-8\"></head>\\n<body>\\n' +\n `<div dir=\"auto\">${html}</div>\\n` +\n `<img src=\"${src}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;width:1px;height:1px;border:0\">\\n` +\n '</body>\\n</html>\\n'\n )\n}\n\n/*==========================================\n * WHO OPENED: A PERSON, OR A MACHINE.\n *\n * The same judgement scanner clicks get (`./click-tracking.ts`), with the\n * machines that fetch images rather than links — told apart by what a fetch\n * says it is AND where it came from (AGL-3488), because the agent alone\n * cannot separate them:\n *\n * - **Apple Mail Privacy Protection** fetches every remote image of every\n * message as it arrives, whether or not anyone reads it, naming itself\n * only as `Mozilla/5.0` — and from Apple's relay egress, never from\n * Google's or Microsoft's mail servers. The bare agent from anywhere else\n * is a scanner borrowing it, and is called one: a sequence mailing\n * Google Workspace addresses once read 128 such fetches as Apple's.\n * - **Gmail's `GoogleImageProxy`** is NOT a machine here. Gmail fetches\n * through it when the reader opens the message, so outside the delivery\n * window its fetch is the reader's open, and setting it apart would leave\n * the rate blind to every Gmail and Workspace reader. The proxy caches the\n * image, so a reader's later opens may not arrive at all; the rate is\n * taken over first opens per person either way. Gmail's PREFETCH, which\n * fetches on delivery for some signed-in recipients, names a 2015 Edge\n * instead — no reader's browser — and is a scanner.\n * - **Google's and Microsoft's mail scanning** fetches from their mail\n * networks with whatever agent it likes. Anything from Google's own\n * network that is not the image proxy is a scanner, and so is anything\n * from Exchange Online Protection, Microsoft's mail filter. The rest of\n * Microsoft's mail network is NOT judged by itself: Outlook on the web\n * loads images through it when the reader opens the message.\n * - **Yahoo's image proxy** fetches on the provider's behalf, and is set\n * apart as a proxy.\n * - **Gateways and scanners** that name themselves as the click scanners\n * do, or that arrive within seconds of delivery.\n *\n * Every fetch's agent and network are kept on its history row, so a\n * judgement can be checked against what actually arrived.\n *\n * Counted apart, never dropped: `machineOpens` beside `opens`, as\n * `machineClicks` beside `clicks`.\n *==========================================*/\n\n/**\n * How soon after a send an open is a machine's however it identifies\n * itself — the click window's reasoning, for the same reason: a proxy or a\n * gateway fetches as the message is delivered, and a person reading a cold\n * email does not.\n */\nexport const OUTREACH_OPEN_HUMAN_DELAY_MS = 30_000\n\n/** Image proxies that fetch on a mail provider's behalf, matched as substrings of the agent. */\nconst IMAGE_PROXY_AGENTS = ['yahoomailproxy']\n\n/**\n * Gmail's image proxy, which fetches when the reader opens the message and\n * so counts as their open — see the note above. It names itself in an agent\n * the scanner list would otherwise match (`googleimageproxy`).\n */\nconst GMAIL_PROXY_AGENTS = ['googleimageproxy', 'ggpht.com']\n\n/**\n * Gmail's prefetch: Chrome 42 and Edge 12 in one agent, both from 2015,\n * matched together so neither browser alone is ever read as a machine.\n */\nconst GMAIL_PREFETCH_AGENT_PARTS = ['chrome/42.0.2311.135', 'edge/12.246']\n\n/**\n * The agent Apple's Mail Privacy Protection proxy sends: the bare product\n * token, with no platform after it — which no browser or mail client that\n * shows an image to a person sends.\n */\nconst BARE_AGENT = 'mozilla/5.0'\n\n/** The longest agent kept on a history row: evidence, not a store for whatever a caller sends. */\nexport const OUTREACH_OPEN_AGENT_MAX = 400\n\n/**\n * Why an open was read as a machine's. `scanner` (AGL-3488) is a mail\n * provider's or a security product's fetch, known by where it came from;\n * `agent` is one known by what it called itself.\n */\nexport type OutreachOpenMachineReason =\n | 'agent'\n | 'too_soon'\n | 'method'\n | 'image_proxy'\n | 'privacy_proxy'\n | 'scanner'\n\n/** The reasons that are a mail provider's proxy rather than a scanner. */\nexport const OUTREACH_PROXY_OPEN_REASONS: readonly OutreachOpenMachineReason[] = ['image_proxy', 'privacy_proxy']\n\n/** What one fetch of a tracking image was. */\nexport interface OutreachOpenJudgement {\n /** Whether it counts toward the open rate. */\n human: boolean\n /** Why it does not; `null` when it does. */\n machineReason: OutreachOpenMachineReason | null\n}\n\n/** The agent as a history row keeps it: trimmed, on one line, and no longer than {@link OUTREACH_OPEN_AGENT_MAX}. */\nexport function outreachOpenAgentEvidence(userAgent: string | null | undefined): string | null {\n // Every control character, a line break included, read as a space.\n const agent = Array.from(String(userAgent ?? ''), (character) => {\n const code = character.charCodeAt(0)\n return code < 0x20 || code === 0x7f ? ' ' : character\n })\n .join('')\n .replace(/ {2,}/g, ' ')\n .trim()\n return agent ? agent.slice(0, OUTREACH_OPEN_AGENT_MAX) : null\n}\n\n/**\n * Whether one fetch of a tracking image is a person's open.\n *\n * Read before the open is recorded, never before the image is answered:\n * every fetch gets the same image.\n *\n * @param input.sinceSentMs Milliseconds between the send and this fetch, or\n * `null` when the send it belongs to could not be found.\n * @param input.source The network the fetch came from (`./open-source.ts`),\n * or `null` when no address was read — judged then by the agent alone,\n * as every fetch was before AGL-3488.\n */\nexport function judgeOutreachOpen(input: {\n method: string\n userAgent: string | null | undefined\n sinceSentMs: number | null\n source?: OutreachOpenSource | null\n}): OutreachOpenJudgement {\n const machine = (machineReason: OutreachOpenMachineReason): OutreachOpenJudgement => ({\n human: false,\n machineReason,\n })\n const source = input.source ?? null\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 (source === 'yahoo' || IMAGE_PROXY_AGENTS.some((needle) => agent.includes(needle))) return machine('image_proxy')\n if (agent === BARE_AGENT) {\n // Apple's agent from Apple's relay — or from nowhere we could read,\n // which is how every such fetch was judged before the address was.\n return machine(source === null || source === 'apple' ? 'privacy_proxy' : 'scanner')\n }\n if (GMAIL_PREFETCH_AGENT_PARTS.every((part) => agent.includes(part))) return machine('scanner')\n const gmail = GMAIL_PROXY_AGENTS.some((needle) => agent.includes(needle))\n // Gmail's proxy fetches from Google's network; the same words from any\n // other network that could be read are someone else's.\n if (gmail && source !== null && source !== 'google') return machine('agent')\n if (!gmail && OUTREACH_MACHINE_AGENTS.some((needle) => agent.includes(needle))) return machine('agent')\n if (!gmail && (source === 'google' || source === 'microsoft_filter')) return machine('scanner')\n // A fetch before its send is two clocks disagreeing, and read as a person's.\n if (input.sinceSentMs !== null && input.sinceSentMs >= 0 && input.sinceSentMs < OUTREACH_OPEN_HUMAN_DELAY_MS) {\n return machine('too_soon')\n }\n return { human: true, machineReason: null }\n}\n"],"names":["OUTREACH_MACHINE_AGENTS","outreachBodyLinks","HTML_ESCAPES","escapeOutreachHtml","value","String","replace","character","escapedLines","text","outreachOpenTrackedHtml","pixelUrl","body","html","cursor","link","slice","start","href","url","end","src","OUTREACH_OPEN_HUMAN_DELAY_MS","IMAGE_PROXY_AGENTS","GMAIL_PROXY_AGENTS","GMAIL_PREFETCH_AGENT_PARTS","BARE_AGENT","OUTREACH_OPEN_AGENT_MAX","OUTREACH_PROXY_OPEN_REASONS","outreachOpenAgentEvidence","userAgent","agent","Array","from","code","charCodeAt","join","trim","judgeOutreachOpen","input","machine","machineReason","human","source","method","toUpperCase","toLowerCase","some","needle","includes","every","part","gmail","sinceSentMs"],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
* WHO MAY WORK SEQUENCES IN AN ORGANIZATION, as plain verdicts.
|
|
19
|
+
*
|
|
20
|
+
* The route gate (`routes/route-gate.ts`) answers an HTTP request with them;
|
|
21
|
+
* the do-not-contact transfer resource answers an import or an export with
|
|
22
|
+
* the same ones, so a member the Compliance page would refuse cannot reach
|
|
23
|
+
* the list through a file either. Nothing here reads a request or a token:
|
|
24
|
+
* the caller has already resolved the membership and read the organization.
|
|
25
|
+
*/
|
|
26
|
+
/** Why a member may not work Sequences in an organization. */
|
|
27
|
+
export interface OutreachAccessRefusal {
|
|
28
|
+
status: 403;
|
|
29
|
+
code: 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement';
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
/** The membership as the platform resolves it for one organization. */
|
|
33
|
+
export interface OutreachMembership {
|
|
34
|
+
orgId: string | null;
|
|
35
|
+
/** `null` when the account is not on the organization's roster. */
|
|
36
|
+
role: string | null;
|
|
37
|
+
orgWide: boolean;
|
|
38
|
+
permissions: Partial<Record<string, boolean | undefined>>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The member's refusal, or `null`: on the roster of THIS organization, with
|
|
42
|
+
* reach over all of it (Sequences covers the whole organization, so access to
|
|
43
|
+
* particular sites is not enough), and holding `outreach.use`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function outreachMembershipRefusal(membership: OutreachMembership, orgId: string): OutreachAccessRefusal | null;
|
|
46
|
+
/** The organization's refusal, or `null` when its plan or override includes Sequences. */
|
|
47
|
+
export declare function outreachEntitlementRefusal(org: Record<string, unknown> | null | undefined): OutreachAccessRefusal | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
17
|
+
import { OUTREACH_USE_PERMISSION } from "../constants/bundle-common.js";
|
|
18
|
+
/**
|
|
19
|
+
* The member's refusal, or `null`: on the roster of THIS organization, with
|
|
20
|
+
* reach over all of it (Sequences covers the whole organization, so access to
|
|
21
|
+
* particular sites is not enough), and holding `outreach.use`.
|
|
22
|
+
*/ export function outreachMembershipRefusal(membership, orgId) {
|
|
23
|
+
// A targeted org the account is not on answers with that org's id and no
|
|
24
|
+
// role — as does a lookup that failed closed — so the role is the test.
|
|
25
|
+
if (!membership.role || membership.orgId !== orgId) {
|
|
26
|
+
return {
|
|
27
|
+
status: 403,
|
|
28
|
+
code: 'not-a-member',
|
|
29
|
+
message: 'You are not a member of that organization.'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (!membership.orgWide) {
|
|
33
|
+
return {
|
|
34
|
+
status: 403,
|
|
35
|
+
code: 'not-org-wide',
|
|
36
|
+
message: 'Sequences covers the whole organization, and your access is to particular sites.'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {
|
|
40
|
+
return {
|
|
41
|
+
status: 403,
|
|
42
|
+
code: 'permission',
|
|
43
|
+
message: 'Your role does not include Use Sequences.'
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** The organization's refusal, or `null` when its plan or override includes Sequences. */ export function outreachEntitlementRefusal(org) {
|
|
49
|
+
return checkEntitlement(org != null ? org : {}, 'outreach') ? null : {
|
|
50
|
+
status: 403,
|
|
51
|
+
code: 'entitlement',
|
|
52
|
+
message: "Sequences isn't available to this workspace yet."
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=outreach-access.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/outreach-access.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 { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { OUTREACH_USE_PERMISSION } from '../constants/bundle-common'\n\n/**\n * WHO MAY WORK SEQUENCES IN AN ORGANIZATION, as plain verdicts.\n *\n * The route gate (`routes/route-gate.ts`) answers an HTTP request with them;\n * the do-not-contact transfer resource answers an import or an export with\n * the same ones, so a member the Compliance page would refuse cannot reach\n * the list through a file either. Nothing here reads a request or a token:\n * the caller has already resolved the membership and read the organization.\n */\n\n/** Why a member may not work Sequences in an organization. */\nexport interface OutreachAccessRefusal {\n status: 403\n code: 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement'\n message: string\n}\n\n/** The membership as the platform resolves it for one organization. */\nexport interface OutreachMembership {\n orgId: string | null\n /** `null` when the account is not on the organization's roster. */\n role: string | null\n orgWide: boolean\n permissions: Partial<Record<string, boolean | undefined>>\n}\n\n/**\n * The member's refusal, or `null`: on the roster of THIS organization, with\n * reach over all of it (Sequences covers the whole organization, so access to\n * particular sites is not enough), and holding `outreach.use`.\n */\nexport function outreachMembershipRefusal(\n membership: OutreachMembership,\n orgId: string,\n): OutreachAccessRefusal | null {\n // A targeted org the account is not on answers with that org's id and no\n // role — as does a lookup that failed closed — so the role is the test.\n if (!membership.role || membership.orgId !== orgId) {\n return { status: 403, code: 'not-a-member', message: 'You are not a member of that organization.' }\n }\n if (!membership.orgWide) {\n return {\n status: 403,\n code: 'not-org-wide',\n message: 'Sequences covers the whole organization, and your access is to particular sites.',\n }\n }\n if (membership.permissions[OUTREACH_USE_PERMISSION] !== true) {\n return { status: 403, code: 'permission', message: 'Your role does not include Use Sequences.' }\n }\n return null\n}\n\n/** The organization's refusal, or `null` when its plan or override includes Sequences. */\nexport function outreachEntitlementRefusal(\n org: Record<string, unknown> | null | undefined,\n): OutreachAccessRefusal | null {\n return checkEntitlement(org ?? {}, 'outreach')\n ? null\n : { status: 403, code: 'entitlement', message: \"Sequences isn't available to this workspace yet.\" }\n}\n"],"names":["checkEntitlement","OUTREACH_USE_PERMISSION","outreachMembershipRefusal","membership","orgId","role","status","code","message","orgWide","permissions","outreachEntitlementRefusal","org"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,2CAA0C;AAC3E,SAASC,uBAAuB,QAAQ,gCAA4B;AA4BpE;;;;CAIC,GACD,OAAO,SAASC,0BACdC,UAA8B,EAC9BC,KAAa;IAEb,yEAAyE;IACzE,wEAAwE;IACxE,IAAI,CAACD,WAAWE,IAAI,IAAIF,WAAWC,KAAK,KAAKA,OAAO;QAClD,OAAO;YAAEE,QAAQ;YAAKC,MAAM;YAAgBC,SAAS;QAA6C;IACpG;IACA,IAAI,CAACL,WAAWM,OAAO,EAAE;QACvB,OAAO;YACLH,QAAQ;YACRC,MAAM;YACNC,SAAS;QACX;IACF;IACA,IAAIL,WAAWO,WAAW,CAACT,wBAAwB,KAAK,MAAM;QAC5D,OAAO;YAAEK,QAAQ;YAAKC,MAAM;YAAcC,SAAS;QAA4C;IACjG;IACA,OAAO;AACT;AAEA,wFAAwF,GACxF,OAAO,SAASG,2BACdC,GAA+C;IAE/C,OAAOZ,iBAAiBY,cAAAA,MAAO,CAAC,GAAG,cAC/B,OACA;QAAEN,QAAQ;QAAKC,MAAM;QAAeC,SAAS;IAAmD;AACtG"}
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
import { type OutreachGateHold, type OutreachGateLookups } from '../engine/gates';
|
|
18
18
|
import { type OutreachAttestations, type OutreachSequenceSettings } from '../model/outreach.types';
|
|
19
19
|
import type { OutreachEnrollBlock, OutreachEnrollPreviewPerson, OutreachPersonRef } from '../model/outreach-api';
|
|
20
|
-
type Firestore = FirebaseFirestore.Firestore;
|
|
21
20
|
/**
|
|
22
21
|
* One person as the preview and the enroll read them: a contact, or a lead
|
|
23
22
|
* the sequence's site holds (AGL-3234).
|
|
@@ -46,9 +45,9 @@ export interface OutreachEnrollCandidate {
|
|
|
46
45
|
/** The lead's person key; `null` for a contact. */
|
|
47
46
|
leadId: string | null;
|
|
48
47
|
/**
|
|
49
|
-
* The contact
|
|
50
|
-
* contact-shaped view of it — see the module note. `null`
|
|
51
|
-
* no longer exists.
|
|
48
|
+
* The contact as the record system keeps it (`plugin-person-records`), or
|
|
49
|
+
* for a lead the contact-shaped view of it — see the module note. `null`
|
|
50
|
+
* when the record no longer exists.
|
|
52
51
|
*/
|
|
53
52
|
contact: Record<string, unknown> | null;
|
|
54
53
|
/** The lead document as stored, for a lead; `null` for a contact. */
|
|
@@ -76,12 +75,14 @@ export interface OutreachEnrollCandidate {
|
|
|
76
75
|
export declare function leadAsContact(lead: Record<string, unknown>, contactGroupId: string): Record<string, unknown>;
|
|
77
76
|
/**
|
|
78
77
|
* The people, as the sequence's site knows them, and the company each
|
|
79
|
-
* contact is filed under there —
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
78
|
+
* contact is filed under there — asked of the plugin that keeps people
|
|
79
|
+
* (`plugin-person-records`, AGL-3080), never read off its storage: the
|
|
80
|
+
* contacts and the leads at once, the companies once each through its
|
|
81
|
+
* `company` record index, and for each lead one address lookup, because a
|
|
82
|
+
* lead the workspace already holds as a contact is that contact's to enroll.
|
|
83
|
+
* With no plugin keeping people, every person reads as gone.
|
|
83
84
|
*/
|
|
84
|
-
export declare function readOutreachEnrollCandidates(
|
|
85
|
+
export declare function readOutreachEnrollCandidates(input: {
|
|
85
86
|
orgId: string;
|
|
86
87
|
hostId: string;
|
|
87
88
|
contactGroupId: string;
|
|
@@ -128,4 +129,3 @@ export declare function decideOutreachEnrollment(question: OutreachGateQuestion,
|
|
|
128
129
|
uid: string;
|
|
129
130
|
nowMs: number;
|
|
130
131
|
}): OutreachEnrollDecision;
|
|
131
|
-
export {};
|
|
@@ -50,7 +50,8 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
50
50
|
*==========================================*/ import { normalizeContactEmail, contactDisplayName, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
|
|
51
51
|
import { crmLeadDisplayName, crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
|
|
52
52
|
import { seenOnlyThroughGrant, visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
53
|
-
import {
|
|
53
|
+
import { findPluginPerson, readPluginPeople } from "@aglyn/aglyn/plugin-manager/plugin-person-records";
|
|
54
|
+
import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
54
55
|
import { evaluateOutreachGates } from "../engine/gates.js";
|
|
55
56
|
import { outreachAttestationsFrom } from "../engine/enrollment-state.js";
|
|
56
57
|
import { OUTREACH_ATTESTATION_KINDS } from "../model/outreach.types.js";
|
|
@@ -90,33 +91,33 @@ import { OUTREACH_ATTESTATION_KINDS } from "../model/outreach.types.js";
|
|
|
90
91
|
}
|
|
91
92
|
});
|
|
92
93
|
}
|
|
93
|
-
/** `getAll` takes this many references at most per call. */ const GET_ALL_CHUNK = 300;
|
|
94
|
-
async function getAllChunked(firestore, refs) {
|
|
95
|
-
const snapshots = [];
|
|
96
|
-
for(let start = 0; start < refs.length; start += GET_ALL_CHUNK){
|
|
97
|
-
snapshots.push(...await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK)));
|
|
98
|
-
}
|
|
99
|
-
return snapshots;
|
|
100
|
-
}
|
|
101
94
|
/**
|
|
102
95
|
* The people, as the sequence's site knows them, and the company each
|
|
103
|
-
* contact is filed under there —
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
96
|
+
* contact is filed under there — asked of the plugin that keeps people
|
|
97
|
+
* (`plugin-person-records`, AGL-3080), never read off its storage: the
|
|
98
|
+
* contacts and the leads at once, the companies once each through its
|
|
99
|
+
* `company` record index, and for each lead one address lookup, because a
|
|
100
|
+
* lead the workspace already holds as a contact is that contact's to enroll.
|
|
101
|
+
* With no plugin keeping people, every person reads as gone.
|
|
102
|
+
*/ export async function readOutreachEnrollCandidates(input) {
|
|
103
|
+
var _ref;
|
|
108
104
|
if (!input.people.length) return [];
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
const records = (_ref = await readPluginPeople({
|
|
106
|
+
orgId: input.orgId,
|
|
107
|
+
records: input.people.map((person)=>({
|
|
108
|
+
kind: person.kind,
|
|
109
|
+
id: person.id
|
|
110
|
+
}))
|
|
111
|
+
})) != null ? _ref : input.people.map(()=>null);
|
|
113
112
|
const read = await Promise.all(input.people.map(async (person, index)=>{
|
|
114
113
|
var _readContactFacet_companyId;
|
|
115
|
-
|
|
116
|
-
const data = ((_snapshots_index = snapshots[index]) == null ? void 0 : _snapshots_index.exists) ? snapshots[index].data() : null;
|
|
114
|
+
const data = records[index] ? _extends({}, records[index].data) : null;
|
|
117
115
|
if (person.kind === 'lead') {
|
|
118
116
|
const email = data ? normalizeContactEmail(data['email']) : null;
|
|
119
|
-
const held = data && email ? await
|
|
117
|
+
const held = data && email ? await findPluginPerson({
|
|
118
|
+
orgId: input.orgId,
|
|
119
|
+
email
|
|
120
|
+
}) : null;
|
|
120
121
|
return {
|
|
121
122
|
personId: person.id,
|
|
122
123
|
target: 'lead',
|
|
@@ -154,10 +155,14 @@ async function getAllChunked(firestore, refs) {
|
|
|
154
155
|
...new Set(read.map((entry)=>entry.companyId).filter(Boolean))
|
|
155
156
|
];
|
|
156
157
|
const companies = new Map();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
found.
|
|
160
|
-
|
|
158
|
+
const companyIndex = companyIds.length ? pluginRecordIndex('company') : null;
|
|
159
|
+
if (companyIndex) {
|
|
160
|
+
const found = await Promise.all(companyIds.map((id)=>companyIndex.index.get({
|
|
161
|
+
orgId: input.orgId,
|
|
162
|
+
id
|
|
163
|
+
})));
|
|
164
|
+
found.forEach((company, index)=>{
|
|
165
|
+
if (company) companies.set(companyIds[index], _extends({}, company.facts));
|
|
161
166
|
});
|
|
162
167
|
}
|
|
163
168
|
return read.map((_0)=>{
|