@aglyn/plugins-outreach 1.0.0-beta.160 → 1.0.0-beta.162
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/README.md +4 -0
- package/package.json +13 -13
- package/src/lib/components/compliance-section.d.ts +3 -2
- package/src/lib/components/compliance-section.js +7 -2
- package/src/lib/components/compliance-section.js.map +1 -1
- package/src/lib/components/do-not-contact-domains.d.ts +29 -0
- package/src/lib/components/do-not-contact-domains.js +208 -0
- package/src/lib/components/do-not-contact-domains.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +5 -1
- package/src/lib/components/use-outreach-api.js +12 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +8 -1
- package/src/lib/components/use-outreach-data.js +32 -0
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/api-routes.d.ts +5 -0
- package/src/lib/constants/api-routes.js +4 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/do-not-contact-domain.d.ts +39 -0
- package/src/lib/engine/do-not-contact-domain.js +51 -0
- package/src/lib/engine/do-not-contact-domain.js.map +1 -0
- package/src/lib/engine/gates.d.ts +6 -1
- package/src/lib/engine/gates.js +12 -1
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/gateway-block.d.ts +31 -0
- package/src/lib/engine/gateway-block.js +54 -0
- package/src/lib/engine/gateway-block.js.map +1 -0
- package/src/lib/engine/index.d.ts +3 -0
- package/src/lib/engine/index.js +3 -0
- package/src/lib/engine/index.js.map +1 -1
- package/src/lib/engine/mailbox-health.d.ts +5 -0
- package/src/lib/engine/mailbox-health.js +12 -5
- package/src/lib/engine/mailbox-health.js.map +1 -1
- package/src/lib/engine/mailbox-notice.d.ts +46 -0
- package/src/lib/engine/mailbox-notice.js +64 -0
- package/src/lib/engine/mailbox-notice.js.map +1 -0
- package/src/lib/enrollment/enroll-people.js +6 -3
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.js +7 -3
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/model/outreach-api.d.ts +23 -2
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +33 -1
- package/src/lib/model/outreach.types.js +11 -1
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/routes/do-not-contact-routes.d.ts +35 -0
- package/src/lib/routes/do-not-contact-routes.js +92 -0
- package/src/lib/routes/do-not-contact-routes.js.map +1 -0
- package/src/lib/routes/enroll-routes.js +1 -0
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.js +13 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -1
- package/src/lib/routes/platform-deps.js +2 -1
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/register-routes.js +8 -1
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +6 -0
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/runtime/enrollment-events.d.ts +6 -2
- package/src/lib/runtime/enrollment-events.js +20 -3
- package/src/lib/runtime/enrollment-events.js.map +1 -1
- package/src/lib/runtime/mailbox-health-store.d.ts +1 -1
- package/src/lib/runtime/mailbox-health-store.js +17 -4
- package/src/lib/runtime/mailbox-health-store.js.map +1 -1
- package/src/lib/runtime/mailbox-notices.d.ts +39 -0
- package/src/lib/runtime/mailbox-notices.js +117 -0
- package/src/lib/runtime/mailbox-notices.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +4 -0
- package/src/lib/runtime/person-erasure.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +32 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +38 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +6 -6
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.d.ts +2 -0
- package/src/lib/runtime/sync-job.js +84 -15
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/runtime/timeline.d.ts +10 -1
- package/src/lib/runtime/timeline.js +23 -1
- package/src/lib/runtime/timeline.js.map +1 -1
- package/src/lib/runtime/unsubscribe-route.d.ts +1 -1
- package/src/lib/runtime/unsubscribe-route.js.map +1 -1
- package/src/lib/server.d.ts +1 -1
- package/src/lib/server.js +1 -1
- package/src/lib/server.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.d.ts +28 -1
- package/src/lib/storage/do-not-contact-store.js +117 -9
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/index.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 * The Outreach engine (AGL-2979): pure decisions, no I/O, safe for the\n * console and the server alike. `./do-not-contact` is deliberately absent —\n * it hashes with `node:crypto` — and is imported by its own path.\n */\nexport * from './sequence-validation'\nexport * from './recipient-country'\nexport * from './gates'\nexport * from './schedule'\nexport * from './sending-capacity'\nexport * from './enrollment-state'\nexport * from './compose'\nexport * from './thread-message'\nexport * from './delivery-status'\nexport * from './opt-out-intent'\nexport * from './thread-classification'\nexport * from './mailbox-health'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,2BAAuB;AACrC,cAAc,yBAAqB;AACnC,cAAc,aAAS;AACvB,cAAc,gBAAY;AAC1B,cAAc,wBAAoB;AAClC,cAAc,wBAAoB;AAClC,cAAc,eAAW;AACzB,cAAc,sBAAkB;AAChC,cAAc,uBAAmB;AACjC,cAAc,sBAAkB;AAChC,cAAc,6BAAyB;AACvC,cAAc,sBAAkB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/index.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 * The Outreach engine (AGL-2979): pure decisions, no I/O, safe for the\n * console and the server alike. `./do-not-contact` is deliberately absent —\n * it hashes with `node:crypto` — and is imported by its own path.\n */\nexport * from './sequence-validation'\nexport * from './recipient-country'\nexport * from './gates'\nexport * from './schedule'\nexport * from './sending-capacity'\nexport * from './enrollment-state'\nexport * from './compose'\nexport * from './thread-message'\nexport * from './delivery-status'\nexport * from './opt-out-intent'\nexport * from './thread-classification'\nexport * from './mailbox-health'\nexport * from './mailbox-notice'\nexport * from './gateway-block'\nexport * from './do-not-contact-domain'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,2BAAuB;AACrC,cAAc,yBAAqB;AACnC,cAAc,aAAS;AACvB,cAAc,gBAAY;AAC1B,cAAc,wBAAoB;AAClC,cAAc,wBAAoB;AAClC,cAAc,eAAW;AACzB,cAAc,sBAAkB;AAChC,cAAc,uBAAmB;AACjC,cAAc,sBAAkB;AAChC,cAAc,6BAAyB;AACvC,cAAc,sBAAkB;AAChC,cAAc,sBAAkB;AAChC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB"}
|
|
@@ -20,6 +20,11 @@ export declare const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
|
|
|
20
20
|
export declare const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
|
|
21
21
|
/** The bounce rate a mailbox may not exceed over that window. */
|
|
22
22
|
export declare const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
|
|
23
|
+
/**
|
|
24
|
+
* The fewest sends the window must hold before its bounce rate is judged
|
|
25
|
+
* (AGL-3244). Below it the daily rule alone pauses the mailbox.
|
|
26
|
+
*/
|
|
27
|
+
export declare const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25;
|
|
23
28
|
/** How long a spam complaint keeps a mailbox paused. */
|
|
24
29
|
export declare const OUTREACH_COMPLAINT_PAUSE_MS: number;
|
|
25
30
|
export type OutreachHealthPauseReason = 'bounces_today' | 'bounce_rate' | 'complaint';
|
|
@@ -25,10 +25,13 @@
|
|
|
25
25
|
* - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one
|
|
26
26
|
* of its own days; or
|
|
27
27
|
* - more than {@link OUTREACH_PAUSE_BOUNCE_RATE} of its last
|
|
28
|
-
* {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced, once the
|
|
29
|
+
* window holds at least {@link OUTREACH_BOUNCE_RATE_MIN_SENDS} of them
|
|
30
|
+
* (AGL-3244). Under that floor one bounce IS the rate — 1 of 21 is 4.8% —
|
|
31
|
+
* and a single gateway block on a cold day closed a whole sending window
|
|
32
|
+
* on the strength of one address. A mailbox with fewer sends is still
|
|
33
|
+
* judged on the sends it has by the first rule: a second bounce in one
|
|
34
|
+
* day pauses it whatever the window holds; or
|
|
32
35
|
* - a reply called its email spam in the last week — a complaint is worth a
|
|
33
36
|
* week of review, and the pause lasts until the week is out.
|
|
34
37
|
*
|
|
@@ -37,6 +40,10 @@
|
|
|
37
40
|
*==========================================*/ /** Hard bounces in one mailbox day that pause the mailbox. */ export const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
|
|
38
41
|
/** How many of the most recent sends the bounce rate is taken over. */ export const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
|
|
39
42
|
/** The bounce rate a mailbox may not exceed over that window. */ export const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
|
|
43
|
+
/**
|
|
44
|
+
* The fewest sends the window must hold before its bounce rate is judged
|
|
45
|
+
* (AGL-3244). Below it the daily rule alone pauses the mailbox.
|
|
46
|
+
*/ export const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25;
|
|
40
47
|
/** How long a spam complaint keeps a mailbox paused. */ export const OUTREACH_COMPLAINT_PAUSE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41
48
|
const count = (value)=>{
|
|
42
49
|
const number = Number(value);
|
|
@@ -54,7 +61,7 @@ const count = (value)=>{
|
|
|
54
61
|
}
|
|
55
62
|
const sends = Math.min(count(input.recentSends), OUTREACH_BOUNCE_RATE_WINDOW_SENDS);
|
|
56
63
|
const bounces = Math.min(count(input.recentHardBounces), sends);
|
|
57
|
-
if (sends
|
|
64
|
+
if (sends >= OUTREACH_BOUNCE_RATE_MIN_SENDS && bounces / sends > OUTREACH_PAUSE_BOUNCE_RATE) {
|
|
58
65
|
const percent = (bounces / sends * 100).toFixed(1);
|
|
59
66
|
return {
|
|
60
67
|
pause: true,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mailbox-health.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 * WHEN A MAILBOX STOPS ITSELF (AGL-2979).\n *\n * Bounces are the one signal a small sender gets early: at this volume the\n * mailbox providers' own dashboards show nothing, and a list with bad\n * addresses in it shows up as hard bounces days before it shows up as a\n * reputation problem. So a mailbox pauses itself, and a person re-checks\n * the list before it sends again, when:\n *\n * - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one\n * of its own days; or\n * - more than {@link OUTREACH_PAUSE_BOUNCE_RATE} of its last\n * {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mailbox-health.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 * WHEN A MAILBOX STOPS ITSELF (AGL-2979).\n *\n * Bounces are the one signal a small sender gets early: at this volume the\n * mailbox providers' own dashboards show nothing, and a list with bad\n * addresses in it shows up as hard bounces days before it shows up as a\n * reputation problem. So a mailbox pauses itself, and a person re-checks\n * the list before it sends again, when:\n *\n * - it collects {@link OUTREACH_PAUSE_BOUNCES_PER_DAY} hard bounces in one\n * of its own days; or\n * - more than {@link OUTREACH_PAUSE_BOUNCE_RATE} of its last\n * {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} sends hard-bounced, once the\n * window holds at least {@link OUTREACH_BOUNCE_RATE_MIN_SENDS} of them\n * (AGL-3244). Under that floor one bounce IS the rate — 1 of 21 is 4.8% —\n * and a single gateway block on a cold day closed a whole sending window\n * on the strength of one address. A mailbox with fewer sends is still\n * judged on the sends it has by the first rule: a second bounce in one\n * day pauses it whatever the window holds; or\n * - a reply called its email spam in the last week — a complaint is worth a\n * week of review, and the pause lasts until the week is out.\n *\n * Only hard bounces count. A soft bounce is a full mailbox or a busy server,\n * and the sending server is still retrying it.\n *==========================================*/\n\n/** Hard bounces in one mailbox day that pause the mailbox. */\nexport const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2\n\n/** How many of the most recent sends the bounce rate is taken over. */\nexport const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50\n\n/** The bounce rate a mailbox may not exceed over that window. */\nexport const OUTREACH_PAUSE_BOUNCE_RATE = 0.03\n\n/**\n * The fewest sends the window must hold before its bounce rate is judged\n * (AGL-3244). Below it the daily rule alone pauses the mailbox.\n */\nexport const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25\n\n/** How long a spam complaint keeps a mailbox paused. */\nexport const OUTREACH_COMPLAINT_PAUSE_MS = 7 * 24 * 60 * 60 * 1000\n\nexport type OutreachHealthPauseReason = 'bounces_today' | 'bounce_rate' | 'complaint'\n\nexport interface OutreachMailboxHealthInput {\n /** Hard bounces on this mailbox's mail received during its current local day. */\n bouncesToday: number\n /**\n * The mailbox's most recent sends — at most\n * {@link OUTREACH_BOUNCE_RATE_WINDOW_SENDS} of them — and how many of\n * those hard-bounced.\n */\n recentSends: number\n recentHardBounces: number\n /** When a reply last called this mailbox's email spam, or `null`. */\n lastComplaintAtMs?: number | null\n nowMs: number\n}\n\nexport interface OutreachMailboxHealthDecision {\n pause: boolean\n reason: OutreachHealthPauseReason | null\n /** What the mailbox's page says, in a sentence. */\n message: string | null\n /** For a complaint, when the pause may end; `null` when a person has to resume it. */\n pausedUntilMs: number | null\n}\n\nconst count = (value: unknown): number => {\n const number = Number(value)\n return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0\n}\n\n/** Whether a mailbox should pause itself now — see the module note. */\nexport function decideOutreachMailboxHealth(\n input: OutreachMailboxHealthInput,\n): OutreachMailboxHealthDecision {\n const bouncesToday = count(input.bouncesToday)\n if (bouncesToday >= OUTREACH_PAUSE_BOUNCES_PER_DAY) {\n return {\n pause: true,\n reason: 'bounces_today',\n message: `Paused after ${bouncesToday} hard bounces today. Re-check the addresses in your sequences before resuming.`,\n pausedUntilMs: null,\n }\n }\n const sends = Math.min(count(input.recentSends), OUTREACH_BOUNCE_RATE_WINDOW_SENDS)\n const bounces = Math.min(count(input.recentHardBounces), sends)\n if (sends >= OUTREACH_BOUNCE_RATE_MIN_SENDS && bounces / sends > OUTREACH_PAUSE_BOUNCE_RATE) {\n const percent = ((bounces / sends) * 100).toFixed(1)\n return {\n pause: true,\n reason: 'bounce_rate',\n message: `Paused: ${bounces} of the last ${sends} emails hard-bounced (${percent}%). Re-check the addresses in your sequences before resuming.`,\n pausedUntilMs: null,\n }\n }\n const complaintAtMs = input.lastComplaintAtMs\n if (\n typeof complaintAtMs === 'number' &&\n Number.isFinite(complaintAtMs) &&\n input.nowMs < complaintAtMs + OUTREACH_COMPLAINT_PAUSE_MS\n ) {\n return {\n pause: true,\n reason: 'complaint',\n message:\n 'Paused for a week after a reply called an email spam. Review who your sequences are reaching.',\n pausedUntilMs: complaintAtMs + OUTREACH_COMPLAINT_PAUSE_MS,\n }\n }\n return { pause: false, reason: null, message: null, pausedUntilMs: null }\n}\n"],"names":["OUTREACH_PAUSE_BOUNCES_PER_DAY","OUTREACH_BOUNCE_RATE_WINDOW_SENDS","OUTREACH_PAUSE_BOUNCE_RATE","OUTREACH_BOUNCE_RATE_MIN_SENDS","OUTREACH_COMPLAINT_PAUSE_MS","count","value","number","Number","isFinite","Math","max","floor","decideOutreachMailboxHealth","input","bouncesToday","pause","reason","message","pausedUntilMs","sends","min","recentSends","bounces","recentHardBounces","percent","toFixed","complaintAtMs","lastComplaintAtMs","nowMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;4CAwB4C,GAE5C,4DAA4D,GAC5D,OAAO,MAAMA,iCAAiC,EAAC;AAE/C,qEAAqE,GACrE,OAAO,MAAMC,oCAAoC,GAAE;AAEnD,+DAA+D,GAC/D,OAAO,MAAMC,6BAA6B,KAAI;AAE9C;;;CAGC,GACD,OAAO,MAAMC,iCAAiC,GAAE;AAEhD,sDAAsD,GACtD,OAAO,MAAMC,8BAA8B,IAAI,KAAK,KAAK,KAAK,KAAI;AA4BlE,MAAMC,QAAQ,CAACC;IACb,MAAMC,SAASC,OAAOF;IACtB,OAAOE,OAAOC,QAAQ,CAACF,UAAUG,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACL,WAAW;AACrE;AAEA,qEAAqE,GACrE,OAAO,SAASM,4BACdC,KAAiC;IAEjC,MAAMC,eAAeV,MAAMS,MAAMC,YAAY;IAC7C,IAAIA,gBAAgBf,gCAAgC;QAClD,OAAO;YACLgB,OAAO;YACPC,QAAQ;YACRC,SAAS,CAAC,aAAa,EAAEH,aAAa,8EAA8E,CAAC;YACrHI,eAAe;QACjB;IACF;IACA,MAAMC,QAAQV,KAAKW,GAAG,CAAChB,MAAMS,MAAMQ,WAAW,GAAGrB;IACjD,MAAMsB,UAAUb,KAAKW,GAAG,CAAChB,MAAMS,MAAMU,iBAAiB,GAAGJ;IACzD,IAAIA,SAASjB,kCAAkCoB,UAAUH,QAAQlB,4BAA4B;QAC3F,MAAMuB,UAAU,AAAC,CAAA,AAACF,UAAUH,QAAS,GAAE,EAAGM,OAAO,CAAC;QAClD,OAAO;YACLV,OAAO;YACPC,QAAQ;YACRC,SAAS,CAAC,QAAQ,EAAEK,QAAQ,aAAa,EAAEH,MAAM,sBAAsB,EAAEK,QAAQ,6DAA6D,CAAC;YAC/IN,eAAe;QACjB;IACF;IACA,MAAMQ,gBAAgBb,MAAMc,iBAAiB;IAC7C,IACE,OAAOD,kBAAkB,YACzBnB,OAAOC,QAAQ,CAACkB,kBAChBb,MAAMe,KAAK,GAAGF,gBAAgBvB,6BAC9B;QACA,OAAO;YACLY,OAAO;YACPC,QAAQ;YACRC,SACE;YACFC,eAAeQ,gBAAgBvB;QACjC;IACF;IACA,OAAO;QAAEY,OAAO;QAAOC,QAAQ;QAAMC,SAAS;QAAMC,eAAe;IAAK;AAC1E"}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
export type OutreachMailboxNoticeKind = 'auto_pause' | 'reconnect_required';
|
|
18
|
+
export interface OutreachMailboxNotice {
|
|
19
|
+
kind: OutreachMailboxNoticeKind;
|
|
20
|
+
mailbox: {
|
|
21
|
+
/** The account's own address. */
|
|
22
|
+
email: string;
|
|
23
|
+
/** The address mail goes out as, when it is not the account's. */
|
|
24
|
+
sendAs: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* For a pause, the engine's own sentence — `autoPause.message`; for a
|
|
29
|
+
* reconnect, {@link outreachReconnectRequiredSentence}.
|
|
30
|
+
*/
|
|
31
|
+
message: string;
|
|
32
|
+
/** Enrollments active on the mailbox, waiting for it to send again. */
|
|
33
|
+
waiting: number;
|
|
34
|
+
}
|
|
35
|
+
export interface OutreachMailboxNoticeEmail {
|
|
36
|
+
subject: string;
|
|
37
|
+
text: string;
|
|
38
|
+
}
|
|
39
|
+
/** The mailbox as the notice names it: `Avery Quinn <avery@example.com>`, the send-as beside it when it differs. */
|
|
40
|
+
export declare function outreachMailboxNoticeName(mailbox: OutreachMailboxNotice['mailbox']): string;
|
|
41
|
+
/** What the notice says when Google stopped accepting the mailbox's connection. */
|
|
42
|
+
export declare function outreachReconnectRequiredSentence(errorCode: string | null | undefined): string;
|
|
43
|
+
/** The email, composed — see the module note. */
|
|
44
|
+
export declare function composeOutreachMailboxNotice(notice: OutreachMailboxNotice, options: {
|
|
45
|
+
mailboxesUrl: string | null;
|
|
46
|
+
}): OutreachMailboxNoticeEmail;
|
|
@@ -0,0 +1,64 @@
|
|
|
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 MAILBOX'S OWNER IS TOLD (AGL-3244).
|
|
18
|
+
*
|
|
19
|
+
* A mailbox that pauses itself, or that Google stops accepting, holds every
|
|
20
|
+
* enrollment on it until a person acts — and the person is the member whose
|
|
21
|
+
* mailbox it is, who is not watching the Mailboxes page. So the moment the
|
|
22
|
+
* runtime judges it, they are emailed: the engine's own sentence for the
|
|
23
|
+
* pause, the mailbox by name, how many enrollments are waiting on it, and
|
|
24
|
+
* the link to the page where Resume and Reconnect live.
|
|
25
|
+
*
|
|
26
|
+
* Plain text, one screen long, and the reason in the first line: it is read
|
|
27
|
+
* on a phone between meetings.
|
|
28
|
+
*==========================================*/ /** The mailbox as the notice names it: `Avery Quinn <avery@example.com>`, the send-as beside it when it differs. */ export function outreachMailboxNoticeName(mailbox) {
|
|
29
|
+
var _mailbox_displayName, _mailbox_sendAs;
|
|
30
|
+
const name = String((_mailbox_displayName = mailbox.displayName) != null ? _mailbox_displayName : '').trim();
|
|
31
|
+
const own = name ? `${name} <${mailbox.email}>` : mailbox.email;
|
|
32
|
+
const sendAs = String((_mailbox_sendAs = mailbox.sendAs) != null ? _mailbox_sendAs : '').trim().toLowerCase();
|
|
33
|
+
return sendAs && sendAs !== String(mailbox.email).trim().toLowerCase() ? `${own}, sending as ${sendAs}` : own;
|
|
34
|
+
}
|
|
35
|
+
/** What the notice says when Google stopped accepting the mailbox's connection. */ export function outreachReconnectRequiredSentence(errorCode) {
|
|
36
|
+
const code = String(errorCode != null ? errorCode : '').trim();
|
|
37
|
+
return 'Google stopped accepting this mailbox’s connection' + (code ? ` (${code})` : '') + ' — the password changed, access was removed from the Google account, or an administrator revoked it. ' + 'Nothing sends from it until it is connected again.';
|
|
38
|
+
}
|
|
39
|
+
function waitingSentence(waiting) {
|
|
40
|
+
const count = Math.max(0, Math.floor(Number(waiting) || 0));
|
|
41
|
+
if (count === 0) return 'No enrollment is waiting on it right now.';
|
|
42
|
+
return count === 1 ? '1 enrollment is active on it and waiting; its next email goes out once the mailbox sends again.' : `${count} enrollments are active on it and waiting; their next emails go out once the mailbox sends again.`;
|
|
43
|
+
}
|
|
44
|
+
/** The email, composed — see the module note. */ export function composeOutreachMailboxNotice(notice, options) {
|
|
45
|
+
var _notice_message;
|
|
46
|
+
const name = outreachMailboxNoticeName(notice.mailbox);
|
|
47
|
+
const paused = notice.kind === 'auto_pause';
|
|
48
|
+
const subject = paused ? `Sequences paused your mailbox ${notice.mailbox.email}` : `Your mailbox ${notice.mailbox.email} needs reconnecting`;
|
|
49
|
+
const action = paused ? 'Re-check the addresses in the sequences it sends, then open Mailboxes and select Resume.' : 'Open Mailboxes and select Reconnect to connect it again.';
|
|
50
|
+
const where = options.mailboxesUrl ? `${action}\n\n${options.mailboxesUrl}` : `${action} Mailboxes is under Sequences in your workspace’s console.`;
|
|
51
|
+
const text = [
|
|
52
|
+
paused ? `Sequences paused the mailbox ${name}.` : `The mailbox ${name} needs reconnecting.`,
|
|
53
|
+
String((_notice_message = notice.message) != null ? _notice_message : '').trim(),
|
|
54
|
+
waitingSentence(notice.waiting),
|
|
55
|
+
where,
|
|
56
|
+
'You are getting this because the mailbox is yours, or you manage the organization it belongs to.'
|
|
57
|
+
].filter(Boolean).join('\n\n');
|
|
58
|
+
return {
|
|
59
|
+
subject,
|
|
60
|
+
text
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=mailbox-notice.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/mailbox-notice.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 * WHAT THE MAILBOX'S OWNER IS TOLD (AGL-3244).\n *\n * A mailbox that pauses itself, or that Google stops accepting, holds every\n * enrollment on it until a person acts — and the person is the member whose\n * mailbox it is, who is not watching the Mailboxes page. So the moment the\n * runtime judges it, they are emailed: the engine's own sentence for the\n * pause, the mailbox by name, how many enrollments are waiting on it, and\n * the link to the page where Resume and Reconnect live.\n *\n * Plain text, one screen long, and the reason in the first line: it is read\n * on a phone between meetings.\n *==========================================*/\n\nexport type OutreachMailboxNoticeKind = 'auto_pause' | 'reconnect_required'\n\nexport interface OutreachMailboxNotice {\n kind: OutreachMailboxNoticeKind\n mailbox: {\n /** The account's own address. */\n email: string\n /** The address mail goes out as, when it is not the account's. */\n sendAs: string\n displayName: string\n }\n /**\n * For a pause, the engine's own sentence — `autoPause.message`; for a\n * reconnect, {@link outreachReconnectRequiredSentence}.\n */\n message: string\n /** Enrollments active on the mailbox, waiting for it to send again. */\n waiting: number\n}\n\nexport interface OutreachMailboxNoticeEmail {\n subject: string\n text: string\n}\n\n/** The mailbox as the notice names it: `Avery Quinn <avery@example.com>`, the send-as beside it when it differs. */\nexport function outreachMailboxNoticeName(mailbox: OutreachMailboxNotice['mailbox']): string {\n const name = String(mailbox.displayName ?? '').trim()\n const own = name ? `${name} <${mailbox.email}>` : mailbox.email\n const sendAs = String(mailbox.sendAs ?? '')\n .trim()\n .toLowerCase()\n return sendAs && sendAs !== String(mailbox.email).trim().toLowerCase() ? `${own}, sending as ${sendAs}` : own\n}\n\n/** What the notice says when Google stopped accepting the mailbox's connection. */\nexport function outreachReconnectRequiredSentence(errorCode: string | null | undefined): string {\n const code = String(errorCode ?? '').trim()\n return (\n 'Google stopped accepting this mailbox’s connection' +\n (code ? ` (${code})` : '') +\n ' — the password changed, access was removed from the Google account, or an administrator revoked it. ' +\n 'Nothing sends from it until it is connected again.'\n )\n}\n\nfunction waitingSentence(waiting: number): string {\n const count = Math.max(0, Math.floor(Number(waiting) || 0))\n if (count === 0) return 'No enrollment is waiting on it right now.'\n return count === 1\n ? '1 enrollment is active on it and waiting; its next email goes out once the mailbox sends again.'\n : `${count} enrollments are active on it and waiting; their next emails go out once the mailbox sends again.`\n}\n\n/** The email, composed — see the module note. */\nexport function composeOutreachMailboxNotice(\n notice: OutreachMailboxNotice,\n options: { mailboxesUrl: string | null },\n): OutreachMailboxNoticeEmail {\n const name = outreachMailboxNoticeName(notice.mailbox)\n const paused = notice.kind === 'auto_pause'\n const subject = paused\n ? `Sequences paused your mailbox ${notice.mailbox.email}`\n : `Your mailbox ${notice.mailbox.email} needs reconnecting`\n const action = paused\n ? 'Re-check the addresses in the sequences it sends, then open Mailboxes and select Resume.'\n : 'Open Mailboxes and select Reconnect to connect it again.'\n const where = options.mailboxesUrl\n ? `${action}\\n\\n${options.mailboxesUrl}`\n : `${action} Mailboxes is under Sequences in your workspace’s console.`\n const text = [\n paused ? `Sequences paused the mailbox ${name}.` : `The mailbox ${name} needs reconnecting.`,\n String(notice.message ?? '').trim(),\n waitingSentence(notice.waiting),\n where,\n 'You are getting this because the mailbox is yours, or you manage the organization it belongs to.',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n return { subject, text }\n}\n"],"names":["outreachMailboxNoticeName","mailbox","name","String","displayName","trim","own","email","sendAs","toLowerCase","outreachReconnectRequiredSentence","errorCode","code","waitingSentence","waiting","count","Math","max","floor","Number","composeOutreachMailboxNotice","notice","options","paused","kind","subject","action","where","mailboxesUrl","text","message","filter","Boolean","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;4CAY4C,GA2B5C,kHAAkH,GAClH,OAAO,SAASA,0BAA0BC,OAAyC;QAC7DA,sBAEEA;IAFtB,MAAMC,OAAOC,QAAOF,uBAAAA,QAAQG,WAAW,YAAnBH,uBAAuB,IAAII,IAAI;IACnD,MAAMC,MAAMJ,OAAO,GAAGA,KAAK,EAAE,EAAED,QAAQM,KAAK,CAAC,CAAC,CAAC,GAAGN,QAAQM,KAAK;IAC/D,MAAMC,SAASL,QAAOF,kBAAAA,QAAQO,MAAM,YAAdP,kBAAkB,IACrCI,IAAI,GACJI,WAAW;IACd,OAAOD,UAAUA,WAAWL,OAAOF,QAAQM,KAAK,EAAEF,IAAI,GAAGI,WAAW,KAAK,GAAGH,IAAI,aAAa,EAAEE,QAAQ,GAAGF;AAC5G;AAEA,iFAAiF,GACjF,OAAO,SAASI,kCAAkCC,SAAoC;IACpF,MAAMC,OAAOT,OAAOQ,oBAAAA,YAAa,IAAIN,IAAI;IACzC,OACE,uDACCO,CAAAA,OAAO,CAAC,EAAE,EAAEA,KAAK,CAAC,CAAC,GAAG,EAAC,IACxB,0GACA;AAEJ;AAEA,SAASC,gBAAgBC,OAAe;IACtC,MAAMC,QAAQC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACC,OAAOL,YAAY;IACxD,IAAIC,UAAU,GAAG,OAAO;IACxB,OAAOA,UAAU,IACb,oGACA,GAAGA,MAAM,iGAAiG,CAAC;AACjH;AAEA,+CAA+C,GAC/C,OAAO,SAASK,6BACdC,MAA6B,EAC7BC,OAAwC;QAe/BD;IAbT,MAAMnB,OAAOF,0BAA0BqB,OAAOpB,OAAO;IACrD,MAAMsB,SAASF,OAAOG,IAAI,KAAK;IAC/B,MAAMC,UAAUF,SACZ,CAAC,8BAA8B,EAAEF,OAAOpB,OAAO,CAACM,KAAK,EAAE,GACvD,CAAC,aAAa,EAAEc,OAAOpB,OAAO,CAACM,KAAK,CAAC,mBAAmB,CAAC;IAC7D,MAAMmB,SAASH,SACX,6FACA;IACJ,MAAMI,QAAQL,QAAQM,YAAY,GAC9B,GAAGF,OAAO,IAAI,EAAEJ,QAAQM,YAAY,EAAE,GACtC,GAAGF,OAAO,0DAA0D,CAAC;IACzE,MAAMG,OAAO;QACXN,SAAS,CAAC,6BAA6B,EAAErB,KAAK,CAAC,CAAC,GAAG,CAAC,YAAY,EAAEA,KAAK,oBAAoB,CAAC;QAC5FC,QAAOkB,kBAAAA,OAAOS,OAAO,YAAdT,kBAAkB,IAAIhB,IAAI;QACjCQ,gBAAgBQ,OAAOP,OAAO;QAC9Ba;QACA;KACD,CACEI,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAO;QAAER;QAASI;IAAK;AACzB"}
|
|
@@ -67,9 +67,12 @@ import { OUTREACH_ATTESTATION_KINDS } from "../model/outreach.types.js";
|
|
|
67
67
|
const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source;
|
|
68
68
|
if (kind) sources[kind] = true;
|
|
69
69
|
}
|
|
70
|
-
return {
|
|
70
|
+
return _extends({
|
|
71
71
|
email: lead['email'],
|
|
72
|
-
name: lead['name']
|
|
72
|
+
name: lead['name']
|
|
73
|
+
}, lead['emailState'] && typeof lead['emailState'] === 'object' ? {
|
|
74
|
+
emailState: lead['emailState']
|
|
75
|
+
} : {}, {
|
|
73
76
|
facets: {
|
|
74
77
|
[contactGroupId]: _extends({
|
|
75
78
|
sources
|
|
@@ -85,7 +88,7 @@ import { OUTREACH_ATTESTATION_KINDS } from "../model/outreach.types.js";
|
|
|
85
88
|
tags: lead['tags']
|
|
86
89
|
} : {})
|
|
87
90
|
}
|
|
88
|
-
};
|
|
91
|
+
});
|
|
89
92
|
}
|
|
90
93
|
/** `getAll` takes this many references at most per call. */ const GET_ALL_CHUNK = 300;
|
|
91
94
|
async function getAllChunked(firestore, refs) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enroll-people.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 * WHO CAN BE ENROLLED, AND WHAT THE REP STILL OWES (AGL-2980).\n *\n * The enroll dialog shows every person a view or a search named, each\n * marked eligible, blocked, or waiting on the rep; Confirm asks the same\n * question again, on the server, with what the rep supplied. Both answers\n * come from the engine's `evaluateOutreachGates` — this module only reads\n * the people and asks it the right questions.\n *\n * ## Three questions of one pure function\n *\n * The gates refuse a cold contact until the rep writes a personal line and\n * ticks the attestations, and refuse an address in an unknown country until\n * the rep confirms it is a US business address. So a preview asks:\n *\n * 1. as the person stands, with nothing confirmed — allowed means\n * ELIGIBLE;\n * 2. as if the rep confirmed everything they can — refused means BLOCKED,\n * for the reasons that remain, which no tick can clear;\n * 3. otherwise the person is WAITING ON THE REP, for what (1) found\n * missing.\n *\n * The confirmation in (2) is hypothetical and never stored: Confirm stamps\n * only what the rep ticked, with their uid and the time, and asks again.\n *\n * ## One enrollment per person per sequence, ever\n *\n * The enrollment's id is the sequence and the contact\n * (`outreachEnrollmentId`), and it is written with `create()`. A person who\n * finished, replied or was stopped is not sent the same emails a second\n * time by being enrolled again, and two members confirming the same person\n * at once write one enrollment.\n *==========================================*/\n\nimport { normalizeContactEmail, contactDisplayName, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmLeadDisplayName, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n evaluateOutreachGates,\n type OutreachGateBlock,\n type OutreachGateLookups,\n} from '../engine/gates'\nimport { outreachAttestationsFrom } from '../engine/enrollment-state'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport type {\n OutreachEnrollBlock,\n OutreachEnrollPreviewPerson,\n OutreachPersonRef,\n} from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * One person as the preview and the enroll read them: a contact, or a lead\n * the sequence's site holds (AGL-3234).\n *\n * ## A lead is judged as the contact it would be\n *\n * The gates, the merge fields and the country rule all read a CONTACT\n * document — its facet's sources, stage, address, company and title. A lead\n * carries the same facts in its own shape, so rather than teach every gate\n * two records, a lead is handed to them as {@link leadAsContact}: a\n * contact-shaped view whose one facet, under the sequence's group, holds\n * what the lead holds. An imported lead has no inbound source and is cold;\n * one that wrote in through a form is not; a lead is never a customer; its\n * address decides its country; and `{{contact.firstName}}` or\n * `{{contact.company}}` in a step reads the lead's name and company text,\n * so a sequence written for contacts sends to leads unchanged — which is\n * also what lets an enrollment made on a lead keep sending after the lead\n * converts and the view becomes the real contact.\n */\nexport interface OutreachEnrollCandidate {\n /** The record's own id: the contact's, or the lead's person key. */\n personId: string\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n /**\n * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the\n * contact-shaped view of it — see the module note. `null` when the record\n * no longer exists.\n */\n contact: Record<string, unknown> | null\n /** The lead document as stored, for a lead; `null` for a contact. */\n lead: Record<string, unknown> | null\n /** Whether the sequence's site may see the record. A lead is the site's own. */\n visible: boolean\n /** The name the sequence's site knows them by; `''` for none. */\n name: string\n /** The primary address, normalized, or `null` when there is none. */\n email: string | null\n /** The company the contact is filed under at that site, when there is one. */\n company: Record<string, unknown> | null\n /** A lead already converted: the contact it became. Enroll that instead. */\n convertedContactId: string | null\n /** A lead whose address the workspace already holds as a contact. Enroll that instead. */\n heldAsContactId: string | null\n}\n\n/**\n * A lead as the gates and the merge fields read a contact — see the\n * module note. The facet is the sequence's group's, which is the one every\n * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,\n * `manual`, `api`, `signup` — become the facet's source flags by kind, so\n * the inbound rule reads them as it reads a contact's.\n */\nexport function leadAsContact(\n lead: Record<string, unknown>,\n contactGroupId: string,\n): Record<string, unknown> {\n const sources: Record<string, true> = {}\n for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []) {\n const source = String(raw)\n const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source\n if (kind) sources[kind] = true\n }\n return {\n email: lead['email'],\n name: lead['name'],\n facets: {\n [contactGroupId]: {\n sources,\n ...(lead['address'] && typeof lead['address'] === 'object' ? { address: lead['address'] } : {}),\n ...(typeof lead['company'] === 'string' ? { companyName: lead['company'] } : {}),\n ...(typeof lead['jobTitle'] === 'string' ? { jobTitle: lead['jobTitle'] } : {}),\n ...(typeof lead['phone'] === 'string' ? { phone: lead['phone'] } : {}),\n ...(Array.isArray(lead['tags']) ? { tags: lead['tags'] } : {}),\n },\n },\n }\n}\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * The people, as the sequence's site knows them, and the company each\n * contact is filed under there — batched reads for everyone: the contacts\n * and the leads by id, the companies once, and for each lead one address\n * lookup, because a lead the workspace already holds as a contact is that\n * contact's to enroll.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; people: readonly OutreachPersonRef[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.people.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n const leads = firestore.collection('hosts').doc(input.hostId).collection('leads')\n const snapshots = await getAllChunked(\n firestore,\n input.people.map((person) =>\n person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id),\n ),\n )\n const read = await Promise.all(\n input.people.map(async (person, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n if (person.kind === 'lead') {\n const email = data ? normalizeContactEmail(data['email']) : null\n const held =\n data && email ? await findContactByEmail(org.collection('contacts'), email) : null\n return {\n personId: person.id,\n target: 'lead' as const,\n contactId: '',\n leadId: person.id,\n contact: data ? leadAsContact(data, input.contactGroupId) : null,\n lead: data,\n // A lead is the site's own record: no scope to check.\n visible: data !== null,\n name: data ? crmLeadDisplayName(data) : '',\n email,\n companyId: '',\n convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,\n heldAsContactId: held ? held.id : null,\n }\n }\n return {\n personId: person.id,\n target: 'contact' as const,\n contactId: person.id,\n leadId: null,\n contact: data,\n lead: null,\n visible: data ? visibleToHost(data['visibleTo'] as string[] | undefined, input.hostId) : false,\n name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',\n email: data ? normalizeContactEmail(data['email']) : null,\n companyId: data ? String(readContactFacet(data, input.contactGroupId).companyId ?? '') : '',\n convertedContactId: null,\n heldAsContactId: null,\n }\n }),\n )\n const companyIds = [...new Set(read.map((entry) => entry.companyId).filter(Boolean))]\n const companies = new Map<string, Record<string, unknown>>()\n if (companyIds.length) {\n const found = await getAllChunked(\n firestore,\n companyIds.map((id) => org.collection('companies').doc(id)),\n )\n found.forEach((snapshot, index) => {\n if (snapshot.exists) companies.set(companyIds[index], snapshot.data() as Record<string, unknown>)\n })\n }\n return read.map(({ companyId, ...candidate }) => ({\n ...candidate,\n company: companyId ? (companies.get(companyId) ?? null) : null,\n }))\n}\n\n/** The person as every answer names them. */\nfunction personOf(candidate: OutreachEnrollCandidate) {\n return {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n}\n\n/** What stands in the way before the gates are asked anything. */\nexport function outreachCandidateBlocks(\n candidate: OutreachEnrollCandidate,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollBlock[] {\n if (!candidate.contact) {\n return candidate.target === 'lead'\n ? [{ code: 'lead_missing', reason: \"This lead no longer exists in this sequence's site's CRM.\" }]\n : [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\n if (candidate.target === 'lead') {\n // A lead that converted, or whose address the workspace already holds\n // as a contact, is that contact's to enroll — one person, one record.\n if (candidate.convertedContactId) {\n blocks.push({\n code: 'lead_converted',\n reason: 'This lead was converted into a contact. Enroll the contact instead.',\n })\n } else if (candidate.heldAsContactId) {\n blocks.push({\n code: 'lead_is_contact',\n reason: 'This address is already a contact in your CRM. Enroll the contact instead.',\n })\n }\n if (crmLeadStatus(candidate.lead) === 'unqualified') {\n blocks.push({\n code: 'lead_unqualified',\n reason: 'This lead was closed as unqualified. Reopen it before enrolling.',\n })\n }\n }\n if (!candidate.visible) {\n blocks.push({\n code: 'not_in_site',\n reason: `This contact isn't in ${facts.siteName || \"this sequence's site\"}'s CRM.`,\n })\n }\n if (!candidate.email) {\n blocks.push({ code: 'no_email', reason: 'This contact has no email address.' })\n }\n if (facts.alreadyInSequence) {\n blocks.push({\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n })\n }\n return blocks\n}\n\n/** Every attestation, stamped for a hypothetical question only — never stored. */\nconst EVERYTHING_CONFIRMED: OutreachAttestations = Object.fromEntries(\n OUTREACH_ATTESTATION_KINDS.map((kind) => [kind, { uid: 'preview', atMs: 1 }]),\n)\n\nconst blocksOf = (blocks: readonly OutreachGateBlock[]): OutreachEnrollBlock[] =>\n blocks.map(({ code, reason }) => ({ code, reason }))\n\nexport interface OutreachGateQuestion {\n candidate: OutreachEnrollCandidate\n lookups: OutreachGateLookups\n /** The sequence's settings, its countries already capped by the organization's. */\n settings: OutreachSequenceSettings\n contactGroupId: string\n}\n\n/** Where one person stands before the rep confirms anything — see the module note. */\nexport function previewOutreachPerson(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollPreviewPerson {\n const { candidate } = question\n const person = {\n ...personOf(candidate),\n name: candidate.name,\n email: candidate.email,\n }\n const early = outreachCandidateBlocks(candidate, facts)\n if (early.length) {\n return {\n ...person,\n cold: false,\n country: null,\n status: 'blocked',\n blocks: early,\n requires: { personalLine: false, attestations: [] },\n }\n }\n const ask = (personalLine: string, attestations: OutreachAttestations) =>\n evaluateOutreachGates({\n email: candidate.email ?? '',\n contact: candidate.contact,\n contactGroupId: question.contactGroupId,\n company: candidate.company,\n settings: question.settings,\n personalLine,\n attestations,\n lookups: question.lookups,\n })\n const asIs = ask('', {})\n const known = { cold: asIs.cold, country: asIs.country.country }\n if (asIs.allowed) {\n return {\n ...person,\n ...known,\n status: 'eligible',\n blocks: [],\n requires: { personalLine: false, attestations: [] },\n }\n }\n // The line is only checked for being present, so any sentence stands in.\n const confirmed = ask('A personal line.', EVERYTHING_CONFIRMED)\n if (!confirmed.allowed) {\n return {\n ...person,\n ...known,\n status: 'blocked',\n blocks: blocksOf(confirmed.blocks),\n requires: { personalLine: false, attestations: [] },\n }\n }\n const attestations: OutreachAttestationKind[] = asIs.cold\n ? [...asIs.missingAttestations]\n : asIs.blocks.some((block) => block.code === 'country_unknown')\n ? ['us_business_address']\n : []\n return {\n ...person,\n ...known,\n status: 'needs_confirmation',\n blocks: [],\n requires: {\n personalLine: asIs.blocks.some((block) => block.code === 'personal_line_missing'),\n attestations,\n },\n }\n}\n\nexport interface OutreachEnrollDecision {\n allowed: boolean\n blocks: OutreachEnrollBlock[]\n cold: boolean\n /** The address as the gates normalized it. */\n email: string | null\n /** What the rep confirmed, stamped with who and when. */\n attestations: OutreachAttestations\n}\n\n/** Confirm's answer for one person, with what the rep supplied — see the module note. */\nexport function decideOutreachEnrollment(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n supplied: {\n personalLine: string\n attestations: readonly unknown[]\n uid: string\n nowMs: number\n },\n): OutreachEnrollDecision {\n const attestations = outreachAttestationsFrom(supplied.attestations, supplied.uid, supplied.nowMs)\n const early = outreachCandidateBlocks(question.candidate, facts)\n if (early.length) {\n return { allowed: false, blocks: early, cold: false, email: question.candidate.email, attestations }\n }\n const result = evaluateOutreachGates({\n email: question.candidate.email ?? '',\n contact: question.candidate.contact,\n contactGroupId: question.contactGroupId,\n company: question.candidate.company,\n settings: question.settings,\n personalLine: supplied.personalLine,\n attestations,\n lookups: question.lookups,\n })\n return {\n allowed: result.allowed,\n blocks: blocksOf(result.blocks),\n cold: result.cold,\n email: result.email,\n attestations,\n }\n}\n"],"names":["normalizeContactEmail","contactDisplayName","readContactFacet","crmLeadDisplayName","crmLeadStatus","visibleToHost","findContactByEmail","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","leadAsContact","lead","contactGroupId","sources","raw","Array","isArray","source","String","kind","startsWith","email","name","facets","address","companyName","jobTitle","phone","tags","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","people","org","collection","doc","orgId","leads","hostId","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","companyId","convertedContactId","heldAsContactId","trim","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","personOf","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","uid","atMs","blocksOf","previewOutreachPerson","question","early","cold","country","status","requires","personalLine","attestations","ask","settings","lookups","asIs","known","allowed","confirmed","missingAttestations","some","block","decideOutreachEnrollment","supplied","nowMs","result"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enroll-people.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 * WHO CAN BE ENROLLED, AND WHAT THE REP STILL OWES (AGL-2980).\n *\n * The enroll dialog shows every person a view or a search named, each\n * marked eligible, blocked, or waiting on the rep; Confirm asks the same\n * question again, on the server, with what the rep supplied. Both answers\n * come from the engine's `evaluateOutreachGates` — this module only reads\n * the people and asks it the right questions.\n *\n * ## Three questions of one pure function\n *\n * The gates refuse a cold contact until the rep writes a personal line and\n * ticks the attestations, and refuse an address in an unknown country until\n * the rep confirms it is a US business address. So a preview asks:\n *\n * 1. as the person stands, with nothing confirmed — allowed means\n * ELIGIBLE;\n * 2. as if the rep confirmed everything they can — refused means BLOCKED,\n * for the reasons that remain, which no tick can clear;\n * 3. otherwise the person is WAITING ON THE REP, for what (1) found\n * missing.\n *\n * The confirmation in (2) is hypothetical and never stored: Confirm stamps\n * only what the rep ticked, with their uid and the time, and asks again.\n *\n * ## One enrollment per person per sequence, ever\n *\n * The enrollment's id is the sequence and the contact\n * (`outreachEnrollmentId`), and it is written with `create()`. A person who\n * finished, replied or was stopped is not sent the same emails a second\n * time by being enrolled again, and two members confirming the same person\n * at once write one enrollment.\n *==========================================*/\n\nimport { normalizeContactEmail, contactDisplayName, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmLeadDisplayName, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n evaluateOutreachGates,\n type OutreachGateBlock,\n type OutreachGateLookups,\n} from '../engine/gates'\nimport { outreachAttestationsFrom } from '../engine/enrollment-state'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport type {\n OutreachEnrollBlock,\n OutreachEnrollPreviewPerson,\n OutreachPersonRef,\n} from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * One person as the preview and the enroll read them: a contact, or a lead\n * the sequence's site holds (AGL-3234).\n *\n * ## A lead is judged as the contact it would be\n *\n * The gates, the merge fields and the country rule all read a CONTACT\n * document — its facet's sources, stage, address, company and title. A lead\n * carries the same facts in its own shape, so rather than teach every gate\n * two records, a lead is handed to them as {@link leadAsContact}: a\n * contact-shaped view whose one facet, under the sequence's group, holds\n * what the lead holds. An imported lead has no inbound source and is cold;\n * one that wrote in through a form is not; a lead is never a customer; its\n * address decides its country; and `{{contact.firstName}}` or\n * `{{contact.company}}` in a step reads the lead's name and company text,\n * so a sequence written for contacts sends to leads unchanged — which is\n * also what lets an enrollment made on a lead keep sending after the lead\n * converts and the view becomes the real contact.\n */\nexport interface OutreachEnrollCandidate {\n /** The record's own id: the contact's, or the lead's person key. */\n personId: string\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n /**\n * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the\n * contact-shaped view of it — see the module note. `null` when the record\n * no longer exists.\n */\n contact: Record<string, unknown> | null\n /** The lead document as stored, for a lead; `null` for a contact. */\n lead: Record<string, unknown> | null\n /** Whether the sequence's site may see the record. A lead is the site's own. */\n visible: boolean\n /** The name the sequence's site knows them by; `''` for none. */\n name: string\n /** The primary address, normalized, or `null` when there is none. */\n email: string | null\n /** The company the contact is filed under at that site, when there is one. */\n company: Record<string, unknown> | null\n /** A lead already converted: the contact it became. Enroll that instead. */\n convertedContactId: string | null\n /** A lead whose address the workspace already holds as a contact. Enroll that instead. */\n heldAsContactId: string | null\n}\n\n/**\n * A lead as the gates and the merge fields read a contact — see the\n * module note. The facet is the sequence's group's, which is the one every\n * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,\n * `manual`, `api`, `signup` — become the facet's source flags by kind, so\n * the inbound rule reads them as it reads a contact's.\n */\nexport function leadAsContact(\n lead: Record<string, unknown>,\n contactGroupId: string,\n): Record<string, unknown> {\n const sources: Record<string, true> = {}\n for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []) {\n const source = String(raw)\n const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source\n if (kind) sources[kind] = true\n }\n return {\n email: lead['email'],\n name: lead['name'],\n // The verdict on the address (AGL-3245) is the lead's own top-level\n // field, as it is the contact's, so the gates quote it either way.\n ...(lead['emailState'] && typeof lead['emailState'] === 'object' ? { emailState: lead['emailState'] } : {}),\n facets: {\n [contactGroupId]: {\n sources,\n ...(lead['address'] && typeof lead['address'] === 'object' ? { address: lead['address'] } : {}),\n ...(typeof lead['company'] === 'string' ? { companyName: lead['company'] } : {}),\n ...(typeof lead['jobTitle'] === 'string' ? { jobTitle: lead['jobTitle'] } : {}),\n ...(typeof lead['phone'] === 'string' ? { phone: lead['phone'] } : {}),\n ...(Array.isArray(lead['tags']) ? { tags: lead['tags'] } : {}),\n },\n },\n }\n}\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * The people, as the sequence's site knows them, and the company each\n * contact is filed under there — batched reads for everyone: the contacts\n * and the leads by id, the companies once, and for each lead one address\n * lookup, because a lead the workspace already holds as a contact is that\n * contact's to enroll.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; people: readonly OutreachPersonRef[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.people.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n const leads = firestore.collection('hosts').doc(input.hostId).collection('leads')\n const snapshots = await getAllChunked(\n firestore,\n input.people.map((person) =>\n person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id),\n ),\n )\n const read = await Promise.all(\n input.people.map(async (person, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n if (person.kind === 'lead') {\n const email = data ? normalizeContactEmail(data['email']) : null\n const held =\n data && email ? await findContactByEmail(org.collection('contacts'), email) : null\n return {\n personId: person.id,\n target: 'lead' as const,\n contactId: '',\n leadId: person.id,\n contact: data ? leadAsContact(data, input.contactGroupId) : null,\n lead: data,\n // A lead is the site's own record: no scope to check.\n visible: data !== null,\n name: data ? crmLeadDisplayName(data) : '',\n email,\n companyId: '',\n convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,\n heldAsContactId: held ? held.id : null,\n }\n }\n return {\n personId: person.id,\n target: 'contact' as const,\n contactId: person.id,\n leadId: null,\n contact: data,\n lead: null,\n visible: data ? visibleToHost(data['visibleTo'] as string[] | undefined, input.hostId) : false,\n name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',\n email: data ? normalizeContactEmail(data['email']) : null,\n companyId: data ? String(readContactFacet(data, input.contactGroupId).companyId ?? '') : '',\n convertedContactId: null,\n heldAsContactId: null,\n }\n }),\n )\n const companyIds = [...new Set(read.map((entry) => entry.companyId).filter(Boolean))]\n const companies = new Map<string, Record<string, unknown>>()\n if (companyIds.length) {\n const found = await getAllChunked(\n firestore,\n companyIds.map((id) => org.collection('companies').doc(id)),\n )\n found.forEach((snapshot, index) => {\n if (snapshot.exists) companies.set(companyIds[index], snapshot.data() as Record<string, unknown>)\n })\n }\n return read.map(({ companyId, ...candidate }) => ({\n ...candidate,\n company: companyId ? (companies.get(companyId) ?? null) : null,\n }))\n}\n\n/** The person as every answer names them. */\nfunction personOf(candidate: OutreachEnrollCandidate) {\n return {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n}\n\n/** What stands in the way before the gates are asked anything. */\nexport function outreachCandidateBlocks(\n candidate: OutreachEnrollCandidate,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollBlock[] {\n if (!candidate.contact) {\n return candidate.target === 'lead'\n ? [{ code: 'lead_missing', reason: \"This lead no longer exists in this sequence's site's CRM.\" }]\n : [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\n if (candidate.target === 'lead') {\n // A lead that converted, or whose address the workspace already holds\n // as a contact, is that contact's to enroll — one person, one record.\n if (candidate.convertedContactId) {\n blocks.push({\n code: 'lead_converted',\n reason: 'This lead was converted into a contact. Enroll the contact instead.',\n })\n } else if (candidate.heldAsContactId) {\n blocks.push({\n code: 'lead_is_contact',\n reason: 'This address is already a contact in your CRM. Enroll the contact instead.',\n })\n }\n if (crmLeadStatus(candidate.lead) === 'unqualified') {\n blocks.push({\n code: 'lead_unqualified',\n reason: 'This lead was closed as unqualified. Reopen it before enrolling.',\n })\n }\n }\n if (!candidate.visible) {\n blocks.push({\n code: 'not_in_site',\n reason: `This contact isn't in ${facts.siteName || \"this sequence's site\"}'s CRM.`,\n })\n }\n if (!candidate.email) {\n blocks.push({ code: 'no_email', reason: 'This contact has no email address.' })\n }\n if (facts.alreadyInSequence) {\n blocks.push({\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n })\n }\n return blocks\n}\n\n/** Every attestation, stamped for a hypothetical question only — never stored. */\nconst EVERYTHING_CONFIRMED: OutreachAttestations = Object.fromEntries(\n OUTREACH_ATTESTATION_KINDS.map((kind) => [kind, { uid: 'preview', atMs: 1 }]),\n)\n\nconst blocksOf = (blocks: readonly OutreachGateBlock[]): OutreachEnrollBlock[] =>\n blocks.map(({ code, reason }) => ({ code, reason }))\n\nexport interface OutreachGateQuestion {\n candidate: OutreachEnrollCandidate\n lookups: OutreachGateLookups\n /** The sequence's settings, its countries already capped by the organization's. */\n settings: OutreachSequenceSettings\n contactGroupId: string\n}\n\n/** Where one person stands before the rep confirms anything — see the module note. */\nexport function previewOutreachPerson(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollPreviewPerson {\n const { candidate } = question\n const person = {\n ...personOf(candidate),\n name: candidate.name,\n email: candidate.email,\n }\n const early = outreachCandidateBlocks(candidate, facts)\n if (early.length) {\n return {\n ...person,\n cold: false,\n country: null,\n status: 'blocked',\n blocks: early,\n requires: { personalLine: false, attestations: [] },\n }\n }\n const ask = (personalLine: string, attestations: OutreachAttestations) =>\n evaluateOutreachGates({\n email: candidate.email ?? '',\n contact: candidate.contact,\n contactGroupId: question.contactGroupId,\n company: candidate.company,\n settings: question.settings,\n personalLine,\n attestations,\n lookups: question.lookups,\n })\n const asIs = ask('', {})\n const known = { cold: asIs.cold, country: asIs.country.country }\n if (asIs.allowed) {\n return {\n ...person,\n ...known,\n status: 'eligible',\n blocks: [],\n requires: { personalLine: false, attestations: [] },\n }\n }\n // The line is only checked for being present, so any sentence stands in.\n const confirmed = ask('A personal line.', EVERYTHING_CONFIRMED)\n if (!confirmed.allowed) {\n return {\n ...person,\n ...known,\n status: 'blocked',\n blocks: blocksOf(confirmed.blocks),\n requires: { personalLine: false, attestations: [] },\n }\n }\n const attestations: OutreachAttestationKind[] = asIs.cold\n ? [...asIs.missingAttestations]\n : asIs.blocks.some((block) => block.code === 'country_unknown')\n ? ['us_business_address']\n : []\n return {\n ...person,\n ...known,\n status: 'needs_confirmation',\n blocks: [],\n requires: {\n personalLine: asIs.blocks.some((block) => block.code === 'personal_line_missing'),\n attestations,\n },\n }\n}\n\nexport interface OutreachEnrollDecision {\n allowed: boolean\n blocks: OutreachEnrollBlock[]\n cold: boolean\n /** The address as the gates normalized it. */\n email: string | null\n /** What the rep confirmed, stamped with who and when. */\n attestations: OutreachAttestations\n}\n\n/** Confirm's answer for one person, with what the rep supplied — see the module note. */\nexport function decideOutreachEnrollment(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n supplied: {\n personalLine: string\n attestations: readonly unknown[]\n uid: string\n nowMs: number\n },\n): OutreachEnrollDecision {\n const attestations = outreachAttestationsFrom(supplied.attestations, supplied.uid, supplied.nowMs)\n const early = outreachCandidateBlocks(question.candidate, facts)\n if (early.length) {\n return { allowed: false, blocks: early, cold: false, email: question.candidate.email, attestations }\n }\n const result = evaluateOutreachGates({\n email: question.candidate.email ?? '',\n contact: question.candidate.contact,\n contactGroupId: question.contactGroupId,\n company: question.candidate.company,\n settings: question.settings,\n personalLine: supplied.personalLine,\n attestations,\n lookups: question.lookups,\n })\n return {\n allowed: result.allowed,\n blocks: blocksOf(result.blocks),\n cold: result.cold,\n email: result.email,\n attestations,\n }\n}\n"],"names":["normalizeContactEmail","contactDisplayName","readContactFacet","crmLeadDisplayName","crmLeadStatus","visibleToHost","findContactByEmail","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","leadAsContact","lead","contactGroupId","sources","raw","Array","isArray","source","String","kind","startsWith","email","name","emailState","facets","address","companyName","jobTitle","phone","tags","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","people","org","collection","doc","orgId","leads","hostId","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","companyId","convertedContactId","heldAsContactId","trim","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","personOf","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","uid","atMs","blocksOf","previewOutreachPerson","question","early","cold","country","status","requires","personalLine","attestations","ask","settings","lookups","asIs","known","allowed","confirmed","missingAttestations","some","block","decideOutreachEnrollment","supplied","nowMs","result"],"mappings":"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"}
|
|
@@ -41,7 +41,7 @@ import { EMAIL_TOPIC_SALES, readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLEC
|
|
|
41
41
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
42
42
|
import { loadCrmInboundRoster } from "@aglyn/tenant-data-admin/server/crm-inbound-email";
|
|
43
43
|
import { EMAIL_SUPPRESSIONS_COLLECTION, HOST_SUPPRESSIONS_SUBCOLLECTION } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
44
|
-
import { lookupOutreachDoNotContact } from "../storage/do-not-contact-store.js";
|
|
44
|
+
import { lookupOutreachDoNotContact, lookupOutreachDoNotContactDomains } from "../storage/do-not-contact-store.js";
|
|
45
45
|
import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
46
46
|
/** `getAll` takes this many references at most per call. */ const GET_ALL_CHUNK = 300;
|
|
47
47
|
async function getAllChunked(firestore, refs) {
|
|
@@ -82,7 +82,7 @@ async function getAllChunked(firestore, refs) {
|
|
|
82
82
|
const host = firestore.collection('hosts').doc(hostId);
|
|
83
83
|
const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments');
|
|
84
84
|
const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities);
|
|
85
|
-
const [platform, site, sales, doNotContact, roster, perPerson] = await Promise.all([
|
|
85
|
+
const [platform, site, sales, doNotContact, doNotContactDomain, roster, perPerson] = await Promise.all([
|
|
86
86
|
keyedLookup(firestore, people, (key)=>firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key), // A released record no longer suppresses.
|
|
87
87
|
(snapshot)=>snapshot.exists && !snapshot.get('releasedAt'), 'platform suppression'),
|
|
88
88
|
keyedLookup(firestore, people, (key)=>host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key), (snapshot)=>snapshot.exists, 'site suppression'),
|
|
@@ -91,6 +91,9 @@ async function getAllChunked(firestore, refs) {
|
|
|
91
91
|
return readTopicSubscriptionState(((_ref = snapshot.exists ? snapshot.get('topics') : null) != null ? _ref : {})[EMAIL_TOPIC_SALES]);
|
|
92
92
|
}, 'sales topic'),
|
|
93
93
|
lookupOutreachDoNotContact(firestore, orgId, people.map((person)=>person.email)),
|
|
94
|
+
// The domain half of the same list (AGL-3244), one `getAll` over the
|
|
95
|
+
// distinct domains the people are at.
|
|
96
|
+
lookupOutreachDoNotContactDomains(firestore, orgId, people.map((person)=>person.email)),
|
|
94
97
|
loadCrmInboundRoster(firestore, orgId).catch((error)=>{
|
|
95
98
|
console.error('[outreach] roster lookup failed; reading as unchecked', error);
|
|
96
99
|
return null;
|
|
@@ -139,13 +142,14 @@ async function getAllChunked(firestore, refs) {
|
|
|
139
142
|
]));
|
|
140
143
|
const lookups = new Map();
|
|
141
144
|
for (const person of people){
|
|
142
|
-
var _platform_get, _site_get, _sales_get, _doNotContact_get, _ref, _ref1;
|
|
145
|
+
var _platform_get, _site_get, _sales_get, _doNotContact_get, _doNotContactDomain_get, _ref, _ref1;
|
|
143
146
|
const own = byPerson.get(person.personId);
|
|
144
147
|
lookups.set(person.personId, {
|
|
145
148
|
platformSuppressed: (_platform_get = platform.get(person.personId)) != null ? _platform_get : null,
|
|
146
149
|
hostSuppressed: (_site_get = site.get(person.personId)) != null ? _site_get : null,
|
|
147
150
|
salesTopicState: (_sales_get = sales.get(person.personId)) != null ? _sales_get : null,
|
|
148
151
|
doNotContact: (_doNotContact_get = doNotContact.get(person.email)) != null ? _doNotContact_get : null,
|
|
152
|
+
doNotContactDomain: (_doNotContactDomain_get = doNotContactDomain.get(person.email)) != null ? _doNotContactDomain_get : null,
|
|
149
153
|
workspaceMembers: roster,
|
|
150
154
|
openEnrollments: (_ref = own == null ? void 0 : own.open) != null ? _ref : null,
|
|
151
155
|
hasInboundEmail: (_ref1 = own == null ? void 0 : own.inbound) != null ? _ref1 : null
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.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 * THE LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport { lookupOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n const host = firestore.collection('hosts').doc(hostId)\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedLookup(\n firestore,\n people,\n (key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n 'site suppression',\n ),\n keyedLookup<TopicSubscriptionState>(\n firestore,\n people,\n (key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","readOutreachGateLookups","input","orgId","hostId","host","doc","enrollments","activities","platform","site","sales","doNotContact","roster","perPerson","Promise","all","snapshot","exists","get","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","id","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.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 * THE LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n const host = firestore.collection('hosts').doc(hostId)\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, doNotContactDomain, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedLookup(\n firestore,\n people,\n (key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n 'site suppression',\n ),\n keyedLookup<TopicSubscriptionState>(\n firestore,\n people,\n (key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The domain half of the same list (AGL-3244), one `getAll` over the\n // distinct domains the people are at.\n lookupOutreachDoNotContactDomains(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n doNotContactDomain: doNotContactDomain.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","readOutreachGateLookups","input","orgId","hostId","host","doc","enrollments","activities","platform","site","sales","doNotContact","doNotContactDomain","roster","perPerson","Promise","all","snapshot","exists","get","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","id","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import type { OutreachComposeError } from '../engine/compose';
|
|
27
27
|
import type { OutreachComplianceIssue } from './compliance-settings';
|
|
28
|
-
import type { OutreachAttestationKind, OutreachComplianceSettingsDocument, OutreachEnrollment, OutreachSequence } from './outreach.types';
|
|
28
|
+
import type { OutreachAttestationKind, OutreachComplianceSettingsDocument, OutreachDoNotContactDomainEntry, OutreachEnrollment, OutreachSequence } from './outreach.types';
|
|
29
29
|
import type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft';
|
|
30
30
|
/** Every reason these routes refuse with, for a caller to branch on. */
|
|
31
|
-
export type OutreachRouteRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'method-not-allowed' | 'invalid-request' | 'invalid-settings' | 'invalid-sequence' | 'sequence-not-found' | 'sequence-archived' | 'sequence-not-active' | 'activation-refused' | 'delete-refused' | 'mailbox-unavailable' | 'enrollment-not-found' | 'transition-refused' | 'view-not-found' | 'view-unsupported' | 'contact-not-found';
|
|
31
|
+
export type OutreachRouteRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'method-not-allowed' | 'invalid-request' | 'invalid-domain' | 'invalid-settings' | 'invalid-sequence' | 'sequence-not-found' | 'sequence-archived' | 'sequence-not-active' | 'activation-refused' | 'delete-refused' | 'mailbox-unavailable' | 'enrollment-not-found' | 'transition-refused' | 'view-not-found' | 'view-unsupported' | 'contact-not-found';
|
|
32
32
|
/** A refusal, in the one shape every route answers with. */
|
|
33
33
|
export interface OutreachRouteRefusal {
|
|
34
34
|
/** A sentence written for the person reading it. */
|
|
@@ -216,6 +216,27 @@ export interface OutreachEnrollmentActionResponse {
|
|
|
216
216
|
/** Other open enrollments of the same address a do-not-contact stopped. */
|
|
217
217
|
stoppedOthers: number;
|
|
218
218
|
}
|
|
219
|
+
/** `GET outreach/do-not-contact/domains?orgId` — and the answer to a change. */
|
|
220
|
+
export interface OutreachDoNotContactDomainsResponse {
|
|
221
|
+
ok: true;
|
|
222
|
+
/** Every domain on the list, alphabetically. */
|
|
223
|
+
domains: OutreachDoNotContactDomainEntry[];
|
|
224
|
+
}
|
|
225
|
+
/** `POST outreach/do-not-contact/domains` */
|
|
226
|
+
export interface OutreachDoNotContactDomainRequest {
|
|
227
|
+
orgId: string;
|
|
228
|
+
action: 'add' | 'remove';
|
|
229
|
+
/** The domain, or an address at it. */
|
|
230
|
+
domain: string;
|
|
231
|
+
/** Why, for an add. */
|
|
232
|
+
detail?: string;
|
|
233
|
+
}
|
|
234
|
+
/** The answer to a change: the list as it stands, and whether anything changed. */
|
|
235
|
+
export interface OutreachDoNotContactDomainResponse extends OutreachDoNotContactDomainsResponse {
|
|
236
|
+
changed: boolean;
|
|
237
|
+
/** The domain as the list spells it. */
|
|
238
|
+
domain: string;
|
|
239
|
+
}
|
|
219
240
|
/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */
|
|
220
241
|
export interface OutreachPreviewRequest {
|
|
221
242
|
orgId: string;
|