@aglyn/plugins-outreach 1.0.0-beta.200 → 1.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-actions.d.ts +33 -0
  3. package/src/lib/components/enrollment-actions.js +174 -0
  4. package/src/lib/components/enrollment-actions.js.map +1 -0
  5. package/src/lib/components/enrollment-detail.d.ts +21 -0
  6. package/src/lib/components/enrollment-detail.js +870 -0
  7. package/src/lib/components/enrollment-detail.js.map +1 -0
  8. package/src/lib/components/enrollment-filter-params.d.ts +58 -0
  9. package/src/lib/components/enrollment-filter-params.js +98 -0
  10. package/src/lib/components/enrollment-filter-params.js.map +1 -0
  11. package/src/lib/components/enrollments-table.d.ts +21 -0
  12. package/src/lib/components/enrollments-table.js +249 -210
  13. package/src/lib/components/enrollments-table.js.map +1 -1
  14. package/src/lib/components/outreach-console-page.js +10 -0
  15. package/src/lib/components/outreach-console-page.js.map +1 -1
  16. package/src/lib/components/sequence-detail.d.ts +2 -0
  17. package/src/lib/components/sequence-detail.js +70 -8
  18. package/src/lib/components/sequence-detail.js.map +1 -1
  19. package/src/lib/components/sequence-report-card.d.ts +17 -0
  20. package/src/lib/components/sequence-report-card.js +63 -15
  21. package/src/lib/components/sequence-report-card.js.map +1 -1
  22. package/src/lib/components/sequences-section.d.ts +4 -1
  23. package/src/lib/components/sequences-section.js +12 -1
  24. package/src/lib/components/sequences-section.js.map +1 -1
  25. package/src/lib/components/use-outreach-api.d.ts +3 -1
  26. package/src/lib/components/use-outreach-api.js +6 -0
  27. package/src/lib/components/use-outreach-api.js.map +1 -1
  28. package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
  29. package/src/lib/components/use-outreach-enrollment.js +133 -0
  30. package/src/lib/components/use-outreach-enrollment.js.map +1 -0
  31. package/src/lib/constants/api-routes.d.ts +2 -0
  32. package/src/lib/constants/api-routes.js +1 -0
  33. package/src/lib/constants/api-routes.js.map +1 -1
  34. package/src/lib/engine/gates.d.ts +4 -3
  35. package/src/lib/engine/gates.js.map +1 -1
  36. package/src/lib/engine/gateway-block.d.ts +4 -8
  37. package/src/lib/engine/gateway-block.js +6 -26
  38. package/src/lib/engine/gateway-block.js.map +1 -1
  39. package/src/lib/engine/mail-gateway.d.ts +18 -73
  40. package/src/lib/engine/mail-gateway.js +32 -206
  41. package/src/lib/engine/mail-gateway.js.map +1 -1
  42. package/src/lib/model/enrollment-engagement.d.ts +26 -2
  43. package/src/lib/model/enrollment-engagement.js +40 -3
  44. package/src/lib/model/enrollment-engagement.js.map +1 -1
  45. package/src/lib/model/enrollment-history.d.ts +40 -0
  46. package/src/lib/model/enrollment-history.js +95 -0
  47. package/src/lib/model/enrollment-history.js.map +1 -0
  48. package/src/lib/model/enrollment-list-query.d.ts +6 -0
  49. package/src/lib/model/enrollment-list-query.js +35 -1
  50. package/src/lib/model/enrollment-list-query.js.map +1 -1
  51. package/src/lib/model/enrollment-timeline.d.ts +78 -0
  52. package/src/lib/model/enrollment-timeline.js +430 -0
  53. package/src/lib/model/enrollment-timeline.js.map +1 -0
  54. package/src/lib/model/outreach-api.d.ts +24 -0
  55. package/src/lib/model/outreach-api.js.map +1 -1
  56. package/src/lib/model/outreach.types.d.ts +78 -29
  57. package/src/lib/model/outreach.types.js +33 -8
  58. package/src/lib/model/outreach.types.js.map +1 -1
  59. package/src/lib/routes/enroll-routes.js +26 -3
  60. package/src/lib/routes/enroll-routes.js.map +1 -1
  61. package/src/lib/routes/enrollment-routes.d.ts +10 -0
  62. package/src/lib/routes/enrollment-routes.js +82 -1
  63. package/src/lib/routes/enrollment-routes.js.map +1 -1
  64. package/src/lib/routes/register-routes.js +14 -4
  65. package/src/lib/routes/register-routes.js.map +1 -1
  66. package/src/lib/routes/route-deps.d.ts +3 -1
  67. package/src/lib/routes/route-deps.js.map +1 -1
  68. package/src/lib/runtime/click-events.d.ts +10 -5
  69. package/src/lib/runtime/click-events.js +45 -6
  70. package/src/lib/runtime/click-events.js.map +1 -1
  71. package/src/lib/runtime/gateway-ledger.d.ts +6 -0
  72. package/src/lib/runtime/gateway-ledger.js +9 -3
  73. package/src/lib/runtime/gateway-ledger.js.map +1 -1
  74. package/src/lib/runtime/host-suspension.d.ts +30 -0
  75. package/src/lib/runtime/host-suspension.js +35 -0
  76. package/src/lib/runtime/host-suspension.js.map +1 -0
  77. package/src/lib/runtime/person-erasure.d.ts +4 -1
  78. package/src/lib/runtime/person-erasure.js +14 -3
  79. package/src/lib/runtime/person-erasure.js.map +1 -1
  80. package/src/lib/runtime/platform-runtime-deps.js +34 -3
  81. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  82. package/src/lib/runtime/runtime-deps.d.ts +27 -1
  83. package/src/lib/runtime/runtime-deps.js.map +1 -1
  84. package/src/lib/runtime/send-job.js +46 -6
  85. package/src/lib/runtime/send-job.js.map +1 -1
  86. package/src/lib/runtime/sync-job.js +4 -0
  87. package/src/lib/runtime/sync-job.js.map +1 -1
  88. package/src/lib/storage/domain-intel-store.d.ts +61 -43
  89. package/src/lib/storage/domain-intel-store.js +138 -226
  90. package/src/lib/storage/domain-intel-store.js.map +1 -1
@@ -0,0 +1,133 @@
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
+ */ 'use client';
17
+ import { useFirestore } from "@aglyn/tenant-feature-instance";
18
+ import { collection, doc, limit, onSnapshot, orderBy, query } from "firebase/firestore";
19
+ import { useEffect, useState } from "react";
20
+ import { readOutreachHistoryEntry } from "../model/enrollment-history.js";
21
+ import { OUTREACH_COLLECTIONS, OUTREACH_ENROLLMENT_HISTORY, OUTREACH_ENROLLMENT_HISTORY_MAX } from "../model/outreach.types.js";
22
+ import { readStoredOutreachEnrollment } from "../model/stored-records.js";
23
+ /**
24
+ * What ONE enrollment's detail view reads (AGL-3332), and nothing else
25
+ * reads: the enrollment itself, its history, and the mail gateway in front
26
+ * of the person's domain.
27
+ *
28
+ * The first two are listens, opened when the view mounts and closed when it
29
+ * leaves, so the enrollments table — which lists hundreds of people — never
30
+ * pays for any of it; the gateway is one server read per page.
31
+ */ const denied = (error)=>(error == null ? void 0 : error.code) === 'permission-denied';
32
+ function failed(data, error, what) {
33
+ if (denied(error)) return {
34
+ status: 'refused',
35
+ data
36
+ };
37
+ console.error(`[outreach] ${what} could not be read`, error);
38
+ return {
39
+ status: 'error',
40
+ data
41
+ };
42
+ }
43
+ /** One enrollment, live, or `null` once it is known not to exist. */ export function useOutreachEnrollment(orgId, enrollmentId) {
44
+ const firestore = useFirestore();
45
+ const [result, setResult] = useState({
46
+ status: 'loading',
47
+ data: null
48
+ });
49
+ useEffect(()=>{
50
+ setResult({
51
+ status: 'loading',
52
+ data: null
53
+ });
54
+ if (!orgId || !enrollmentId) return undefined;
55
+ return onSnapshot(doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments, enrollmentId), (snapshot)=>setResult({
56
+ status: 'ready',
57
+ data: readStoredOutreachEnrollment(snapshot.id, snapshot.exists() ? snapshot.data() : undefined)
58
+ }), (error)=>setResult(failed(null, error, 'the enrollment')));
59
+ }, [
60
+ firestore,
61
+ orgId,
62
+ enrollmentId
63
+ ]);
64
+ return result;
65
+ }
66
+ /**
67
+ * The enrollment's history rows, newest first — every row it can hold, which
68
+ * is bounded by `OUTREACH_ENROLLMENT_HISTORY_MAX` on the writing side.
69
+ */ export function useOutreachEnrollmentHistory(orgId, enrollmentId) {
70
+ const firestore = useFirestore();
71
+ const [result, setResult] = useState({
72
+ status: 'loading',
73
+ data: []
74
+ });
75
+ useEffect(()=>{
76
+ setResult({
77
+ status: 'loading',
78
+ data: []
79
+ });
80
+ if (!orgId || !enrollmentId) return undefined;
81
+ return onSnapshot(query(collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments, enrollmentId, OUTREACH_ENROLLMENT_HISTORY), orderBy('atMs', 'desc'), limit(OUTREACH_ENROLLMENT_HISTORY_MAX)), (snapshot)=>setResult({
82
+ status: 'ready',
83
+ data: snapshot.docs.map((entry)=>readOutreachHistoryEntry(entry.id, entry.data())).filter((entry)=>entry !== null)
84
+ }), (error)=>setResult(failed([], error, 'the enrollment’s history')));
85
+ }, [
86
+ firestore,
87
+ orgId,
88
+ enrollmentId
89
+ ]);
90
+ return result;
91
+ }
92
+ /**
93
+ * The mail gateway in front of the person's domain (AGL-3326, AGL-3328),
94
+ * and what it did with mail from the enrollment's sending domain — asked of
95
+ * the server once per page, because the domain's MX lives in the platform's
96
+ * cache, which no member reads.
97
+ */ export function useOutreachEnrollmentGateway(api, enrollmentId) {
98
+ const [result, setResult] = useState({
99
+ status: 'loading',
100
+ data: null
101
+ });
102
+ useEffect(()=>{
103
+ let live = true;
104
+ setResult({
105
+ status: 'loading',
106
+ data: null
107
+ });
108
+ if (!enrollmentId) return undefined;
109
+ api.readEnrollmentGateway(enrollmentId).then((answer)=>{
110
+ if (live) setResult({
111
+ status: 'ready',
112
+ data: answer
113
+ });
114
+ }, (error)=>{
115
+ if (!live) return;
116
+ const status = error == null ? void 0 : error.status;
117
+ if (status === 403) setResult({
118
+ status: 'refused',
119
+ data: null
120
+ });
121
+ else setResult(failed(null, error, 'the domain’s mail gateway'));
122
+ });
123
+ return ()=>{
124
+ live = false;
125
+ };
126
+ }, [
127
+ api,
128
+ enrollmentId
129
+ ]);
130
+ return result;
131
+ }
132
+
133
+ //# sourceMappingURL=use-outreach-enrollment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-enrollment.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'use client'\n\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { collection, doc, limit, onSnapshot, orderBy, query } from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\nimport { readOutreachHistoryEntry } from '../model/enrollment-history'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n type OutreachEnrollment,\n type OutreachEnrollmentHistoryEntry,\n} from '../model/outreach.types'\nimport { readStoredOutreachEnrollment } from '../model/stored-records'\nimport type { OutreachEnrollmentGatewayResponse } from '../model/outreach-api'\nimport type { OutreachApi } from './use-outreach-api'\nimport type { OutreachLoad } from './use-outreach-data'\n\n/**\n * What ONE enrollment's detail view reads (AGL-3332), and nothing else\n * reads: the enrollment itself, its history, and the mail gateway in front\n * of the person's domain.\n *\n * The first two are listens, opened when the view mounts and closed when it\n * leaves, so the enrollments table — which lists hundreds of people — never\n * pays for any of it; the gateway is one server read per page.\n */\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\nfunction failed<T>(data: T, error: unknown, what: string): OutreachLoad<T> {\n if (denied(error)) return { status: 'refused', data }\n console.error(`[outreach] ${what} could not be read`, error)\n return { status: 'error', data }\n}\n\n/** One enrollment, live, or `null` once it is known not to exist. */\nexport function useOutreachEnrollment(\n orgId: string | null,\n enrollmentId: string | null,\n): OutreachLoad<OutreachEnrollment | null> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachEnrollment | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !enrollmentId) return undefined\n return onSnapshot(\n doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments, enrollmentId),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: readStoredOutreachEnrollment(snapshot.id, snapshot.exists() ? snapshot.data() : undefined),\n }),\n (error) => setResult(failed(null, error, 'the enrollment')),\n )\n }, [firestore, orgId, enrollmentId])\n return result\n}\n\n/**\n * The enrollment's history rows, newest first — every row it can hold, which\n * is bounded by `OUTREACH_ENROLLMENT_HISTORY_MAX` on the writing side.\n */\nexport function useOutreachEnrollmentHistory(\n orgId: string | null,\n enrollmentId: string | null,\n): OutreachLoad<OutreachEnrollmentHistoryEntry[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachEnrollmentHistoryEntry[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId || !enrollmentId) return undefined\n return onSnapshot(\n query(\n collection(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.enrollments,\n enrollmentId,\n OUTREACH_ENROLLMENT_HISTORY,\n ),\n orderBy('atMs', 'desc'),\n limit(OUTREACH_ENROLLMENT_HISTORY_MAX),\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.docs\n .map((entry) => readOutreachHistoryEntry(entry.id, entry.data()))\n .filter((entry): entry is OutreachEnrollmentHistoryEntry => entry !== null),\n }),\n (error) => setResult(failed([], error, 'the enrollment’s history')),\n )\n }, [firestore, orgId, enrollmentId])\n return result\n}\n\n/**\n * The mail gateway in front of the person's domain (AGL-3326, AGL-3328),\n * and what it did with mail from the enrollment's sending domain — asked of\n * the server once per page, because the domain's MX lives in the platform's\n * cache, which no member reads.\n */\nexport function useOutreachEnrollmentGateway(\n api: Pick<OutreachApi, 'readEnrollmentGateway'>,\n enrollmentId: string | null,\n): OutreachLoad<OutreachEnrollmentGatewayResponse | null> {\n const [result, setResult] = useState<OutreachLoad<OutreachEnrollmentGatewayResponse | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n let live = true\n setResult({ status: 'loading', data: null })\n if (!enrollmentId) return undefined\n api.readEnrollmentGateway(enrollmentId).then(\n (answer) => {\n if (live) setResult({ status: 'ready', data: answer })\n },\n (error: unknown) => {\n if (!live) return\n const status = (error as { status?: unknown } | null)?.status\n if (status === 403) setResult({ status: 'refused', data: null })\n else setResult(failed(null, error, 'the domain’s mail gateway'))\n },\n )\n return () => {\n live = false\n }\n }, [api, enrollmentId])\n return result\n}\n"],"names":["useFirestore","collection","doc","limit","onSnapshot","orderBy","query","useEffect","useState","readOutreachHistoryEntry","OUTREACH_COLLECTIONS","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","readStoredOutreachEnrollment","denied","error","code","failed","data","what","status","console","useOutreachEnrollment","orgId","enrollmentId","firestore","result","setResult","undefined","enrollments","snapshot","id","exists","useOutreachEnrollmentHistory","docs","map","entry","filter","useOutreachEnrollmentGateway","api","live","readEnrollmentGateway","then","answer"],"mappings":"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"}
@@ -61,6 +61,8 @@ export declare const OUTREACH_API_ROUTES: {
61
61
  readonly enroll: "outreach/enroll";
62
62
  /** `POST` — pause, resume, stop, or mark do-not-contact. */
63
63
  readonly enrollmentsAction: "outreach/enrollments/action";
64
+ /** `POST` — the mail gateway in front of one enrolled person's domain (AGL-3332). */
65
+ readonly enrollmentsGateway: "outreach/enrollments/gateway";
64
66
  /**
65
67
  * `GET ?orgId` — the domains on the organization's do-not-contact list
66
68
  * (AGL-3244); `POST` — add one, or take one off.
@@ -47,6 +47,7 @@
47
47
  /** `POST` — who a saved view or a search would enroll, and where each stands. */ enrollPreview: 'outreach/enroll/preview',
48
48
  /** `POST` — enroll them, every gate checked again. */ enroll: 'outreach/enroll',
49
49
  /** `POST` — pause, resume, stop, or mark do-not-contact. */ enrollmentsAction: 'outreach/enrollments/action',
50
+ /** `POST` — the mail gateway in front of one enrolled person's domain (AGL-3332). */ enrollmentsGateway: 'outreach/enrollments/gateway',
50
51
  /**
51
52
  * `GET ?orgId` — the domains on the organization's do-not-contact list
52
53
  * (AGL-3244); `POST` — add one, or take one off.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/api-routes.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 * Outreach's console API routes, as the dispatcher keys them (AGL-2974).\n *\n * One table both halves import, because the server registers the path and the\n * client fetches it, and a route spelled twice is a route that 404s the day\n * one spelling changes. Every path sits under the `outreach` prefix that\n * `plugins.config.json` gives this plugin, which is what lets the console's\n * `/api/[...pluginApi]` dispatcher gate a request on the plugin being enabled\n * and released before the handler runs. Client-safe: constants only.\n */\nexport const OUTREACH_API_ROUTES = {\n /** `GET` — proves the server bundle loaded and registered; see `server.ts`. */\n ping: 'outreach/ping',\n // Mailboxes (AGL-2978). Every one names its org (`orgId`, query or body)\n // so the dispatcher's release gate asks about that organization.\n /** `GET ?orgId` — whether this deployment can connect a Google mailbox. */\n mailboxesAvailability: 'outreach/mailboxes/availability',\n /** `POST` — Google's consent address for a new connect. */\n mailboxesConnect: 'outreach/mailboxes/connect',\n /**\n * `GET` — Google's redirect back. It carries no bearer token: the signed\n * state names the org and the member, and the route hands the code to the\n * Mailboxes page in a fragment rather than acting on it.\n */\n mailboxesOAuthCallback: 'outreach/mailboxes/oauth/callback',\n /** `POST` — finishes a connect with the member's own session. */\n mailboxesConnectComplete: 'outreach/mailboxes/connect/complete',\n /** `POST` — send-as, display name, daily cap, sending window, timezone. */\n mailboxesSettings: 'outreach/mailboxes/settings',\n /** `POST` — pause or resume. */\n mailboxesStatus: 'outreach/mailboxes/status',\n /** `POST` — a plain-text test to the account's own address. */\n mailboxesTest: 'outreach/mailboxes/test',\n /** `POST` — revoke the grant at Google and delete the mailbox. */\n mailboxesDisconnect: 'outreach/mailboxes/disconnect',\n // Settings, sequences and enrollments (AGL-2980). Every one names its org\n // (`orgId`, query or body); the contract is `model/outreach-api.ts`.\n /** `GET ?orgId` — the compliance settings; `POST` — save them. */\n settings: 'outreach/settings',\n /** `POST` — create a sequence, or save an edit to one. */\n sequencesSave: 'outreach/sequences/save',\n /** `POST` — activate, pause or archive a sequence. */\n sequencesStatus: 'outreach/sequences/status',\n /** `POST` — delete a draft nobody was enrolled in. */\n sequencesDelete: 'outreach/sequences/delete',\n /** `POST` — who a saved view or a search would enroll, and where each stands. */\n enrollPreview: 'outreach/enroll/preview',\n /** `POST` — enroll them, every gate checked again. */\n enroll: 'outreach/enroll',\n /** `POST` — pause, resume, stop, or mark do-not-contact. */\n enrollmentsAction: 'outreach/enrollments/action',\n /**\n * `GET ?orgId` — the domains on the organization's do-not-contact list\n * (AGL-3244); `POST` — add one, or take one off.\n */\n doNotContactDomains: 'outreach/do-not-contact/domains',\n /**\n * `GET ?orgId` — each connected mailbox domain's click-tracking host\n * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove\n * it. Setting up and removing are an owner's or admin's.\n */\n linkDomains: 'outreach/link-domains',\n /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\n /**\n * `POST` — one step of a saved sequence, rendered as the preview renders\n * it and sent through the sequence's mailbox to the member (AGL-3325):\n * `[Test]` in the subject, its links minted as test links, and counted\n * on nothing but the mailbox's test tally.\n */\n stepTest: 'outreach/steps/test',\n /**\n * `POST` — one person's own copies of a sequence's email steps, drafted by\n * the workspace's AI (AGL-3324): for a person about to be enrolled, or for\n * the next step of an enrollment. Nothing is stored.\n */\n curateDraft: 'outreach/curate/draft',\n /** `POST` — stores a member's confirmed copy of one step on an enrollment, or clears it. */\n curateSave: 'outreach/curate/save',\n // The one route a RECIPIENT reaches (AGL-2981): no session, a signed\n // token instead, and registered as a recipient link so it answers whether\n // or not Outreach is released for the organization now.\n /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */\n unsubscribe: 'outreach/unsubscribe',\n /**\n * `GET` — a link in a tracked sequence email (AGL-3239). `?t=<signed\n * token>`; answers a redirect to the destination the token names, and\n * records the click afterwards. A recipient link like the unsubscribe\n * one: no session, and it answers whether or not Outreach is released to\n * the organization now, because a link in an email that already left must\n * not stop working when a rollout is paused.\n */\n click: 'outreach/click',\n /**\n * `GET` — the short tracking link (AGL-3297): `outreach/l/<id>`, where the\n * id names a stored `outreachLinks` document holding the destination. It\n * replaced the signed `click` link for every send since; `click` keeps\n * answering the links already in inboxes. A recipient link, for `click`'s\n * reasons.\n */\n shortLink: 'outreach/l/:linkId',\n} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","doNotContactDomains","linkDomains","preview","stepTest","curateDraft","curateSave","unsubscribe","click","shortLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GACD,OAAO,MAAMA,sBAAsB;IACjC,6EAA6E,GAC7EC,MAAM;IACN,yEAAyE;IACzE,iEAAiE;IACjE,yEAAyE,GACzEC,uBAAuB;IACvB,yDAAyD,GACzDC,kBAAkB;IAClB;;;;GAIC,GACDC,wBAAwB;IACxB,+DAA+D,GAC/DC,0BAA0B;IAC1B,yEAAyE,GACzEC,mBAAmB;IACnB,8BAA8B,GAC9BC,iBAAiB;IACjB,6DAA6D,GAC7DC,eAAe;IACf,gEAAgE,GAChEC,qBAAqB;IACrB,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE,GAChEC,UAAU;IACV,wDAAwD,GACxDC,eAAe;IACf,oDAAoD,GACpDC,iBAAiB;IACjB,oDAAoD,GACpDC,iBAAiB;IACjB,+EAA+E,GAC/EC,eAAe;IACf,oDAAoD,GACpDC,QAAQ;IACR,0DAA0D,GAC1DC,mBAAmB;IACnB;;;GAGC,GACDC,qBAAqB;IACrB;;;;GAIC,GACDC,aAAa;IACb,8EAA8E,GAC9EC,SAAS;IACT;;;;;GAKC,GACDC,UAAU;IACV;;;;GAIC,GACDC,aAAa;IACb,0FAA0F,GAC1FC,YAAY;IACZ,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;IACP;;;;;;GAMC,GACDC,WAAW;AACb,EAAU"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/api-routes.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 * Outreach's console API routes, as the dispatcher keys them (AGL-2974).\n *\n * One table both halves import, because the server registers the path and the\n * client fetches it, and a route spelled twice is a route that 404s the day\n * one spelling changes. Every path sits under the `outreach` prefix that\n * `plugins.config.json` gives this plugin, which is what lets the console's\n * `/api/[...pluginApi]` dispatcher gate a request on the plugin being enabled\n * and released before the handler runs. Client-safe: constants only.\n */\nexport const OUTREACH_API_ROUTES = {\n /** `GET` — proves the server bundle loaded and registered; see `server.ts`. */\n ping: 'outreach/ping',\n // Mailboxes (AGL-2978). Every one names its org (`orgId`, query or body)\n // so the dispatcher's release gate asks about that organization.\n /** `GET ?orgId` — whether this deployment can connect a Google mailbox. */\n mailboxesAvailability: 'outreach/mailboxes/availability',\n /** `POST` — Google's consent address for a new connect. */\n mailboxesConnect: 'outreach/mailboxes/connect',\n /**\n * `GET` — Google's redirect back. It carries no bearer token: the signed\n * state names the org and the member, and the route hands the code to the\n * Mailboxes page in a fragment rather than acting on it.\n */\n mailboxesOAuthCallback: 'outreach/mailboxes/oauth/callback',\n /** `POST` — finishes a connect with the member's own session. */\n mailboxesConnectComplete: 'outreach/mailboxes/connect/complete',\n /** `POST` — send-as, display name, daily cap, sending window, timezone. */\n mailboxesSettings: 'outreach/mailboxes/settings',\n /** `POST` — pause or resume. */\n mailboxesStatus: 'outreach/mailboxes/status',\n /** `POST` — a plain-text test to the account's own address. */\n mailboxesTest: 'outreach/mailboxes/test',\n /** `POST` — revoke the grant at Google and delete the mailbox. */\n mailboxesDisconnect: 'outreach/mailboxes/disconnect',\n // Settings, sequences and enrollments (AGL-2980). Every one names its org\n // (`orgId`, query or body); the contract is `model/outreach-api.ts`.\n /** `GET ?orgId` — the compliance settings; `POST` — save them. */\n settings: 'outreach/settings',\n /** `POST` — create a sequence, or save an edit to one. */\n sequencesSave: 'outreach/sequences/save',\n /** `POST` — activate, pause or archive a sequence. */\n sequencesStatus: 'outreach/sequences/status',\n /** `POST` — delete a draft nobody was enrolled in. */\n sequencesDelete: 'outreach/sequences/delete',\n /** `POST` — who a saved view or a search would enroll, and where each stands. */\n enrollPreview: 'outreach/enroll/preview',\n /** `POST` — enroll them, every gate checked again. */\n enroll: 'outreach/enroll',\n /** `POST` — pause, resume, stop, or mark do-not-contact. */\n enrollmentsAction: 'outreach/enrollments/action',\n /** `POST` — the mail gateway in front of one enrolled person's domain (AGL-3332). */\n enrollmentsGateway: 'outreach/enrollments/gateway',\n /**\n * `GET ?orgId` — the domains on the organization's do-not-contact list\n * (AGL-3244); `POST` — add one, or take one off.\n */\n doNotContactDomains: 'outreach/do-not-contact/domains',\n /**\n * `GET ?orgId` — each connected mailbox domain's click-tracking host\n * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove\n * it. Setting up and removing are an owner's or admin's.\n */\n linkDomains: 'outreach/link-domains',\n /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\n /**\n * `POST` — one step of a saved sequence, rendered as the preview renders\n * it and sent through the sequence's mailbox to the member (AGL-3325):\n * `[Test]` in the subject, its links minted as test links, and counted\n * on nothing but the mailbox's test tally.\n */\n stepTest: 'outreach/steps/test',\n /**\n * `POST` — one person's own copies of a sequence's email steps, drafted by\n * the workspace's AI (AGL-3324): for a person about to be enrolled, or for\n * the next step of an enrollment. Nothing is stored.\n */\n curateDraft: 'outreach/curate/draft',\n /** `POST` — stores a member's confirmed copy of one step on an enrollment, or clears it. */\n curateSave: 'outreach/curate/save',\n // The one route a RECIPIENT reaches (AGL-2981): no session, a signed\n // token instead, and registered as a recipient link so it answers whether\n // or not Outreach is released for the organization now.\n /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */\n unsubscribe: 'outreach/unsubscribe',\n /**\n * `GET` — a link in a tracked sequence email (AGL-3239). `?t=<signed\n * token>`; answers a redirect to the destination the token names, and\n * records the click afterwards. A recipient link like the unsubscribe\n * one: no session, and it answers whether or not Outreach is released to\n * the organization now, because a link in an email that already left must\n * not stop working when a rollout is paused.\n */\n click: 'outreach/click',\n /**\n * `GET` — the short tracking link (AGL-3297): `outreach/l/<id>`, where the\n * id names a stored `outreachLinks` document holding the destination. It\n * replaced the signed `click` link for every send since; `click` keeps\n * answering the links already in inboxes. A recipient link, for `click`'s\n * reasons.\n */\n shortLink: 'outreach/l/:linkId',\n} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","enrollmentsGateway","doNotContactDomains","linkDomains","preview","stepTest","curateDraft","curateSave","unsubscribe","click","shortLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GACD,OAAO,MAAMA,sBAAsB;IACjC,6EAA6E,GAC7EC,MAAM;IACN,yEAAyE;IACzE,iEAAiE;IACjE,yEAAyE,GACzEC,uBAAuB;IACvB,yDAAyD,GACzDC,kBAAkB;IAClB;;;;GAIC,GACDC,wBAAwB;IACxB,+DAA+D,GAC/DC,0BAA0B;IAC1B,yEAAyE,GACzEC,mBAAmB;IACnB,8BAA8B,GAC9BC,iBAAiB;IACjB,6DAA6D,GAC7DC,eAAe;IACf,gEAAgE,GAChEC,qBAAqB;IACrB,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE,GAChEC,UAAU;IACV,wDAAwD,GACxDC,eAAe;IACf,oDAAoD,GACpDC,iBAAiB;IACjB,oDAAoD,GACpDC,iBAAiB;IACjB,+EAA+E,GAC/EC,eAAe;IACf,oDAAoD,GACpDC,QAAQ;IACR,0DAA0D,GAC1DC,mBAAmB;IACnB,mFAAmF,GACnFC,oBAAoB;IACpB;;;GAGC,GACDC,qBAAqB;IACrB;;;;GAIC,GACDC,aAAa;IACb,8EAA8E,GAC9EC,SAAS;IACT;;;;;GAKC,GACDC,UAAU;IACV;;;;GAIC,GACDC,aAAa;IACb,0FAA0F,GAC1FC,YAAY;IACZ,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;IACP;;;;;;GAMC,GACDC,WAAW;AACb,EAAU"}
@@ -80,9 +80,10 @@ export interface OutreachGateLookups {
80
80
  hasInboundEmail: boolean | null;
81
81
  /**
82
82
  * The mail gateway in front of the address's domain (AGL-3326), as its MX
83
- * names it — `outreachDomainIntel/{domain}` — and what that gateway did
84
- * with this organization's mail lately, from `outreachGatewayStats`.
85
- * `none` blocks; a gateway that holds is answered in `hold`.
83
+ * names it — the platform's `mailDomains/{domain}` cache — and what that
84
+ * gateway did lately with mail from the mailbox's sending domain, from
85
+ * the organization's `mailGatewayLedger` (AGL-3328). `none` blocks; a
86
+ * gateway that holds is answered in `hold`.
86
87
  */
87
88
  gateway: OutreachGatewayStanding | null;
88
89
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.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 MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *\n * ## A block and a hold\n *\n * Every gate above REFUSES: the person is not enrolled, or the enrollment\n * is stopped. The mail gateway (AGL-3326) adds one verdict of a different\n * weight. A domain with no MX cannot receive mail and blocks like the rest;\n * a gateway that refused this organization's sender twice in the last\n * thirty days without delivering once HOLDS the send — the enrollment is\n * allowed, and pauses at the send with `gateway_blocked_here` until a\n * member resumes it — because the founder decides, and the decision is\n * shown to them in Check people before they tick the person.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { emailStateRefusal, readEmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachGatewayStanding,\n outreachGatewayHoldReason,\n outreachGatewayHolds,\n} from './mail-gateway'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`, folded across the site's consent\n * group. Only `opted-out` refuses: a pending confirmation — the site's own,\n * or a sibling's where the org said its group waits — is about a\n * subscription, and a sales email does not rest on one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The address's DOMAIN is on that list (AGL-3244): a member put it there,\n * or the domain's mail gateway blocked the sender outright.\n */\n doNotContactDomain: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n /**\n * The mail gateway in front of the address's domain (AGL-3326), as its MX\n * names it — `outreachDomainIntel/{domain}` — and what that gateway did\n * with this organization's mail lately, from `outreachGatewayStats`.\n * `none` blocks; a gateway that holds is answered in `hold`.\n */\n gateway: OutreachGatewayStanding | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n /**\n * A member already released the gateway hold on this enrollment\n * (AGL-3326) — resumed it, or ticked the person past the red chip — so\n * the gateway's history does not hold it again.\n */\n gatewayHoldReleased?: boolean\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'email_state'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\n | 'no_mx'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\n/** The one verdict that holds a send rather than refusing it — see the module note. */\nexport interface OutreachGateHold {\n code: 'gateway_blocked_here'\n gateway: OutreachGatewayStanding['gateway']\n /** A sentence the enrollment row shows. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /**\n * The gateway hold (AGL-3326), when the address's gateway refused this\n * organization twice lately and nobody released it; `null` otherwise. An\n * allowed result can carry one: the send waits for a member.\n */\n hold: OutreachGateHold | null\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n hold: null,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to — the record's own verdict first\n // (AGL-3245), in the words the record page shows: what the lists below\n // hold, said by the reason it is held.\n const recordRefusal = emailStateRefusal(readEmailState(contact))\n if (recordRefusal) block('email_state', recordRefusal)\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n if (lookups?.doNotContactDomain === true) {\n block(\n 'do_not_contact_domain',\n `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`,\n )\n } else if (lookups?.doNotContactDomain !== false) {\n block(\n 'do_not_contact_domain',\n `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 4b. The mail gateway in front of the domain (AGL-3326): no MX means no\n // mail; a gateway that refused us twice lately holds, below.\n const gateway = lookups?.gateway ?? null\n if (!gateway) {\n block('no_mx', `We couldn't look up the mail server for ${domain}. ${COULD_NOT_CHECK}`)\n } else if (gateway.gateway === 'none') {\n block('no_mx', `${domain} has no MX record, so ${email} cannot receive mail.`)\n }\n const hold: OutreachGateHold | null =\n gateway && input.gatewayHoldReleased !== true && outreachGatewayHolds(gateway)\n ? {\n code: 'gateway_blocked_here',\n gateway: gateway.gateway,\n reason: outreachGatewayHoldReason(gateway.gateway, gateway.blocked30),\n }\n : null\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n hold,\n missingAttestations,\n }\n}\n\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","emailStateRefusal","readEmailState","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachGatewayHoldReason","outreachGatewayHolds","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","hold","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","recordRefusal","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","gateway","gatewayHoldReleased","blocked30","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.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 MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *\n * ## A block and a hold\n *\n * Every gate above REFUSES: the person is not enrolled, or the enrollment\n * is stopped. The mail gateway (AGL-3326) adds one verdict of a different\n * weight. A domain with no MX cannot receive mail and blocks like the rest;\n * a gateway that refused this organization's sender twice in the last\n * thirty days without delivering once HOLDS the send — the enrollment is\n * allowed, and pauses at the send with `gateway_blocked_here` until a\n * member resumes it — because the founder decides, and the decision is\n * shown to them in Check people before they tick the person.\n *==========================================*/\n\nimport {\n type ContactSource,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { emailStateRefusal, readEmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport type { TopicSubscriptionState } from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n findMemberByEmailAddress,\n type MemberAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_PERSONAL_LINE_MAX,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollmentStatus,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport {\n type OutreachGatewayStanding,\n outreachGatewayHoldReason,\n outreachGatewayHolds,\n} from './mail-gateway'\nimport {\n type OutreachRecipientCountry,\n outreachCountryName,\n resolveOutreachRecipientCountry,\n} from './recipient-country'\nimport { readOutreachSequenceSettings } from './sequence-validation'\n\n/**\n * The capture doors that mean a person came to the business: a form, a\n * sign-up to the site or its newsletter, an order, a booking. `api`,\n * `import` and `manual` are how a business adds people it found, which is\n * exactly what cold means.\n */\nexport const OUTREACH_INBOUND_CONTACT_SOURCES: readonly ContactSource[] = [\n 'form',\n 'member',\n 'newsletter',\n 'order',\n 'booking',\n]\n\n/** The enrollment statuses that hold a person: one sequence at a time. */\nexport const OUTREACH_OPEN_ENROLLMENT_STATUSES: readonly OutreachEnrollmentStatus[] = [\n 'active',\n 'paused',\n]\n\n/**\n * What the runtime looked up about the address before asking. Every field is\n * `null` when its read failed, and a `null` refuses.\n */\nexport interface OutreachGateLookups {\n /**\n * `emailSuppressions/{key}` holds a record in force — a hard bounce or a\n * spam complaint anywhere on the platform (`isEmailSuppressed`).\n */\n platformSuppressed: boolean | null\n /** `hosts/{hostId}/suppressions/{key}` exists for the sequence's site. */\n hostSuppressed: boolean | null\n /**\n * The address's standing on the site's `sales` topic —\n * `readTopicSubscriptionState` of the `sales` entry in\n * `hosts/{hostId}/topicOptOuts/{key}`, folded across the site's consent\n * group. Only `opted-out` refuses: a pending confirmation — the site's own,\n * or a sibling's where the org said its group waits — is about a\n * subscription, and a sales email does not rest on one.\n */\n salesTopicState: TopicSubscriptionState | null\n /** The address is on the organization's Outreach do-not-contact list. */\n doNotContact: boolean | null\n /**\n * The address's DOMAIN is on that list (AGL-3244): a member put it there,\n * or the domain's mail gateway blocked the sender outright.\n */\n doNotContactDomain: boolean | null\n /**\n * The organization's roster: each member's sign-in address and confirmed\n * aliases, as `crmInboundRoster` builds it.\n */\n workspaceMembers: readonly MemberAddresses[] | null\n /**\n * The person's enrollments that are `active` or `paused`, found by contact\n * id or by address. Other statuses are ignored, so passing every\n * enrollment is safe too.\n */\n openEnrollments: ReadonlyArray<{\n id: string\n sequenceId: string\n status: OutreachEnrollmentStatus\n }> | null\n /**\n * An email the person wrote (`direction: 'inbound'`) is filed on the\n * contact. `null` reads as `false`, which only makes the contact cold and\n * the rules stricter.\n */\n hasInboundEmail: boolean | null\n /**\n * The mail gateway in front of the address's domain (AGL-3326), as its MX\n * names it — the platform's `mailDomains/{domain}` cache — and what that\n * gateway did lately with mail from the mailbox's sending domain, from\n * the organization's `mailGatewayLedger` (AGL-3328). `none` blocks; a\n * gateway that holds is answered in `hold`.\n */\n gateway: OutreachGatewayStanding | null\n}\n\nexport interface OutreachGateInput {\n /** The address the steps go to. */\n email: string\n /** The contact document as stored (`orgs/{orgId}/contacts/{id}`), or `null` when it is gone. */\n contact: Record<string, unknown> | null | undefined\n /** The consent group of the sequence's site: whose facet holds stage, sources and address. */\n contactGroupId: string\n /** The company document the contact is filed under, when there is one. */\n company?: Record<string, unknown> | null\n /** The sequence's settings as stored; a missing field reads as its default. */\n settings: Partial<OutreachSequenceSettings> | null | undefined\n /** The rep's signal sentence. */\n personalLine: string | null | undefined\n attestations: OutreachAttestations | null | undefined\n /** The enrollment being re-checked before a send, so it does not count as another. */\n enrollmentId?: string | null\n /**\n * A member already released the gateway hold on this enrollment\n * (AGL-3326) — resumed it, or ticked the person past the red chip — so\n * the gateway's history does not hold it again.\n */\n gatewayHoldReleased?: boolean\n lookups: OutreachGateLookups\n}\n\nexport type OutreachGateCode =\n | 'contact_missing'\n | 'invalid_email'\n | 'email_not_on_contact'\n | 'free_mail'\n | 'country_not_allowed'\n | 'country_unknown'\n | 'platform_suppressed'\n | 'host_suppressed'\n | 'email_state'\n | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\n | 'no_mx'\n | 'workspace_member'\n | 'customer'\n | 'already_enrolled'\n | 'attestations_missing'\n | 'personal_line_missing'\n | 'personal_line_too_long'\n\nexport interface OutreachGateBlock {\n code: OutreachGateCode\n /** A sentence a rep can act on. */\n reason: string\n}\n\n/** The one verdict that holds a send rather than refusing it — see the module note. */\nexport interface OutreachGateHold {\n code: 'gateway_blocked_here'\n gateway: OutreachGatewayStanding['gateway']\n /** A sentence the enrollment row shows. */\n reason: string\n}\n\nexport interface OutreachGateResult {\n allowed: boolean\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n cold: boolean\n country: OutreachRecipientCountry\n /** Every gate that refuses, in gate order. */\n blocks: OutreachGateBlock[]\n /**\n * The gateway hold (AGL-3326), when the address's gateway refused this\n * organization twice lately and nobody released it; `null` otherwise. An\n * allowed result can carry one: the send waits for a member.\n */\n hold: OutreachGateHold | null\n /** The attestations a cold contact still needs, in the order they are asked. */\n missingAttestations: OutreachAttestationKind[]\n}\n\n/** Local parts that answer no person: nobody reads a reply sent there. */\nconst MACHINE_LOCAL_PART = /^(no-?reply|do-?not-?reply|mailer-daemon|postmaster|bounces?)(\\+.*)?$/\n\n/**\n * Whether a contact came to the sending site's business through no inbound\n * door — see the module note.\n */\nexport function isColdOutreachContact(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n hasInboundEmail: boolean,\n): boolean {\n if (hasInboundEmail) return false\n const { sources } = readContactFacet(contact, contactGroupId)\n return !OUTREACH_INBOUND_CONTACT_SOURCES.some((source) => sources?.[source] === true)\n}\n\n/**\n * Whether the sending site's business counts the contact as a customer: a\n * `customer` stage, the `evangelist` stage past it, or an order on record.\n */\nexport function isOutreachCustomer(\n contact: Record<string, unknown> | null | undefined,\n contactGroupId: string,\n): boolean {\n const facet = readContactFacet(contact, contactGroupId)\n return (\n facet.lifecycleStage === 'customer' ||\n facet.lifecycleStage === 'evangelist' ||\n facet.sources?.order === true ||\n Number(facet.ordersCount) > 0\n )\n}\n\n/** A confirmation counts only when it says who confirmed it and when. */\nexport function outreachAttestationHolds(\n attestations: OutreachAttestations | null | undefined,\n kind: OutreachAttestationKind,\n): boolean {\n const entry = attestations?.[kind]\n return (\n typeof entry?.uid === 'string' &&\n entry.uid.trim() !== '' &&\n typeof entry.atMs === 'number' &&\n Number.isFinite(entry.atMs) &&\n entry.atMs > 0\n )\n}\n\n/** The personal line as stored: one line, whitespace settled. */\nexport function normalizeOutreachPersonalLine(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\n/** Each attestation as the sentence that asks for it reads it. */\nconst ATTESTATION_PHRASES: Record<OutreachAttestationKind, string> = {\n us_business_address: \"it's a US business address\",\n published_or_given: 'they or their company published it, or they gave it to you',\n verified_deliverable: 'it was verified as deliverable',\n}\n\nfunction listInWords(items: readonly string[]): string {\n if (items.length <= 1) return items.join('')\n return `${items.slice(0, -1).join('; ')}; and ${items[items.length - 1]}`\n}\n\nconst COULD_NOT_CHECK = 'Try again in a moment.'\n\n/** Every gate an address and a contact must pass — see the module note. */\nexport function evaluateOutreachGates(input: OutreachGateInput): OutreachGateResult {\n const lookups = input.lookups\n const settings = readOutreachSequenceSettings(input.settings)\n const email = normalizeContactEmail(input.email)\n const contact = input.contact ?? null\n const cold = isColdOutreachContact(\n contact,\n input.contactGroupId,\n lookups?.hasInboundEmail === true,\n )\n const blocks: OutreachGateBlock[] = []\n const block = (code: OutreachGateCode, reason: string) => blocks.push({ code, reason })\n // The gates below need both a record and an address to read.\n const refusedOutright = (): OutreachGateResult => ({\n allowed: false,\n email,\n cold,\n country: { country: null, source: null },\n blocks,\n hold: null,\n missingAttestations: [],\n })\n\n if (!contact) {\n block('contact_missing', 'This contact no longer exists in the CRM.')\n return refusedOutright()\n }\n\n // 1. A valid address, one that belongs to this contact and to a person.\n if (!email) {\n block('invalid_email', `\"${String(input.email ?? '').trim()}\" isn't a valid email address.`)\n return refusedOutright()\n }\n if (MACHINE_LOCAL_PART.test(email.slice(0, email.lastIndexOf('@')))) {\n block('invalid_email', `${email} is an automated address that no person reads.`)\n }\n if (!contactEmails(contact).includes(email)) {\n block('email_not_on_contact', `${email} isn't an address on this contact's record.`)\n }\n\n // 2. A business address for a cold contact.\n if (cold && isPublicMailboxDomain(email)) {\n block(\n 'free_mail',\n `${email} is a personal mailbox. Cold outreach goes only to business addresses.`,\n )\n }\n\n // 3. The country.\n const facet = readContactFacet(contact, input.contactGroupId)\n const companyAddress = input.company ? input.company['address'] : null\n const country = resolveOutreachRecipientCountry({\n email,\n contactAddress: facet.address,\n companyAddress,\n })\n const attestedUs = outreachAttestationHolds(input.attestations, 'us_business_address')\n if (country.country) {\n const name = outreachCountryName(country.country)\n const evidence =\n country.source === 'domain' ? `its domain places it in ${name}` : `it's in ${name}`\n if (!settings.allowedCountries.includes(country.country)) {\n block(\n 'country_not_allowed',\n `${email} can't be emailed from this sequence: ${evidence}, which isn't one of its countries.`,\n )\n } else if (cold && country.country !== 'US') {\n block(\n 'country_not_allowed',\n `Cold outreach goes only to US business addresses, and ${evidence}.`,\n )\n }\n } else if (!settings.allowedCountries.includes('US')) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Add the country to the contact's address.`,\n )\n } else if (!attestedUs) {\n block(\n 'country_unknown',\n `We don't know which country ${email} is in. Confirm it's a US business address, or add the country to the contact.`,\n )\n }\n\n // 4. Every list that says not to — the record's own verdict first\n // (AGL-3245), in the words the record page shows: what the lists below\n // hold, said by the reason it is held.\n const recordRefusal = emailStateRefusal(readEmailState(contact))\n if (recordRefusal) block('email_state', recordRefusal)\n if (lookups?.platformSuppressed === true) {\n block(\n 'platform_suppressed',\n `${email} is on the platform's suppression list after a hard bounce or a spam complaint.`,\n )\n } else if (lookups?.platformSuppressed !== false) {\n block(\n 'platform_suppressed',\n `We couldn't check the platform's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.hostSuppressed === true) {\n block('host_suppressed', `${email} unsubscribed from this site's email, or mail to it bounced.`)\n } else if (lookups?.hostSuppressed !== false) {\n block(\n 'host_suppressed',\n `We couldn't check this site's suppression list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.salesTopicState === 'opted-out') {\n block('sales_opted_out', `${email} opted out of sales outreach from this site.`)\n } else if (lookups?.salesTopicState !== 'subscribed' && lookups?.salesTopicState !== 'pending') {\n block(\n 'sales_opted_out',\n `We couldn't check whether ${email} opted out of sales outreach. ${COULD_NOT_CHECK}`,\n )\n }\n if (lookups?.doNotContact === true) {\n block('do_not_contact', `${email} is on your organization's do-not-contact list.`)\n } else if (lookups?.doNotContact !== false) {\n block(\n 'do_not_contact',\n `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`,\n )\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n if (lookups?.doNotContactDomain === true) {\n block(\n 'do_not_contact_domain',\n `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`,\n )\n } else if (lookups?.doNotContactDomain !== false) {\n block(\n 'do_not_contact_domain',\n `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`,\n )\n }\n\n // 4b. The mail gateway in front of the domain (AGL-3326): no MX means no\n // mail; a gateway that refused us twice lately holds, below.\n const gateway = lookups?.gateway ?? null\n if (!gateway) {\n block('no_mx', `We couldn't look up the mail server for ${domain}. ${COULD_NOT_CHECK}`)\n } else if (gateway.gateway === 'none') {\n block('no_mx', `${domain} has no MX record, so ${email} cannot receive mail.`)\n }\n const hold: OutreachGateHold | null =\n gateway && input.gatewayHoldReleased !== true && outreachGatewayHolds(gateway)\n ? {\n code: 'gateway_blocked_here',\n gateway: gateway.gateway,\n reason: outreachGatewayHoldReason(gateway.gateway, gateway.blocked30),\n }\n : null\n\n // 5. Not one of the workspace's own people.\n const members = lookups?.workspaceMembers\n if (!Array.isArray(members)) {\n block(\n 'workspace_member',\n `We couldn't check whether ${email} belongs to a member of this workspace. ${COULD_NOT_CHECK}`,\n )\n } else if (findMemberByEmailAddress(members, email)) {\n block('workspace_member', `${email} belongs to a member of this workspace.`)\n }\n\n // 6. Not a customer, unless the sequence is written for them.\n if (!settings.allowCustomers && isOutreachCustomer(contact, input.contactGroupId)) {\n block('customer', \"This contact is a customer, and this sequence doesn't include customers.\")\n }\n\n // 7. One sequence at a time.\n const enrollments = lookups?.openEnrollments\n if (!Array.isArray(enrollments)) {\n block(\n 'already_enrolled',\n `We couldn't check this contact's other sequences. ${COULD_NOT_CHECK}`,\n )\n } else if (\n enrollments.some(\n (enrollment) =>\n enrollment?.id !== input.enrollmentId &&\n OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment?.status),\n )\n ) {\n block(\n 'already_enrolled',\n 'This contact is already in another sequence. Finish or stop that one first.',\n )\n }\n\n // 8. What only the rep can vouch for, on a cold contact.\n const personalLine = normalizeOutreachPersonalLine(input.personalLine)\n const missingAttestations = cold\n ? OUTREACH_ATTESTATION_KINDS.filter(\n (kind) => !outreachAttestationHolds(input.attestations, kind),\n )\n : []\n if (missingAttestations.length) {\n const phrases = missingAttestations.map((kind) => `that ${ATTESTATION_PHRASES[kind]}`)\n block(\n 'attestations_missing',\n `Before emailing a cold contact, confirm ${listInWords(phrases)}.`,\n )\n }\n if (cold && !personalLine) {\n block(\n 'personal_line_missing',\n 'Write a personal line: one sentence on why you are writing to this person now.',\n )\n }\n if (personalLine.length > OUTREACH_PERSONAL_LINE_MAX) {\n block(\n 'personal_line_too_long',\n `Keep the personal line to one sentence, under ${OUTREACH_PERSONAL_LINE_MAX} characters.`,\n )\n }\n\n return {\n allowed: blocks.length === 0,\n email,\n cold,\n country,\n blocks,\n hold,\n missingAttestations,\n }\n}\n\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","emailStateRefusal","readEmailState","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","outreachGatewayHoldReason","outreachGatewayHolds","outreachCountryName","resolveOutreachRecipientCountry","readOutreachSequenceSettings","OUTREACH_INBOUND_CONTACT_SOURCES","OUTREACH_OPEN_ENROLLMENT_STATUSES","MACHINE_LOCAL_PART","isColdOutreachContact","contact","contactGroupId","hasInboundEmail","sources","some","source","isOutreachCustomer","facet","lifecycleStage","order","Number","ordersCount","outreachAttestationHolds","attestations","kind","entry","uid","trim","atMs","isFinite","normalizeOutreachPersonalLine","value","replace","ATTESTATION_PHRASES","us_business_address","published_or_given","verified_deliverable","listInWords","items","length","join","slice","COULD_NOT_CHECK","evaluateOutreachGates","input","lookups","settings","email","cold","blocks","block","code","reason","push","refusedOutright","allowed","country","hold","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","recordRefusal","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","gateway","gatewayHoldReleased","blocked30","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
@@ -14,15 +14,11 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { isMailGatewayBlock, type MailBounceReading } from '@aglyn/shared-util-email/mail-bounce';
17
18
  /** What a bounce says, as the classifier hands it over. */
18
- export interface OutreachBounceReading {
19
- /** The enhanced status code, `5.7.1`, when the report carried one. */
20
- status: string | null;
21
- /** The server's diagnostic line, when it gave one. */
22
- diagnostic: string | null;
23
- }
24
- /** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */
25
- export declare function isOutreachGatewayBlock(bounce: OutreachBounceReading | null | undefined): boolean;
19
+ export type OutreachBounceReading = MailBounceReading;
20
+ /** Whether a hard bounce reads as the domain's gateway refusing the sender. */
21
+ export declare const isOutreachGatewayBlock: typeof isMailGatewayBlock;
26
22
  /**
27
23
  * What the enrollment's stop detail says when its bounce was a gateway
28
24
  * block: that the domain was blocked and is on the list, then the
@@ -16,32 +16,12 @@
16
16
  */ /*==========================================
17
17
  * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244).
18
18
  *
19
- * Two hard bounces look alike to the delivery report — `failed`, a `5.x.x`
20
- * status — and mean different things. `550 5.1.1 no such user` is a verdict
21
- * on ONE ADDRESS: the person left, or the address was never right. `550
22
- * 5.7.1 ... :blocked` from a Barracuda, a Proofpoint or a Mimecast is a
23
- * verdict on THE SENDER by the whole recipient organization: its gateway
24
- * refused the mail on policy or reputation, and the next address at that
25
- * company will bounce the same way and count against the same bounce rule.
26
- *
27
- * So a hard bounce is read once more, for which of the two it is. The words
28
- * that name a gateway or a policy make it a block, and so does a bare
29
- * `5.7.0` / `5.7.1` (RFC 3463's "delivery not authorized" and "message
30
- * refused"); any wording that says the ADDRESS is the problem — user
31
- * unknown, mailbox not found, a `5.1.x` or `5.2.x` status — outranks them,
32
- * because a server that names the address is talking about the address.
33
- * The tie goes to the address: only the block is filed against the domain.
34
- *==========================================*/ /** What a bounce says, as the classifier hands it over. */ /** The words a gateway or a policy refusal is written in. */ const GATEWAY_WORDS = /barracuda|proofpoint|mimecast|:blocked\b|blocked using|\bpolicy\b|spamhaus|reputation|\bblock ?list|\bblacklist|\bdenylist|\bbanned\b|\brbl\b|\bdnsbl\b/i;
35
- /** The words, and the status classes, that say one address is the problem. */ const ADDRESS_WORDS = /no such (user|mailbox|recipient|address|person)|unknown (user|recipient|address|mailbox|account)|(user|mailbox|recipient|address|account|email)\b[^.;\n]{0,40}\b(unknown|not found|does ?n[o']?t exist|doesn't exist|invalid|disabled|unavailable|inactive)|recipient ?not ?found|does not exist|doesn't exist|invalid (recipient|mailbox|address)|mailbox (unavailable|full|disabled)|address rejected|\b5\.[12]\.\d{1,3}\b/i;
36
- /** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */ export function isOutreachGatewayBlock(bounce) {
37
- var _bounce_status, _bounce_diagnostic;
38
- if (!bounce) return false;
39
- const text = `${(_bounce_status = bounce.status) != null ? _bounce_status : ''} ${(_bounce_diagnostic = bounce.diagnostic) != null ? _bounce_diagnostic : ''}`.trim();
40
- if (!text) return false;
41
- if (ADDRESS_WORDS.test(text)) return false;
42
- if (GATEWAY_WORDS.test(text)) return true;
43
- return /\b5\.7\.[01]\b/.test(text);
44
- }
19
+ * The reading is the platform's — `@aglyn/shared-util-email`'s
20
+ * `mail-bounce` (AGL-3328), which reads a Resend bounce the same way a
21
+ * Gmail DSN is read here. What stays is the sentence Sequences writes on
22
+ * the enrollment when a block files the domain.
23
+ *==========================================*/ import { isMailGatewayBlock } from "@aglyn/shared-util-email/mail-bounce";
24
+ /** Whether a hard bounce reads as the domain's gateway refusing the sender. */ export const isOutreachGatewayBlock = isMailGatewayBlock;
45
25
  /**
46
26
  * What the enrollment's stop detail says when its bounce was a gateway
47
27
  * block: that the domain was blocked and is on the list, then the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gateway-block.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244).\n *\n * Two hard bounces look alike to the delivery report — `failed`, a `5.x.x`\n * status — and mean different things. `550 5.1.1 no such user` is a verdict\n * on ONE ADDRESS: the person left, or the address was never right. `550\n * 5.7.1 ... :blocked` from a Barracuda, a Proofpoint or a Mimecast is a\n * verdict on THE SENDER by the whole recipient organization: its gateway\n * refused the mail on policy or reputation, and the next address at that\n * company will bounce the same way and count against the same bounce rule.\n *\n * So a hard bounce is read once more, for which of the two it is. The words\n * that name a gateway or a policy make it a block, and so does a bare\n * `5.7.0` / `5.7.1` (RFC 3463's \"delivery not authorized\" and \"message\n * refused\"); any wording that says the ADDRESS is the problem — user\n * unknown, mailbox not found, a `5.1.x` or `5.2.x` status — outranks them,\n * because a server that names the address is talking about the address.\n * The tie goes to the address: only the block is filed against the domain.\n *==========================================*/\n\n/** What a bounce says, as the classifier hands it over. */\nexport interface OutreachBounceReading {\n /** The enhanced status code, `5.7.1`, when the report carried one. */\n status: string | null\n /** The server's diagnostic line, when it gave one. */\n diagnostic: string | null\n}\n\n/** The words a gateway or a policy refusal is written in. */\nconst GATEWAY_WORDS =\n /barracuda|proofpoint|mimecast|:blocked\\b|blocked using|\\bpolicy\\b|spamhaus|reputation|\\bblock ?list|\\bblacklist|\\bdenylist|\\bbanned\\b|\\brbl\\b|\\bdnsbl\\b/i\n\n/** The words, and the status classes, that say one address is the problem. */\nconst ADDRESS_WORDS =\n /no such (user|mailbox|recipient|address|person)|unknown (user|recipient|address|mailbox|account)|(user|mailbox|recipient|address|account|email)\\b[^.;\\n]{0,40}\\b(unknown|not found|does ?n[o']?t exist|doesn't exist|invalid|disabled|unavailable|inactive)|recipient ?not ?found|does not exist|doesn't exist|invalid (recipient|mailbox|address)|mailbox (unavailable|full|disabled)|address rejected|\\b5\\.[12]\\.\\d{1,3}\\b/i\n\n/** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */\nexport function isOutreachGatewayBlock(bounce: OutreachBounceReading | null | undefined): boolean {\n if (!bounce) return false\n const text = `${bounce.status ?? ''} ${bounce.diagnostic ?? ''}`.trim()\n if (!text) return false\n if (ADDRESS_WORDS.test(text)) return false\n if (GATEWAY_WORDS.test(text)) return true\n return /\\b5\\.7\\.[01]\\b/.test(text)\n}\n\n/**\n * What the enrollment's stop detail says when its bounce was a gateway\n * block: that the domain was blocked and is on the list, then the\n * diagnostic — already scrubbed of the address — for the record.\n */\nexport function outreachGatewayBlockDetail(domain: string, diagnostic: string | null): string {\n const said = String(diagnostic ?? '').trim()\n return (\n `The mail gateway at ${domain} blocked the email, so ${domain} is on your organization's ` +\n `do-not-contact list and no address there will be emailed.${said ? ` The server said: ${said}` : ''}`\n )\n}\n"],"names":["GATEWAY_WORDS","ADDRESS_WORDS","isOutreachGatewayBlock","bounce","text","status","diagnostic","trim","test","outreachGatewayBlockDetail","domain","said","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;4CAkB4C,GAE5C,yDAAyD,GAQzD,2DAA2D,GAC3D,MAAMA,gBACJ;AAEF,4EAA4E,GAC5E,MAAMC,gBACJ;AAEF,mGAAmG,GACnG,OAAO,SAASC,uBAAuBC,MAAgD;QAErEA,gBAAuBA;IADvC,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMC,OAAO,IAAGD,iBAAAA,OAAOE,MAAM,YAAbF,iBAAiB,GAAG,CAAC,GAAEA,qBAAAA,OAAOG,UAAU,YAAjBH,qBAAqB,IAAI,CAACI,IAAI;IACrE,IAAI,CAACH,MAAM,OAAO;IAClB,IAAIH,cAAcO,IAAI,CAACJ,OAAO,OAAO;IACrC,IAAIJ,cAAcQ,IAAI,CAACJ,OAAO,OAAO;IACrC,OAAO,iBAAiBI,IAAI,CAACJ;AAC/B;AAEA;;;;CAIC,GACD,OAAO,SAASK,2BAA2BC,MAAc,EAAEJ,UAAyB;IAClF,MAAMK,OAAOC,OAAON,qBAAAA,aAAc,IAAIC,IAAI;IAC1C,OACE,CAAC,oBAAoB,EAAEG,OAAO,uBAAuB,EAAEA,OAAO,2BAA2B,CAAC,GAC1F,CAAC,yDAAyD,EAAEC,OAAO,CAAC,kBAAkB,EAAEA,MAAM,GAAG,IAAI;AAEzG"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gateway-block.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244).\n *\n * The reading is the platform's — `@aglyn/shared-util-email`'s\n * `mail-bounce` (AGL-3328), which reads a Resend bounce the same way a\n * Gmail DSN is read here. What stays is the sentence Sequences writes on\n * the enrollment when a block files the domain.\n *==========================================*/\n\nimport { isMailGatewayBlock, type MailBounceReading } from '@aglyn/shared-util-email/mail-bounce'\n\n/** What a bounce says, as the classifier hands it over. */\nexport type OutreachBounceReading = MailBounceReading\n\n/** Whether a hard bounce reads as the domain's gateway refusing the sender. */\nexport const isOutreachGatewayBlock = isMailGatewayBlock\n\n/**\n * What the enrollment's stop detail says when its bounce was a gateway\n * block: that the domain was blocked and is on the list, then the\n * diagnostic — already scrubbed of the address — for the record.\n */\nexport function outreachGatewayBlockDetail(domain: string, diagnostic: string | null): string {\n const said = String(diagnostic ?? '').trim()\n return (\n `The mail gateway at ${domain} blocked the email, so ${domain} is on your organization's ` +\n `do-not-contact list and no address there will be emailed.${said ? ` The server said: ${said}` : ''}`\n )\n}\n"],"names":["isMailGatewayBlock","isOutreachGatewayBlock","outreachGatewayBlockDetail","domain","diagnostic","said","String","trim"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;4CAO4C,GAE5C,SAASA,kBAAkB,QAAgC,uCAAsC;AAKjG,6EAA6E,GAC7E,OAAO,MAAMC,yBAAyBD,mBAAkB;AAExD;;;;CAIC,GACD,OAAO,SAASE,2BAA2BC,MAAc,EAAEC,UAAyB;IAClF,MAAMC,OAAOC,OAAOF,qBAAAA,aAAc,IAAIG,IAAI;IAC1C,OACE,CAAC,oBAAoB,EAAEJ,OAAO,uBAAuB,EAAEA,OAAO,2BAA2B,CAAC,GAC1F,CAAC,yDAAyD,EAAEE,OAAO,CAAC,kBAAkB,EAAEA,MAAM,GAAG,IAAI;AAEzG"}
@@ -14,84 +14,29 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- export type OutreachMailGateway = 'barracuda' | 'proofpoint' | 'mimecast' | 'google' | 'microsoft' | 'other' | 'none';
18
- export declare const OUTREACH_MAIL_GATEWAYS: readonly OutreachMailGateway[];
17
+ import { classifyMailGateway, isMailGatewayFronted, type MailGateway, mailGatewayChip, type MailGatewayChip, type MailGatewayChipTone, mailGatewayDay, type MailGatewayDayCounts, mailGatewayHolds, mailGatewayOfHost, type MailGatewayStanding, mailGatewayWindow, pruneMailGatewayDays } from '@aglyn/shared-util-email/mail-gateway';
18
+ export type OutreachMailGateway = MailGateway;
19
+ export type OutreachGatewayDayCounts = MailGatewayDayCounts;
20
+ export type OutreachGatewayStanding = MailGatewayStanding;
21
+ export type OutreachGatewayChipTone = MailGatewayChipTone;
22
+ export type OutreachGatewayChip = MailGatewayChip;
23
+ export declare const OUTREACH_MAIL_GATEWAYS: readonly MailGateway[];
19
24
  /** The gateway as the Check-people chip and the enrollment row name it. */
20
- export declare const OUTREACH_MAIL_GATEWAY_LABELS: Record<OutreachMailGateway, string>;
21
- /**
22
- * The security gateways that stand in front of a whole company's mail and
23
- * refuse a sender for everyone behind them: the ones the enroll dialog
24
- * counts as "gateway-fronted".
25
- */
26
- export declare const OUTREACH_GATEWAY_FRONTED: readonly OutreachMailGateway[];
27
- /** Blocks in the window, with no delivery beside them, that hold the next send. */
25
+ export declare const OUTREACH_MAIL_GATEWAY_LABELS: Record<MailGateway, string>;
26
+ /** The security gateways the enroll dialog counts as "gateway-fronted". */
27
+ export declare const OUTREACH_GATEWAY_FRONTED: readonly MailGateway[];
28
28
  export declare const OUTREACH_GATEWAY_HOLD_BLOCKS = 2;
29
- /** The days the hold is judged over. */
30
29
  export declare const OUTREACH_GATEWAY_WINDOW_DAYS = 30;
31
- /** The days the Check-people chip counts. */
32
30
  export declare const OUTREACH_GATEWAY_CHIP_DAYS = 7;
33
- /** How long a domain's MX answer is trusted before it is looked up again. */
34
31
  export declare const OUTREACH_DOMAIN_INTEL_TTL_MS: number;
35
- /** A send with no bounce this long after it counts as delivered. */
36
32
  export declare const OUTREACH_GATEWAY_DELIVERED_AFTER_MS: number;
37
- /**
38
- * The gateway ONE host names, or `null` when it names none of the known
39
- * ones — a Remote-MTA in a bounce, or one MX exchange.
40
- */
41
- export declare function outreachMailGatewayOfHost(host: unknown): OutreachMailGateway | null;
42
- /**
43
- * The gateway a domain's MX records name — see the module note. A domain
44
- * whose exchanges name more than one is read as the first named one in MX
45
- * order (the list is handed over lowest-priority-first); one that names
46
- * none is `other`; an empty list is `none`.
47
- */
48
- export declare function classifyOutreachMailGateway(mxHosts: readonly string[]): OutreachMailGateway;
49
- /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */
50
- export declare function isOutreachGatewayFronted(gateway: OutreachMailGateway | null | undefined): boolean;
51
- /** The counts one UTC day holds on a gateway's ledger. */
52
- export interface OutreachGatewayDayCounts {
53
- sent: number;
54
- delivered: number;
55
- blocked: number;
56
- }
57
- /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */
58
- export declare function outreachGatewayDay(atMs: number): string;
59
- /** The ledger's counts summed over the last `days` UTC days, today included. */
60
- export declare function outreachGatewayWindow(days: Record<string, Partial<OutreachGatewayDayCounts>> | null | undefined, nowMs: number, windowDays: number): OutreachGatewayDayCounts;
61
- /** The ledger's days with everything older than `keepDays` dropped. */
62
- export declare function pruneOutreachGatewayDays<T>(days: Record<string, T> | null | undefined, nowMs: number, keepDays: number): Record<string, T>;
63
- /**
64
- * What the gates and the enroll dialog know about the gateway in front of
65
- * one address: which it is, and what it did with this organization's mail
66
- * over the chip's week and the hold's month.
67
- */
68
- export interface OutreachGatewayStanding {
69
- gateway: OutreachMailGateway;
70
- /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_CHIP_DAYS} days. */
71
- blocked7: number;
72
- delivered7: number;
73
- /** Blocks and deliveries in the last {@link OUTREACH_GATEWAY_WINDOW_DAYS} days. */
74
- blocked30: number;
75
- delivered30: number;
76
- }
77
- /**
78
- * Whether the gateway holds the next send — see the module note. A named
79
- * gateway only: `other` is many exchanges, and `none` blocks instead.
80
- */
81
- export declare function outreachGatewayHolds(standing: Pick<OutreachGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean;
33
+ export declare const outreachMailGatewayOfHost: typeof mailGatewayOfHost;
34
+ export declare const classifyOutreachMailGateway: typeof classifyMailGateway;
35
+ export declare const isOutreachGatewayFronted: typeof isMailGatewayFronted;
36
+ export declare const outreachGatewayDay: typeof mailGatewayDay;
37
+ export declare const outreachGatewayWindow: typeof mailGatewayWindow;
38
+ export declare const pruneOutreachGatewayDays: typeof pruneMailGatewayDays;
39
+ export declare const outreachGatewayHolds: typeof mailGatewayHolds;
40
+ export declare const outreachGatewayChip: typeof mailGatewayChip;
82
41
  /** The sentence the hold is written with, on the enrollment and in the engine's reason. */
83
42
  export declare function outreachGatewayHoldReason(gateway: OutreachMailGateway, blocked30: number): string;
84
- /** The chip's tone: what color the Check-people step gives it. */
85
- export type OutreachGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked';
86
- export interface OutreachGatewayChip {
87
- label: string;
88
- tone: OutreachGatewayChipTone;
89
- }
90
- /**
91
- * The Check-people chip for one person, or `null` when there is nothing
92
- * worth a chip: an unrecognized exchange with no verdict this week. Red for
93
- * a gateway that refused this week, green for one that delivered, neutral
94
- * for the hosted providers and the security gateways with no verdict yet,
95
- * and blocked for a domain with no MX.
96
- */
97
- export declare function outreachGatewayChip(standing: OutreachGatewayStanding | null | undefined): OutreachGatewayChip | null;