@aglyn/plugins-outreach 1.0.0-beta.159 → 1.0.0-beta.161
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 +6 -0
- 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/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/register-routes.js +4 -0
- package/src/lib/routes/register-routes.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 +25 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +21 -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 +34 -7
- package/src/lib/runtime/sync-job.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
|
@@ -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"}
|
|
@@ -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;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/outreach-api.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 CONTRACT OF OUTREACH'S SETTINGS, SEQUENCE AND ENROLLMENT ROUTES\n * (AGL-2980): what the console sends, what the routes answer, and every\n * reason one refuses. Client-safe — types and the batch limit only.\n *\n * Every request names its organization (`orgId`, in the query of a `GET`\n * and the JSON body of a `POST`): the routes serve an organization-level\n * surface that names no site.\n */\n\nimport type { OutreachComposeError } from '../engine/compose'\nimport type { OutreachComplianceIssue } from './compliance-settings'\nimport type {\n OutreachAttestationKind,\n OutreachComplianceSettingsDocument,\n OutreachEnrollment,\n OutreachSequence,\n} from './outreach.types'\nimport type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft'\n\n/** Every reason these routes refuse with, for a caller to branch on. */\nexport type OutreachRouteRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'invalid-settings'\n | 'invalid-sequence'\n | 'sequence-not-found'\n | 'sequence-archived'\n | 'sequence-not-active'\n | 'activation-refused'\n | 'delete-refused'\n | 'mailbox-unavailable'\n | 'enrollment-not-found'\n | 'transition-refused'\n | 'view-not-found'\n | 'view-unsupported'\n | 'contact-not-found'\n\n/** A refusal, in the one shape every route answers with. */\nexport interface OutreachRouteRefusal {\n /** A sentence written for the person reading it. */\n error: string\n reason: OutreachRouteRefusalReason\n /** The field-level reasons behind an `invalid-*` or `activation-refused` refusal. */\n issues?: ReadonlyArray<OutreachComplianceIssue | OutreachSequenceIssue>\n}\n\n/*==========================================\n * COMPLIANCE SETTINGS\n *==========================================*/\n\n/** `GET outreach/settings?orgId` — and the answer to a save. */\nexport interface OutreachSettingsResponse {\n ok: true\n settings: OutreachComplianceSettingsDocument\n}\n\n/** `POST outreach/settings` */\nexport interface OutreachSettingsSaveRequest {\n orgId: string\n legalName: string\n brandName: string\n postalAddress: string\n allowedCountries: string[]\n}\n\n/** The answer to a save: the settings as stored, and whether anything changed. */\nexport interface OutreachSettingsSaveResponse extends OutreachSettingsResponse {\n changed: boolean\n}\n\n/*==========================================\n * SEQUENCES\n *==========================================*/\n\n/** `POST outreach/sequences/save` — a new sequence without `sequenceId`, an edit with one. */\nexport interface OutreachSequenceSaveRequest {\n orgId: string\n sequenceId?: string\n sequence: OutreachSequenceDraft\n}\n\nexport interface OutreachSequenceSaveResponse {\n ok: true\n sequence: OutreachSequence\n created: boolean\n /** What the save allowed but probably isn't what was meant. */\n warnings: OutreachSequenceIssue[]\n}\n\n/** What `outreach/sequences/status` does to a sequence. */\nexport type OutreachSequenceAction = 'activate' | 'pause' | 'archive'\n\n/** `POST outreach/sequences/status` */\nexport interface OutreachSequenceStatusRequest {\n orgId: string\n sequenceId: string\n action: OutreachSequenceAction\n}\n\nexport interface OutreachSequenceStatusResponse {\n ok: true\n sequence: OutreachSequence\n /** The open enrollments an archive stopped; `0` for anything else. */\n stoppedEnrollments: number\n}\n\n/** `POST outreach/sequences/delete` — a draft nobody was ever enrolled in. */\nexport interface OutreachSequenceDeleteRequest {\n orgId: string\n sequenceId: string\n}\n\n/*==========================================\n * ENROLLING\n *==========================================*/\n\n/** The most people one preview or one enroll reads. */\nexport const OUTREACH_ENROLL_BATCH_MAX = 50\n\n/**\n * Which CRM record a person to enroll is (AGL-3234): a contact by its\n * document id, or a lead the sequence's site holds by its person key.\n */\nexport type OutreachPersonRef = { kind: 'contact'; id: string } | { kind: 'lead'; id: string }\n\n/**\n * Where the people to enroll come from: a saved Contacts or Leads view,\n * contacts picked by search, or leads picked from the sequence's site.\n */\nexport type OutreachEnrollSource =\n | { kind: 'view'; viewId: string }\n | { kind: 'contacts'; contactIds: string[] }\n | { kind: 'leads'; leadIds: string[] }\n\n/** `POST outreach/enroll/preview` */\nexport interface OutreachEnrollPreviewRequest {\n orgId: string\n sequenceId: string\n source: OutreachEnrollSource\n}\n\n/**\n * Where one person stands:\n *\n * - `eligible` — enrolled on Confirm as they are;\n * - `needs_confirmation` — enrolled once the rep writes the personal line\n * and ticks the attestations `requires` names;\n * - `blocked` — refused whatever the rep confirms, for the `blocks` given.\n */\nexport type OutreachEnrollPreviewStatus = 'eligible' | 'needs_confirmation' | 'blocked'\n\n/** One reason a person can't be enrolled, in a sentence a rep can act on. */\nexport interface OutreachEnrollBlock {\n code: string\n reason: string\n}\n\nexport interface OutreachEnrollPreviewPerson {\n /** The key the dialog and the confirm name this person by: the record's own id. */\n personId: string\n /** The record the person is (AGL-3234). */\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 name: string\n /** The address the steps would go to, or `null` when the contact has none. */\n email: string | null\n /** No inbound capture behind them — the heavier rules apply. */\n cold: boolean\n /** ISO-3166-1 alpha-2, when anything names one. */\n country: string | null\n status: OutreachEnrollPreviewStatus\n /** Why a `blocked` person is refused; empty otherwise. */\n blocks: OutreachEnrollBlock[]\n /** What the rep must supply before a `needs_confirmation` person is enrolled. */\n requires: {\n personalLine: boolean\n attestations: OutreachAttestationKind[]\n }\n}\n\nexport interface OutreachEnrollPreviewResponse {\n ok: true\n people: OutreachEnrollPreviewPerson[]\n /** Everyone the source named, before the batch limit. */\n total: number\n /** True when the source named more people than one enrollment reads. */\n truncated: boolean\n}\n\n/** One person to enroll, with what the rep supplied for them: a contact by id, or a lead by key. */\nexport interface OutreachEnrollPersonRequest {\n contactId?: string\n leadId?: string\n personalLine?: string\n attestations?: OutreachAttestationKind[]\n}\n\n/** `POST outreach/enroll` — the gates are checked again, here, before anything is written. */\nexport interface OutreachEnrollRequest {\n orgId: string\n sequenceId: string\n people: OutreachEnrollPersonRequest[]\n}\n\ninterface OutreachEnrollOutcomePerson {\n personId: string\n target: 'contact' | 'lead'\n contactId: string\n leadId: string | null\n email: string | null\n}\n\nexport type OutreachEnrollOutcome =\n | (OutreachEnrollOutcomePerson & { outcome: 'enrolled'; enrollmentId: string })\n | (OutreachEnrollOutcomePerson & { outcome: 'blocked'; blocks: OutreachEnrollBlock[] })\n\nexport interface OutreachEnrollResponse {\n ok: true\n results: OutreachEnrollOutcome[]\n enrolled: number\n}\n\n/*==========================================\n * ENROLLMENTS\n *==========================================*/\n\n/** What a member may do to one enrollment. */\nexport type OutreachEnrollmentAction = 'pause' | 'resume' | 'stop' | 'do_not_contact'\n\n/** `POST outreach/enrollments/action` */\nexport interface OutreachEnrollmentActionRequest {\n orgId: string\n enrollmentId: string\n action: OutreachEnrollmentAction\n /** Why, for a stop or a do-not-contact. */\n detail?: string\n}\n\nexport interface OutreachEnrollmentActionResponse {\n ok: true\n /** False when the enrollment was already where the action would put it. */\n changed: boolean\n enrollment: OutreachEnrollment\n /** Other open enrollments of the same address a do-not-contact stopped. */\n stoppedOthers: number\n}\n\n/*==========================================\n * PREVIEW\n *==========================================*/\n\n/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */\nexport interface OutreachPreviewRequest {\n orgId: string\n sequenceId: string\n /** The contact to write it to; a sample person when absent. */\n contactId?: string\n /** Or a lead the sequence's site holds (AGL-3234), written to as the contact it would be. */\n leadId?: string\n personalLine?: string\n /** Which step; the first email when absent. */\n stepIndex?: number\n}\n\nexport interface OutreachPreviewResponse {\n ok: true\n stepIndex: number\n subject: string\n /** The plain text, footer included. */\n text: string\n unresolvedFields: string[]\n /** Why it could not be written, when it could not. */\n error: OutreachComposeError | null\n}\n"],"names":["OUTREACH_ENROLL_BATCH_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAgHD;;4CAE4C,GAE5C,qDAAqD,GACrD,OAAO,MAAMA,4BAA4B,GAAE"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/model/outreach-api.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 CONTRACT OF OUTREACH'S SETTINGS, SEQUENCE AND ENROLLMENT ROUTES\n * (AGL-2980): what the console sends, what the routes answer, and every\n * reason one refuses. Client-safe — types and the batch limit only.\n *\n * Every request names its organization (`orgId`, in the query of a `GET`\n * and the JSON body of a `POST`): the routes serve an organization-level\n * surface that names no site.\n */\n\nimport type { OutreachComposeError } from '../engine/compose'\nimport type { OutreachComplianceIssue } from './compliance-settings'\nimport type {\n OutreachAttestationKind,\n OutreachComplianceSettingsDocument,\n OutreachDoNotContactDomainEntry,\n OutreachEnrollment,\n OutreachSequence,\n} from './outreach.types'\nimport type { OutreachSequenceDraft, OutreachSequenceIssue } from './sequence-draft'\n\n/** Every reason these routes refuse with, for a caller to branch on. */\nexport type OutreachRouteRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'invalid-domain'\n | 'invalid-settings'\n | 'invalid-sequence'\n | 'sequence-not-found'\n | 'sequence-archived'\n | 'sequence-not-active'\n | 'activation-refused'\n | 'delete-refused'\n | 'mailbox-unavailable'\n | 'enrollment-not-found'\n | 'transition-refused'\n | 'view-not-found'\n | 'view-unsupported'\n | 'contact-not-found'\n\n/** A refusal, in the one shape every route answers with. */\nexport interface OutreachRouteRefusal {\n /** A sentence written for the person reading it. */\n error: string\n reason: OutreachRouteRefusalReason\n /** The field-level reasons behind an `invalid-*` or `activation-refused` refusal. */\n issues?: ReadonlyArray<OutreachComplianceIssue | OutreachSequenceIssue>\n}\n\n/*==========================================\n * COMPLIANCE SETTINGS\n *==========================================*/\n\n/** `GET outreach/settings?orgId` — and the answer to a save. */\nexport interface OutreachSettingsResponse {\n ok: true\n settings: OutreachComplianceSettingsDocument\n}\n\n/** `POST outreach/settings` */\nexport interface OutreachSettingsSaveRequest {\n orgId: string\n legalName: string\n brandName: string\n postalAddress: string\n allowedCountries: string[]\n}\n\n/** The answer to a save: the settings as stored, and whether anything changed. */\nexport interface OutreachSettingsSaveResponse extends OutreachSettingsResponse {\n changed: boolean\n}\n\n/*==========================================\n * SEQUENCES\n *==========================================*/\n\n/** `POST outreach/sequences/save` — a new sequence without `sequenceId`, an edit with one. */\nexport interface OutreachSequenceSaveRequest {\n orgId: string\n sequenceId?: string\n sequence: OutreachSequenceDraft\n}\n\nexport interface OutreachSequenceSaveResponse {\n ok: true\n sequence: OutreachSequence\n created: boolean\n /** What the save allowed but probably isn't what was meant. */\n warnings: OutreachSequenceIssue[]\n}\n\n/** What `outreach/sequences/status` does to a sequence. */\nexport type OutreachSequenceAction = 'activate' | 'pause' | 'archive'\n\n/** `POST outreach/sequences/status` */\nexport interface OutreachSequenceStatusRequest {\n orgId: string\n sequenceId: string\n action: OutreachSequenceAction\n}\n\nexport interface OutreachSequenceStatusResponse {\n ok: true\n sequence: OutreachSequence\n /** The open enrollments an archive stopped; `0` for anything else. */\n stoppedEnrollments: number\n}\n\n/** `POST outreach/sequences/delete` — a draft nobody was ever enrolled in. */\nexport interface OutreachSequenceDeleteRequest {\n orgId: string\n sequenceId: string\n}\n\n/*==========================================\n * ENROLLING\n *==========================================*/\n\n/** The most people one preview or one enroll reads. */\nexport const OUTREACH_ENROLL_BATCH_MAX = 50\n\n/**\n * Which CRM record a person to enroll is (AGL-3234): a contact by its\n * document id, or a lead the sequence's site holds by its person key.\n */\nexport type OutreachPersonRef = { kind: 'contact'; id: string } | { kind: 'lead'; id: string }\n\n/**\n * Where the people to enroll come from: a saved Contacts or Leads view,\n * contacts picked by search, or leads picked from the sequence's site.\n */\nexport type OutreachEnrollSource =\n | { kind: 'view'; viewId: string }\n | { kind: 'contacts'; contactIds: string[] }\n | { kind: 'leads'; leadIds: string[] }\n\n/** `POST outreach/enroll/preview` */\nexport interface OutreachEnrollPreviewRequest {\n orgId: string\n sequenceId: string\n source: OutreachEnrollSource\n}\n\n/**\n * Where one person stands:\n *\n * - `eligible` — enrolled on Confirm as they are;\n * - `needs_confirmation` — enrolled once the rep writes the personal line\n * and ticks the attestations `requires` names;\n * - `blocked` — refused whatever the rep confirms, for the `blocks` given.\n */\nexport type OutreachEnrollPreviewStatus = 'eligible' | 'needs_confirmation' | 'blocked'\n\n/** One reason a person can't be enrolled, in a sentence a rep can act on. */\nexport interface OutreachEnrollBlock {\n code: string\n reason: string\n}\n\nexport interface OutreachEnrollPreviewPerson {\n /** The key the dialog and the confirm name this person by: the record's own id. */\n personId: string\n /** The record the person is (AGL-3234). */\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 name: string\n /** The address the steps would go to, or `null` when the contact has none. */\n email: string | null\n /** No inbound capture behind them — the heavier rules apply. */\n cold: boolean\n /** ISO-3166-1 alpha-2, when anything names one. */\n country: string | null\n status: OutreachEnrollPreviewStatus\n /** Why a `blocked` person is refused; empty otherwise. */\n blocks: OutreachEnrollBlock[]\n /** What the rep must supply before a `needs_confirmation` person is enrolled. */\n requires: {\n personalLine: boolean\n attestations: OutreachAttestationKind[]\n }\n}\n\nexport interface OutreachEnrollPreviewResponse {\n ok: true\n people: OutreachEnrollPreviewPerson[]\n /** Everyone the source named, before the batch limit. */\n total: number\n /** True when the source named more people than one enrollment reads. */\n truncated: boolean\n}\n\n/** One person to enroll, with what the rep supplied for them: a contact by id, or a lead by key. */\nexport interface OutreachEnrollPersonRequest {\n contactId?: string\n leadId?: string\n personalLine?: string\n attestations?: OutreachAttestationKind[]\n}\n\n/** `POST outreach/enroll` — the gates are checked again, here, before anything is written. */\nexport interface OutreachEnrollRequest {\n orgId: string\n sequenceId: string\n people: OutreachEnrollPersonRequest[]\n}\n\ninterface OutreachEnrollOutcomePerson {\n personId: string\n target: 'contact' | 'lead'\n contactId: string\n leadId: string | null\n email: string | null\n}\n\nexport type OutreachEnrollOutcome =\n | (OutreachEnrollOutcomePerson & { outcome: 'enrolled'; enrollmentId: string })\n | (OutreachEnrollOutcomePerson & { outcome: 'blocked'; blocks: OutreachEnrollBlock[] })\n\nexport interface OutreachEnrollResponse {\n ok: true\n results: OutreachEnrollOutcome[]\n enrolled: number\n}\n\n/*==========================================\n * ENROLLMENTS\n *==========================================*/\n\n/** What a member may do to one enrollment. */\nexport type OutreachEnrollmentAction = 'pause' | 'resume' | 'stop' | 'do_not_contact'\n\n/** `POST outreach/enrollments/action` */\nexport interface OutreachEnrollmentActionRequest {\n orgId: string\n enrollmentId: string\n action: OutreachEnrollmentAction\n /** Why, for a stop or a do-not-contact. */\n detail?: string\n}\n\nexport interface OutreachEnrollmentActionResponse {\n ok: true\n /** False when the enrollment was already where the action would put it. */\n changed: boolean\n enrollment: OutreachEnrollment\n /** Other open enrollments of the same address a do-not-contact stopped. */\n stoppedOthers: number\n}\n\n/*==========================================\n * DO NOT CONTACT: DOMAINS (AGL-3244)\n *==========================================*/\n\n/** `GET outreach/do-not-contact/domains?orgId` — and the answer to a change. */\nexport interface OutreachDoNotContactDomainsResponse {\n ok: true\n /** Every domain on the list, alphabetically. */\n domains: OutreachDoNotContactDomainEntry[]\n}\n\n/** `POST outreach/do-not-contact/domains` */\nexport interface OutreachDoNotContactDomainRequest {\n orgId: string\n action: 'add' | 'remove'\n /** The domain, or an address at it. */\n domain: string\n /** Why, for an add. */\n detail?: string\n}\n\n/** The answer to a change: the list as it stands, and whether anything changed. */\nexport interface OutreachDoNotContactDomainResponse extends OutreachDoNotContactDomainsResponse {\n changed: boolean\n /** The domain as the list spells it. */\n domain: string\n}\n\n/*==========================================\n * PREVIEW\n *==========================================*/\n\n/** `POST outreach/preview` — one email of a saved sequence, as a person would get it. */\nexport interface OutreachPreviewRequest {\n orgId: string\n sequenceId: string\n /** The contact to write it to; a sample person when absent. */\n contactId?: string\n /** Or a lead the sequence's site holds (AGL-3234), written to as the contact it would be. */\n leadId?: string\n personalLine?: string\n /** Which step; the first email when absent. */\n stepIndex?: number\n}\n\nexport interface OutreachPreviewResponse {\n ok: true\n stepIndex: number\n subject: string\n /** The plain text, footer included. */\n text: string\n unresolvedFields: string[]\n /** Why it could not be written, when it could not. */\n error: OutreachComposeError | null\n}\n"],"names":["OUTREACH_ENROLL_BATCH_MAX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;CAQC,GAkHD;;4CAE4C,GAE5C,qDAAqD,GACrD,OAAO,MAAMA,4BAA4B,GAAE"}
|
|
@@ -64,6 +64,12 @@ export declare const OUTREACH_COLLECTIONS: {
|
|
|
64
64
|
* emails for this organization, keyed by `outreachDoNotContactKey`.
|
|
65
65
|
*/
|
|
66
66
|
readonly doNotContact: "outreachDoNotContact";
|
|
67
|
+
/**
|
|
68
|
+
* `orgs/{orgId}/outreachDoNotContactDomains/{domain}` — the domains
|
|
69
|
+
* Outreach never emails for this organization (AGL-3244), keyed by the
|
|
70
|
+
* domain itself: a domain names a company, not a person.
|
|
71
|
+
*/
|
|
72
|
+
readonly doNotContactDomains: "outreachDoNotContactDomains";
|
|
67
73
|
/**
|
|
68
74
|
* `outreachMailboxCredentials/{mailboxId}` — the provider grant behind a
|
|
69
75
|
* mailbox. TOP-LEVEL and closed to every client, staff included, so the
|
|
@@ -654,7 +660,7 @@ export interface OutreachComplianceSettingsDocument extends OutreachComplianceSe
|
|
|
654
660
|
updatedByUid: string | null;
|
|
655
661
|
}
|
|
656
662
|
/** Why an address is on the list. */
|
|
657
|
-
export declare const OUTREACH_DO_NOT_CONTACT_REASONS: readonly ["manual", "opt_out_reply", "unsubscribe", "hard_bounce"];
|
|
663
|
+
export declare const OUTREACH_DO_NOT_CONTACT_REASONS: readonly ["manual", "opt_out_reply", "unsubscribe", "hard_bounce", "gateway_block"];
|
|
658
664
|
export type OutreachDoNotContactReason = (typeof OUTREACH_DO_NOT_CONTACT_REASONS)[number];
|
|
659
665
|
/** What put an address on the list: a member, or the sending runtime. */
|
|
660
666
|
export declare const OUTREACH_DO_NOT_CONTACT_SOURCES: readonly ["member", "runtime"];
|
|
@@ -675,3 +681,29 @@ export interface OutreachDoNotContactEntry {
|
|
|
675
681
|
/** Plain-language detail: why the member added it, the bounce's diagnostic. */
|
|
676
682
|
detail: string | null;
|
|
677
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* One domain on the list (`orgs/{orgId}/outreachDoNotContactDomains/{domain}`)
|
|
686
|
+
* (AGL-3244): no address at it is emailed, whoever enrolls them.
|
|
687
|
+
*
|
|
688
|
+
* Unlike an address entry it CARRIES THE DOMAIN, in clear, because the
|
|
689
|
+
* Compliance page lists it and a member takes it off by name; a domain is a
|
|
690
|
+
* company's, not a person's, and a person erasure leaves it alone. A member
|
|
691
|
+
* adds one by hand; the sending runtime adds one when a hard bounce reads
|
|
692
|
+
* as the domain's mail gateway refusing the sender rather than one address
|
|
693
|
+
* being unknown.
|
|
694
|
+
*/
|
|
695
|
+
export interface OutreachDoNotContactDomainEntry {
|
|
696
|
+
/** The domain, lower-cased, which is also the document id. */
|
|
697
|
+
domain: string;
|
|
698
|
+
reason: OutreachDoNotContactReason;
|
|
699
|
+
source: OutreachDoNotContactSource;
|
|
700
|
+
/** The member who added it; `null` when the runtime did. */
|
|
701
|
+
addedByUid: string | null;
|
|
702
|
+
addedAtMs: number;
|
|
703
|
+
/** The enrollment whose bounce led here, when one did. */
|
|
704
|
+
enrollmentId: string | null;
|
|
705
|
+
/** That enrollment's sequence. */
|
|
706
|
+
sequenceId: string | null;
|
|
707
|
+
/** Plain-language detail: the member's note, or the bounce's diagnostic. */
|
|
708
|
+
detail: string | null;
|
|
709
|
+
}
|
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
* emails for this organization, keyed by `outreachDoNotContactKey`.
|
|
58
58
|
*/ doNotContact: 'outreachDoNotContact',
|
|
59
59
|
/**
|
|
60
|
+
* `orgs/{orgId}/outreachDoNotContactDomains/{domain}` — the domains
|
|
61
|
+
* Outreach never emails for this organization (AGL-3244), keyed by the
|
|
62
|
+
* domain itself: a domain names a company, not a person.
|
|
63
|
+
*/ doNotContactDomains: 'outreachDoNotContactDomains',
|
|
64
|
+
/**
|
|
60
65
|
* `outreachMailboxCredentials/{mailboxId}` — the provider grant behind a
|
|
61
66
|
* mailbox. TOP-LEVEL and closed to every client, staff included, so the
|
|
62
67
|
* token material never shares a readable path with the mailbox it serves.
|
|
@@ -249,7 +254,12 @@ export const OUTREACH_TASK_KIND_LABELS = {
|
|
|
249
254
|
/** A member put it there. */ 'manual',
|
|
250
255
|
/** A reply asked not to be emailed again. */ 'opt_out_reply',
|
|
251
256
|
/** The unsubscribe link or header was used. */ 'unsubscribe',
|
|
252
|
-
/** Mail to it bounced for good. */ 'hard_bounce'
|
|
257
|
+
/** Mail to it bounced for good. */ 'hard_bounce',
|
|
258
|
+
/**
|
|
259
|
+
* The recipient organization's mail gateway refused the sender outright
|
|
260
|
+
* (AGL-3244) — a Barracuda, Proofpoint or Mimecast policy block — which is
|
|
261
|
+
* a verdict on the whole domain, and is filed against it.
|
|
262
|
+
*/ 'gateway_block'
|
|
253
263
|
];
|
|
254
264
|
/** What put an address on the list: a member, or the sending runtime. */ export const OUTREACH_DO_NOT_CONTACT_SOURCES = [
|
|
255
265
|
'member',
|