@aglyn/plugins-outreach 1.0.0-beta.199 → 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.
- package/package.json +14 -14
- package/src/lib/components/do-not-contact-domains.js +60 -42
- package/src/lib/components/do-not-contact-domains.js.map +1 -1
- package/src/lib/components/enrollment-actions.d.ts +33 -0
- package/src/lib/components/enrollment-actions.js +174 -0
- package/src/lib/components/enrollment-actions.js.map +1 -0
- package/src/lib/components/enrollment-detail.d.ts +21 -0
- package/src/lib/components/enrollment-detail.js +870 -0
- package/src/lib/components/enrollment-detail.js.map +1 -0
- package/src/lib/components/enrollment-filter-params.d.ts +58 -0
- package/src/lib/components/enrollment-filter-params.js +98 -0
- package/src/lib/components/enrollment-filter-params.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +25 -2
- package/src/lib/components/enrollments-table.js +303 -283
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/outreach-console-page.js +10 -0
- package/src/lib/components/outreach-console-page.js.map +1 -1
- package/src/lib/components/sequence-detail.d.ts +2 -0
- package/src/lib/components/sequence-detail.js +73 -11
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.d.ts +17 -0
- package/src/lib/components/sequence-report-card.js +63 -15
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/sequences-section.d.ts +13 -1
- package/src/lib/components/sequences-section.js +108 -83
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +3 -1
- package/src/lib/components/use-outreach-api.js +6 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +30 -25
- package/src/lib/components/use-outreach-data.js +93 -104
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
- package/src/lib/components/use-outreach-enrollment.js +133 -0
- package/src/lib/components/use-outreach-enrollment.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +2 -0
- package/src/lib/constants/api-routes.js +1 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/gates.d.ts +4 -3
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/gateway-block.d.ts +4 -8
- package/src/lib/engine/gateway-block.js +6 -26
- package/src/lib/engine/gateway-block.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +18 -73
- package/src/lib/engine/mail-gateway.js +32 -206
- package/src/lib/engine/mail-gateway.js.map +1 -1
- package/src/lib/enrollment/enrollment-search.d.ts +32 -0
- package/src/lib/enrollment/enrollment-search.js +40 -0
- package/src/lib/enrollment/enrollment-search.js.map +1 -0
- package/src/lib/model/do-not-contact-domain-list-query.d.ts +32 -0
- package/src/lib/model/do-not-contact-domain-list-query.js +107 -0
- package/src/lib/model/do-not-contact-domain-list-query.js.map +1 -0
- package/src/lib/model/enrollment-engagement.d.ts +26 -2
- package/src/lib/model/enrollment-engagement.js +40 -3
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +40 -0
- package/src/lib/model/enrollment-history.js +95 -0
- package/src/lib/model/enrollment-history.js.map +1 -0
- package/src/lib/model/enrollment-list-query.d.ts +28 -0
- package/src/lib/model/enrollment-list-query.js +151 -0
- package/src/lib/model/enrollment-list-query.js.map +1 -0
- package/src/lib/model/enrollment-timeline.d.ts +78 -0
- package/src/lib/model/enrollment-timeline.js +430 -0
- package/src/lib/model/enrollment-timeline.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +24 -0
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +90 -29
- package/src/lib/model/outreach.types.js +33 -8
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-list-query.d.ts +22 -0
- package/src/lib/model/sequence-list-query.js +99 -0
- package/src/lib/model/sequence-list-query.js.map +1 -0
- package/src/lib/routes/enroll-routes.js +34 -3
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.d.ts +10 -0
- package/src/lib/routes/enrollment-routes.js +82 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +14 -4
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +3 -1
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +6 -1
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.d.ts +10 -5
- package/src/lib/runtime/click-events.js +45 -6
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +6 -0
- package/src/lib/runtime/gateway-ledger.js +9 -3
- package/src/lib/runtime/gateway-ledger.js.map +1 -1
- package/src/lib/runtime/host-suspension.d.ts +30 -0
- package/src/lib/runtime/host-suspension.js +35 -0
- package/src/lib/runtime/host-suspension.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +4 -1
- package/src/lib/runtime/person-erasure.js +14 -3
- package/src/lib/runtime/person-erasure.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +34 -3
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +27 -1
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +46 -6
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +4 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.js +6 -1
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +61 -43
- package/src/lib/storage/domain-intel-store.js +138 -226
- package/src/lib/storage/domain-intel-store.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-data.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 type { CampaignLinkRollup } from '@aglyn/shared-ui-email-campaigns/model'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n doc,\n documentId,\n getCountFromServer,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachDoNotContactDomainEntry,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport {\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../model/stored-records'\n\n/**\n * The Outreach documents the console reads LIVE (AGL-2980): sequences,\n * enrollments, and the mailboxes they send from.\n *\n * Read straight from Firestore — the rules let an org-wide member holding\n * `outreach.use` on an entitled organization read them, and every write goes\n * through a route — so what a page shows is what the routes and the sending\n * runtime wrote, the moment it lands.\n *\n * Every hook answers one of four states, and a page renders each: still\n * loading, ready, failed, or REFUSED — the rules said no, which is a reader\n * who may not see Outreach here rather than something that went wrong.\n */\n\nexport type OutreachLoadStatus = 'loading' | 'ready' | 'error' | 'refused'\n\nexport interface OutreachLoad<T> {\n status: OutreachLoadStatus\n data: T\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/** The most sequences the list reads. */\nexport const OUTREACH_SEQUENCES_LIMIT = 200\n\n/** The sequences read, and whether the organization holds older ones. */\nexport interface OutreachSequencesLoad extends OutreachLoad<OutreachSequence[]> {\n /** True when there are sequences older than the `OUTREACH_SEQUENCES_LIMIT` read. */\n truncated?: boolean\n}\n\n/** The organization's sequences, newest first. */\nexport function useOutreachSequences(\n orgId: string | null,\n): OutreachSequencesLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequencesLoad>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences),\n // Ordered, so the cap keeps the newest rather than an arbitrary set;\n // every sequence carries `createdAtMs`, stamped by the save route.\n orderBy('createdAtMs', 'desc'),\n // One past the cap: the extra document says there are older\n // sequences, and is never listed.\n limit(OUTREACH_SEQUENCES_LIMIT + 1),\n ),\n (snapshot) => {\n const sequences = snapshot.docs\n .slice(0, OUTREACH_SEQUENCES_LIMIT)\n .map((entry) => readStoredOutreachSequence(entry.id, entry.data()))\n .filter((sequence): sequence is OutreachSequence => sequence !== null)\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n setResult({\n status: 'ready',\n data: sequences,\n truncated: snapshot.docs.length > OUTREACH_SEQUENCES_LIMIT,\n })\n },\n (error) => setResult(failed([], error, 'sequences')),\n )\n }, [firestore, orgId])\n return result\n}\n\n/** One sequence, or `null` once it is known not to exist. */\nexport function useOutreachSequence(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachLoad<OutreachSequence | null> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences, sequenceId),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: readStoredOutreachSequence(\n snapshot.id,\n snapshot.exists() ? snapshot.data() : undefined,\n ),\n }),\n (error) => setResult(failed(null, error, 'the sequence')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * A sequence's per-destination click rollup (AGL-3239), or `null` until a\n * click has been counted.\n *\n * One document, whatever the sequence's size: the aggregate exists so the\n * table is not a read of every enrollment. A sequence that tracks nothing\n * never has one, and an absent document is not an error.\n */\nexport type OutreachSequenceLinksLoad = OutreachLoad<CampaignLinkRollup | null>\n\nexport function useOutreachSequenceLinks(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachSequenceLinksLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequenceLinksLoad>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.sequences,\n sequenceId,\n OUTREACH_LINK_ROLLUP_PATH[0],\n OUTREACH_LINK_ROLLUP_PATH[1],\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.exists() ? (snapshot.data() as CampaignLinkRollup) : null,\n }),\n (error) => setResult(failed(null, error, 'the link report')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * The domains on the organization's do-not-contact list (AGL-3244),\n * alphabetically, live: an add or a remove through the route shows up here\n * without a reload, and so does a domain the sending runtime files after a\n * gateway block.\n */\nexport function useOutreachDoNotContactDomains(\n orgId: string | null,\n): OutreachLoad<OutreachDoNotContactDomainEntry[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachDoNotContactDomainEntry[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains),\n (snapshot) => {\n const domains = snapshot.docs\n .map((entry) => ({ ...(entry.data() as OutreachDoNotContactDomainEntry), domain: entry.id }))\n .sort((a, b) => a.domain.localeCompare(b.domain))\n setResult({ status: 'ready', data: domains })\n },\n (error) => setResult(failed([], error, 'the do-not-contact domains')),\n )\n }, [firestore, orgId])\n return result\n}\n\n/** How many enrollments one page of the table reads; \"Show more\" reads another. */\nexport const OUTREACH_ENROLLMENTS_PAGE = 200\n\nexport interface OutreachEnrollmentsLoad extends OutreachLoad<\n OutreachEnrollment[]\n> {\n /** Whether a further page may exist. */\n hasMore: boolean\n showMore(): void\n}\n\n/**\n * A sequence's enrollments, the most recently enrolled first.\n *\n * Paged by document id — an equality on `sequenceId` in the id's own order\n * needs no composite index — and sorted by enrollment time once read, so a\n * table of up to a page reads in the order people were enrolled.\n */\nexport function useOutreachEnrollments(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachEnrollmentsLoad {\n const firestore = useFirestore()\n const [pages, setPages] = useState(1)\n const [result, setResult] = useState<\n OutreachLoad<OutreachEnrollment[]> & { size: number }\n >({\n status: 'loading',\n data: [],\n size: 0,\n })\n useEffect(() => setPages(1), [orgId, sequenceId])\n useEffect(() => {\n if (!orgId || !sequenceId) {\n setResult({ status: 'loading', data: [], size: 0 })\n return undefined\n }\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments),\n where('sequenceId', '==', sequenceId),\n orderBy(documentId()),\n limit(pages * OUTREACH_ENROLLMENTS_PAGE),\n ),\n (snapshot) => {\n const enrollments = snapshot.docs\n .map((entry) => readStoredOutreachEnrollment(entry.id, entry.data()))\n .filter(\n (enrollment): enrollment is OutreachEnrollment =>\n enrollment !== null,\n )\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n setResult({ status: 'ready', data: enrollments, size: snapshot.size })\n },\n (error) =>\n setResult({ ...failed([], error, 'the enrollments'), size: 0 }),\n )\n }, [firestore, orgId, sequenceId, pages])\n return useMemo(\n () => ({\n status: result.status,\n data: result.data,\n hasMore: result.size >= pages * OUTREACH_ENROLLMENTS_PAGE,\n showMore: () => setPages((count) => count + 1),\n }),\n [result, pages],\n )\n}\n\n/** The counts the sequence list shows beside each sequence. */\nexport interface OutreachSequenceCounts {\n enrolled: number\n active: number\n replied: number\n bounced: number\n optedOut: number\n}\n\nconst COUNTED: ReadonlyArray<\n [keyof OutreachSequenceCounts, OutreachEnrollmentStatus | null]\n> = [\n ['enrolled', null],\n ['active', 'active'],\n ['replied', 'replied'],\n ['bounced', 'bounced'],\n ['optedOut', 'opted_out'],\n]\n\n/**\n * Each sequence's enrollment counts, by server-side aggregation — one count\n * per figure, never a read of the enrollments themselves. Recounted when the\n * sequences asked about change — the list asks about the page on screen —\n * and absent while counting, or if a count failed.\n */\nexport function useOutreachSequenceCounts(\n orgId: string | null,\n sequenceIds: readonly string[],\n): Record<string, OutreachSequenceCounts> {\n const firestore = useFirestore()\n const [counts, setCounts] = useState<Record<string, OutreachSequenceCounts>>(\n {},\n )\n const key = sequenceIds.join(',')\n useEffect(() => {\n if (!orgId || !key) return undefined\n let current = true\n const enrollments = collection(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.enrollments,\n )\n Promise.all(\n key.split(',').map(async (sequenceId) => {\n const figures = await Promise.all(\n COUNTED.map(async ([, status]) => {\n const counted = await getCountFromServer(\n status\n ? query(\n enrollments,\n where('sequenceId', '==', sequenceId),\n where('status', '==', status),\n )\n : query(enrollments, where('sequenceId', '==', sequenceId)),\n )\n return counted.data().count\n }),\n )\n return [\n sequenceId,\n Object.fromEntries(\n COUNTED.map(([name], index) => [name, figures[index]]),\n ) as unknown as OutreachSequenceCounts,\n ] as const\n }),\n )\n .then((entries) => current && setCounts(Object.fromEntries(entries)))\n .catch((error: unknown) =>\n console.error('[outreach] enrollment counts could not be read', error),\n )\n return () => {\n current = false\n }\n }, [firestore, orgId, key])\n return counts\n}\n"],"names":["useFirestore","collection","doc","documentId","getCountFromServer","limit","onSnapshot","orderBy","query","where","useEffect","useMemo","useState","OUTREACH_COLLECTIONS","OUTREACH_LINK_ROLLUP_PATH","readStoredOutreachEnrollment","readStoredOutreachSequence","denied","error","code","failed","data","what","status","console","OUTREACH_SEQUENCES_LIMIT","useOutreachSequences","orgId","firestore","result","setResult","undefined","sequences","snapshot","docs","slice","map","entry","id","filter","sequence","sort","a","b","createdAtMs","truncated","length","useOutreachSequence","sequenceId","exists","useOutreachSequenceLinks","useOutreachDoNotContactDomains","doNotContactDomains","domains","domain","localeCompare","OUTREACH_ENROLLMENTS_PAGE","useOutreachEnrollments","pages","setPages","size","enrollments","enrollment","hasMore","showMore","count","COUNTED","useOutreachSequenceCounts","sequenceIds","counts","setCounts","key","join","current","Promise","all","split","figures","counted","Object","fromEntries","name","index","then","entries","catch"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-data.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 type { CampaignLinkRollup } from '@aglyn/shared-ui-email-campaigns/model'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport type {\n ListQueryDeclaration,\n ListQueryPlan,\n ListQueryRequest,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { useListQuery } from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport {\n collection,\n doc,\n type DocumentData,\n documentId,\n getCountFromServer,\n limit,\n onSnapshot,\n orderBy,\n type Query,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachDoNotContactDomainEntry,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport {\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../model/stored-records'\nimport { OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY } from '../model/do-not-contact-domain-list-query'\nimport {\n OUTREACH_ENROLLMENT_LIST_QUERY,\n outreachEnrollmentListBase,\n} from '../model/enrollment-list-query'\nimport { OUTREACH_SEQUENCE_LIST_QUERY } from '../model/sequence-list-query'\n\n/**\n * The Outreach documents the console reads LIVE (AGL-2980): sequences,\n * enrollments, and the mailboxes they send from.\n *\n * Read straight from Firestore — the rules let an org-wide member holding\n * `outreach.use` on an entitled organization read them, and every write goes\n * through a route — so what a page shows is what the routes and the sending\n * runtime wrote, the moment it lands.\n *\n * Every hook answers one of four states, and a page renders each: still\n * loading, ready, failed, or REFUSED — the rules said no, which is a reader\n * who may not see Outreach here rather than something that went wrong.\n */\n\nexport type OutreachLoadStatus = 'loading' | 'ready' | 'error' | 'refused'\n\nexport interface OutreachLoad<T> {\n status: OutreachLoadStatus\n data: T\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/**\n * One page of a list whose every filter and search word is on its Firestore\n * query (AGL-3321): the rows of the page on screen, whether another page\n * exists, the pager, and the plan — what the query holds and what it could\n * not take, which the list says above itself.\n */\nexport interface OutreachListPage<T> {\n status: OutreachLoadStatus\n rows: T[]\n hasMore: boolean\n page: number\n setPage(page: number): void\n pageSize: number\n setPageSize(pageSize: number): void\n plan: ListQueryPlan\n}\n\n/**\n * A list read through `useListQuery`, in this module's four states and with\n * each document read through its stored-record reader. A document the reader\n * cannot make sense of is left out, as every Outreach read leaves it out.\n */\nfunction useOutreachListQuery<T>(options: {\n collection: Query<DocumentData> | null\n declaration: ListQueryDeclaration\n request: ListQueryRequest\n deps: readonly unknown[]\n read(id: string, data: DocumentData): T | null\n what: string\n}): OutreachListPage<T> {\n const listed = useListQuery<DocumentData & { $id: string }>({\n collection: options.collection,\n declaration: options.declaration,\n request: options.request,\n deps: options.deps,\n idField: '$id',\n })\n const { read, what } = options\n const rows = useMemo(\n () =>\n listed.rows\n .map((row) => read(String(row.$id), row))\n .filter((row): row is NonNullable<typeof row> => row !== null) as T[],\n [listed.rows, read],\n )\n const refused = listed.serverDenied || denied(listed.error)\n useEffect(() => {\n if (listed.status === 'error' && !refused) {\n console.error(`[outreach] ${what} could not be read`, listed.error)\n }\n }, [listed.status, listed.error, refused, what])\n return {\n status:\n listed.status === 'loading'\n ? 'loading'\n : listed.status === 'error'\n ? refused\n ? 'refused'\n : 'error'\n : 'ready',\n rows,\n hasMore: listed.hasMore,\n page: listed.page,\n setPage: listed.setPage,\n pageSize: listed.pageSize,\n setPageSize: listed.setPageSize,\n plan: listed.plan,\n }\n}\n\nconst readSequence = (id: string, data: DocumentData) =>\n readStoredOutreachSequence(id, data)\n\n/**\n * The organization's sequences, newest first, one page at a time — each\n * clause and the search on the query (`OUTREACH_SEQUENCE_LIST_QUERY`).\n */\nexport function useOutreachSequenceList(\n orgId: string | null,\n request: ListQueryRequest,\n): OutreachListPage<OutreachSequence> {\n const firestore = useFirestore()\n return useOutreachListQuery({\n collection: orgId\n ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences)\n : null,\n declaration: OUTREACH_SEQUENCE_LIST_QUERY,\n request,\n deps: [firestore, orgId],\n read: readSequence,\n what: 'sequences',\n })\n}\n\n/** One sequence, or `null` once it is known not to exist. */\nexport function useOutreachSequence(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachLoad<OutreachSequence | null> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences, sequenceId),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: readStoredOutreachSequence(\n snapshot.id,\n snapshot.exists() ? snapshot.data() : undefined,\n ),\n }),\n (error) => setResult(failed(null, error, 'the sequence')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * A sequence's per-destination click rollup (AGL-3239), or `null` until a\n * click has been counted.\n *\n * One document, whatever the sequence's size: the aggregate exists so the\n * table is not a read of every enrollment. A sequence that tracks nothing\n * never has one, and an absent document is not an error.\n */\nexport type OutreachSequenceLinksLoad = OutreachLoad<CampaignLinkRollup | null>\n\nexport function useOutreachSequenceLinks(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachSequenceLinksLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequenceLinksLoad>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.sequences,\n sequenceId,\n OUTREACH_LINK_ROLLUP_PATH[0],\n OUTREACH_LINK_ROLLUP_PATH[1],\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.exists() ? (snapshot.data() as CampaignLinkRollup) : null,\n }),\n (error) => setResult(failed(null, error, 'the link report')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\nconst readDomain = (id: string, data: DocumentData): OutreachDoNotContactDomainEntry => ({\n ...(data as OutreachDoNotContactDomainEntry),\n domain: id,\n})\n\n/**\n * The domains on the organization's do-not-contact list (AGL-3244),\n * alphabetically — the document id is the domain — one page at a time, live:\n * an add or a remove through the route shows up without a reload, and so\n * does a domain the sending runtime files after a gateway block. Each clause\n * and the search are on the query (`OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY`).\n */\nexport function useOutreachDoNotContactDomainList(\n orgId: string | null,\n request: ListQueryRequest,\n): OutreachListPage<OutreachDoNotContactDomainEntry> {\n const firestore = useFirestore()\n return useOutreachListQuery({\n collection: orgId\n ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains)\n : null,\n declaration: OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY,\n request,\n deps: [firestore, orgId],\n read: readDomain,\n what: 'the do-not-contact domains',\n })\n}\n\nconst readEnrollment = (id: string, data: DocumentData) =>\n readStoredOutreachEnrollment(id, data)\n\n/**\n * A sequence's enrollments, the most recently enrolled first, one page at a\n * time — the sequence is the query's base (`sequenceId ==`), and each clause\n * and the search are on it beside that (`OUTREACH_ENROLLMENT_LIST_QUERY`).\n */\nexport function useOutreachEnrollmentList(\n orgId: string | null,\n sequenceId: string | null,\n request: Omit<ListQueryRequest, 'base'>,\n): OutreachListPage<OutreachEnrollment> {\n const firestore = useFirestore()\n return useOutreachListQuery({\n collection:\n orgId && sequenceId\n ? collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments)\n : null,\n declaration: OUTREACH_ENROLLMENT_LIST_QUERY,\n request: { ...request, base: sequenceId ? outreachEnrollmentListBase(sequenceId) : [] },\n deps: [firestore, orgId, sequenceId],\n read: readEnrollment,\n what: 'the enrollments',\n })\n}\n\n/** The counts the sequence list shows beside each sequence. */\nexport interface OutreachSequenceCounts {\n enrolled: number\n active: number\n replied: number\n bounced: number\n optedOut: number\n}\n\nconst COUNTED: ReadonlyArray<\n [keyof OutreachSequenceCounts, OutreachEnrollmentStatus | null]\n> = [\n ['enrolled', null],\n ['active', 'active'],\n ['replied', 'replied'],\n ['bounced', 'bounced'],\n ['optedOut', 'opted_out'],\n]\n\n/**\n * Each sequence's enrollment counts, by server-side aggregation — one count\n * per figure, never a read of the enrollments themselves. Recounted when the\n * sequences asked about change — the list asks about the page on screen —\n * and absent while counting, or if a count failed.\n */\nexport function useOutreachSequenceCounts(\n orgId: string | null,\n sequenceIds: readonly string[],\n): Record<string, OutreachSequenceCounts> {\n const firestore = useFirestore()\n const [counts, setCounts] = useState<Record<string, OutreachSequenceCounts>>(\n {},\n )\n const key = sequenceIds.join(',')\n useEffect(() => {\n if (!orgId || !key) return undefined\n let current = true\n const enrollments = collection(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.enrollments,\n )\n Promise.all(\n key.split(',').map(async (sequenceId) => {\n const figures = await Promise.all(\n COUNTED.map(async ([, status]) => {\n const counted = await getCountFromServer(\n status\n ? query(\n enrollments,\n where('sequenceId', '==', sequenceId),\n where('status', '==', status),\n )\n : query(enrollments, where('sequenceId', '==', sequenceId)),\n )\n return counted.data().count\n }),\n )\n return [\n sequenceId,\n Object.fromEntries(\n COUNTED.map(([name], index) => [name, figures[index]]),\n ) as unknown as OutreachSequenceCounts,\n ] as const\n }),\n )\n .then((entries) => current && setCounts(Object.fromEntries(entries)))\n .catch((error: unknown) =>\n console.error('[outreach] enrollment counts could not be read', error),\n )\n return () => {\n current = false\n }\n }, [firestore, orgId, key])\n return counts\n}\n"],"names":["useFirestore","useListQuery","collection","doc","getCountFromServer","onSnapshot","query","where","useEffect","useMemo","useState","OUTREACH_COLLECTIONS","OUTREACH_LINK_ROLLUP_PATH","readStoredOutreachEnrollment","readStoredOutreachSequence","OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY","OUTREACH_ENROLLMENT_LIST_QUERY","outreachEnrollmentListBase","OUTREACH_SEQUENCE_LIST_QUERY","denied","error","code","failed","data","what","status","console","useOutreachListQuery","options","listed","declaration","request","deps","idField","read","rows","map","row","String","$id","filter","refused","serverDenied","hasMore","page","setPage","pageSize","setPageSize","plan","readSequence","id","useOutreachSequenceList","orgId","firestore","sequences","useOutreachSequence","sequenceId","result","setResult","undefined","snapshot","exists","useOutreachSequenceLinks","readDomain","domain","useOutreachDoNotContactDomainList","doNotContactDomains","readEnrollment","useOutreachEnrollmentList","enrollments","base","COUNTED","useOutreachSequenceCounts","sequenceIds","counts","setCounts","key","join","current","Promise","all","split","figures","counted","count","Object","fromEntries","name","index","then","entries","catch"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type OutreachEnrollment, type OutreachEnrollmentHistoryEntry } from '../model/outreach.types';
|
|
2
|
+
import type { OutreachEnrollmentGatewayResponse } from '../model/outreach-api';
|
|
3
|
+
import type { OutreachApi } from './use-outreach-api';
|
|
4
|
+
import type { OutreachLoad } from './use-outreach-data';
|
|
5
|
+
/** One enrollment, live, or `null` once it is known not to exist. */
|
|
6
|
+
export declare function useOutreachEnrollment(orgId: string | null, enrollmentId: string | null): OutreachLoad<OutreachEnrollment | null>;
|
|
7
|
+
/**
|
|
8
|
+
* The enrollment's history rows, newest first — every row it can hold, which
|
|
9
|
+
* is bounded by `OUTREACH_ENROLLMENT_HISTORY_MAX` on the writing side.
|
|
10
|
+
*/
|
|
11
|
+
export declare function useOutreachEnrollmentHistory(orgId: string | null, enrollmentId: string | null): OutreachLoad<OutreachEnrollmentHistoryEntry[]>;
|
|
12
|
+
/**
|
|
13
|
+
* The mail gateway in front of the person's domain (AGL-3326, AGL-3328),
|
|
14
|
+
* and what it did with mail from the enrollment's sending domain — asked of
|
|
15
|
+
* the server once per page, because the domain's MX lives in the platform's
|
|
16
|
+
* cache, which no member reads.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useOutreachEnrollmentGateway(api: Pick<OutreachApi, 'readEnrollmentGateway'>, enrollmentId: string | null): OutreachLoad<OutreachEnrollmentGatewayResponse | null>;
|
|
@@ -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 — `
|
|
84
|
-
* with
|
|
85
|
-
* `none` blocks; a
|
|
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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CA2C4C,GAE5C,SAEEA,aAAa,EACbC,qBAAqB,EACrBC,gBAAgB,QACX,kCAAiC;AACxC,SAASC,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,iBAAiB,EAAEC,cAAc,QAAQ,qCAAoC;AAEtF,SACEC,wBAAwB,QAEnB,8CAA6C;AACpD,SACEC,0BAA0B,EAC1BC,0BAA0B,QAKrB,6BAAyB;AAChC,SAEEC,yBAAyB,EACzBC,oBAAoB,QACf,oBAAgB;AACvB,SAEEC,mBAAmB,EACnBC,+BAA+B,QAC1B,yBAAqB;AAC5B,SAASC,4BAA4B,QAAQ,2BAAuB;AAEpE;;;;;CAKC,GACD,OAAO,MAAMC,mCAA6D;IACxE;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,wEAAwE,GACxE,OAAO,MAAMC,oCAAyE;IACpF;IACA;CACD,CAAA;AA0ID,wEAAwE,GACxE,MAAMC,qBAAqB;AAE3B;;;CAGC,GACD,OAAO,SAASC,sBACdC,OAAmD,EACnDC,cAAsB,EACtBC,eAAwB;IAExB,IAAIA,iBAAiB,OAAO;IAC5B,MAAM,EAAEC,OAAO,EAAE,GAAGnB,iBAAiBgB,SAASC;IAC9C,OAAO,CAACL,iCAAiCQ,IAAI,CAAC,CAACC,SAAWF,CAAAA,2BAAAA,OAAS,CAACE,OAAO,MAAK;AAClF;AAEA;;;CAGC,GACD,OAAO,SAASC,mBACdN,OAAmD,EACnDC,cAAsB;QAMpBM;IAJF,MAAMA,QAAQvB,iBAAiBgB,SAASC;IACxC,OACEM,MAAMC,cAAc,KAAK,cACzBD,MAAMC,cAAc,KAAK,gBACzBD,EAAAA,iBAAAA,MAAMJ,OAAO,qBAAbI,eAAeE,KAAK,MAAK,QACzBC,OAAOH,MAAMI,WAAW,IAAI;AAEhC;AAEA,uEAAuE,GACvE,OAAO,SAASC,yBACdC,YAAqD,EACrDC,IAA6B;IAE7B,MAAMC,QAAQF,gCAAAA,YAAc,CAACC,KAAK;IAClC,OACE,QAAOC,yBAAAA,MAAOC,GAAG,MAAK,YACtBD,MAAMC,GAAG,CAACC,IAAI,OAAO,MACrB,OAAOF,MAAMG,IAAI,KAAK,YACtBR,OAAOS,QAAQ,CAACJ,MAAMG,IAAI,KAC1BH,MAAMG,IAAI,GAAG;AAEjB;AAEA,+DAA+D,GAC/D,OAAO,SAASE,8BAA8BC,KAAc;IAC1D,OAAO,OAAOA,UAAU,WAAWA,MAAMC,OAAO,CAAC,QAAQ,KAAKL,IAAI,KAAK;AACzE;AAEA,gEAAgE,GAChE,MAAMM,sBAA+D;IACnEC,qBAAqB;IACrBC,oBAAoB;IACpBC,sBAAsB;AACxB;AAEA,SAASC,YAAYC,KAAwB;IAC3C,IAAIA,MAAMC,MAAM,IAAI,GAAG,OAAOD,MAAME,IAAI,CAAC;IACzC,OAAO,GAAGF,MAAMG,KAAK,CAAC,GAAG,CAAC,GAAGD,IAAI,CAAC,MAAM,MAAM,EAAEF,KAAK,CAACA,MAAMC,MAAM,GAAG,EAAE,EAAE;AAC3E;AAEA,MAAMG,kBAAkB;AAExB,yEAAyE,GACzE,OAAO,SAASC,sBAAsBC,KAAwB;QAI5CA;IAHhB,MAAMC,UAAUD,MAAMC,OAAO;IAC7B,MAAMC,WAAWzC,6BAA6BuC,MAAME,QAAQ;IAC5D,MAAMC,QAAQtD,sBAAsBmD,MAAMG,KAAK;IAC/C,MAAMrC,WAAUkC,iBAAAA,MAAMlC,OAAO,YAAbkC,iBAAiB;IACjC,MAAMI,OAAOvC,sBACXC,SACAkC,MAAMjC,cAAc,EACpBkC,CAAAA,2BAAAA,QAASjC,eAAe,MAAK;IAE/B,MAAMqC,SAA8B,EAAE;IACtC,MAAMC,QAAQ,CAACC,MAAwBC,SAAmBH,OAAOI,IAAI,CAAC;YAAEF;YAAMC;QAAO;IACrF,6DAA6D;IAC7D,MAAME,kBAAkB,IAA2B,CAAA;YACjDC,SAAS;YACTR;YACAC;YACAQ,SAAS;gBAAEA,SAAS;gBAAMzC,QAAQ;YAAK;YACvCkC;YACAQ,MAAM;YACNC,qBAAqB,EAAE;QACzB,CAAA;IAEA,IAAI,CAAChD,SAAS;QACZwC,MAAM,mBAAmB;QACzB,OAAOI;IACT;IAEA,wEAAwE;IACxE,IAAI,CAACP,OAAO;YACwBH;QAAlCM,MAAM,iBAAiB,CAAC,CAAC,EAAES,QAAOf,eAAAA,MAAMG,KAAK,YAAXH,eAAe,IAAIjB,IAAI,GAAG,8BAA8B,CAAC;QAC3F,OAAO2B;IACT;IACA,IAAI9C,mBAAmBoD,IAAI,CAACb,MAAMN,KAAK,CAAC,GAAGM,MAAMc,WAAW,CAAC,QAAQ;QACnEX,MAAM,iBAAiB,GAAGH,MAAM,8CAA8C,CAAC;IACjF;IACA,IAAI,CAACvD,cAAckB,SAASoD,QAAQ,CAACf,QAAQ;QAC3CG,MAAM,wBAAwB,GAAGH,MAAM,2CAA2C,CAAC;IACrF;IAEA,4CAA4C;IAC5C,IAAIC,QAAQrD,sBAAsBoD,QAAQ;QACxCG,MACE,aACA,GAAGH,MAAM,sEAAsE,CAAC;IAEpF;IAEA,kBAAkB;IAClB,MAAM9B,QAAQvB,iBAAiBgB,SAASkC,MAAMjC,cAAc;IAC5D,MAAMoD,iBAAiBnB,MAAMoB,OAAO,GAAGpB,MAAMoB,OAAO,CAAC,UAAU,GAAG;IAClE,MAAMR,UAAUpD,gCAAgC;QAC9C2C;QACAkB,gBAAgBhD,MAAMiD,OAAO;QAC7BH;IACF;IACA,MAAMI,aAAa7C,yBAAyBsB,MAAMrB,YAAY,EAAE;IAChE,IAAIiC,QAAQA,OAAO,EAAE;QACnB,MAAMY,OAAOjE,oBAAoBqD,QAAQA,OAAO;QAChD,MAAMa,WACJb,QAAQzC,MAAM,KAAK,WAAW,CAAC,wBAAwB,EAAEqD,MAAM,GAAG,CAAC,QAAQ,EAAEA,MAAM;QACrF,IAAI,CAACtB,SAASwB,gBAAgB,CAACR,QAAQ,CAACN,QAAQA,OAAO,GAAG;YACxDN,MACE,uBACA,GAAGH,MAAM,sCAAsC,EAAEsB,SAAS,mCAAmC,CAAC;QAElG,OAAO,IAAIrB,QAAQQ,QAAQA,OAAO,KAAK,MAAM;YAC3CN,MACE,uBACA,CAAC,sDAAsD,EAAEmB,SAAS,CAAC,CAAC;QAExE;IACF,OAAO,IAAI,CAACvB,SAASwB,gBAAgB,CAACR,QAAQ,CAAC,OAAO;QACpDZ,MACE,mBACA,CAAC,4BAA4B,EAAEH,MAAM,iDAAiD,CAAC;IAE3F,OAAO,IAAI,CAACoB,YAAY;QACtBjB,MACE,mBACA,CAAC,4BAA4B,EAAEH,MAAM,8EAA8E,CAAC;IAExH;IAEA,kEAAkE;IAClE,0EAA0E;IAC1E,0CAA0C;IAC1C,MAAMwB,gBAAgB3E,kBAAkBC,eAAea;IACvD,IAAI6D,eAAerB,MAAM,eAAeqB;IACxC,IAAI1B,CAAAA,2BAAAA,QAAS2B,kBAAkB,MAAK,MAAM;QACxCtB,MACE,uBACA,GAAGH,MAAM,+EAA+E,CAAC;IAE7F,OAAO,IAAIF,CAAAA,2BAAAA,QAAS2B,kBAAkB,MAAK,OAAO;QAChDtB,MACE,uBACA,CAAC,sDAAsD,EAAEH,MAAM,EAAE,EAAEL,iBAAiB;IAExF;IACA,IAAIG,CAAAA,2BAAAA,QAAS4B,cAAc,MAAK,MAAM;QACpCvB,MAAM,mBAAmB,GAAGH,MAAM,4DAA4D,CAAC;IACjG,OAAO,IAAIF,CAAAA,2BAAAA,QAAS4B,cAAc,MAAK,OAAO;QAC5CvB,MACE,mBACA,CAAC,mDAAmD,EAAEH,MAAM,EAAE,EAAEL,iBAAiB;IAErF;IACA,IAAIG,CAAAA,2BAAAA,QAAS6B,eAAe,MAAK,aAAa;QAC5CxB,MAAM,mBAAmB,GAAGH,MAAM,4CAA4C,CAAC;IACjF,OAAO,IAAIF,CAAAA,2BAAAA,QAAS6B,eAAe,MAAK,gBAAgB7B,CAAAA,2BAAAA,QAAS6B,eAAe,MAAK,WAAW;QAC9FxB,MACE,mBACA,CAAC,0BAA0B,EAAEH,MAAM,8BAA8B,EAAEL,iBAAiB;IAExF;IACA,IAAIG,CAAAA,2BAAAA,QAAS8B,YAAY,MAAK,MAAM;QAClCzB,MAAM,kBAAkB,GAAGH,MAAM,+CAA+C,CAAC;IACnF,OAAO,IAAIF,CAAAA,2BAAAA,QAAS8B,YAAY,MAAK,OAAO;QAC1CzB,MACE,kBACA,CAAC,+CAA+C,EAAEH,MAAM,EAAE,EAAEL,iBAAiB;IAEjF;IACA,MAAMkC,SAAS7B,MAAMN,KAAK,CAACM,MAAMc,WAAW,CAAC,OAAO;IACpD,IAAIhB,CAAAA,2BAAAA,QAASgC,kBAAkB,MAAK,MAAM;QACxC3B,MACE,yBACA,GAAGH,MAAM,OAAO,EAAE6B,OAAO,mFAAmF,CAAC;IAEjH,OAAO,IAAI/B,CAAAA,2BAAAA,QAASgC,kBAAkB,MAAK,OAAO;QAChD3B,MACE,yBACA,CAAC,+CAA+C,EAAE0B,OAAO,EAAE,EAAElC,iBAAiB;IAElF;IAEA,yEAAyE;IACzE,iEAAiE;IACjE,MAAMoC,kBAAUjC,2BAAAA,QAASiC,OAAO,mBAAI;IACpC,IAAI,CAACA,SAAS;QACZ5B,MAAM,SAAS,CAAC,wCAAwC,EAAE0B,OAAO,EAAE,EAAElC,iBAAiB;IACxF,OAAO,IAAIoC,QAAQA,OAAO,KAAK,QAAQ;QACrC5B,MAAM,SAAS,GAAG0B,OAAO,sBAAsB,EAAE7B,MAAM,qBAAqB,CAAC;IAC/E;IACA,MAAMU,OACJqB,WAAWlC,MAAMmC,mBAAmB,KAAK,QAAQ7E,qBAAqB4E,WAClE;QACE3B,MAAM;QACN2B,SAASA,QAAQA,OAAO;QACxB1B,QAAQnD,0BAA0B6E,QAAQA,OAAO,EAAEA,QAAQE,SAAS;IACtE,IACA;IAEN,4CAA4C;IAC5C,MAAMC,UAAUpC,2BAAAA,QAASqC,gBAAgB;IACzC,IAAI,CAACC,MAAMC,OAAO,CAACH,UAAU;QAC3B/B,MACE,oBACA,CAAC,0BAA0B,EAAEH,MAAM,wCAAwC,EAAEL,iBAAiB;IAElG,OAAO,IAAI5C,yBAAyBmF,SAASlC,QAAQ;QACnDG,MAAM,oBAAoB,GAAGH,MAAM,uCAAuC,CAAC;IAC7E;IAEA,8DAA8D;IAC9D,IAAI,CAACD,SAASuC,cAAc,IAAIrE,mBAAmBN,SAASkC,MAAMjC,cAAc,GAAG;QACjFuC,MAAM,YAAY;IACpB;IAEA,6BAA6B;IAC7B,MAAMoC,cAAczC,2BAAAA,QAAS0C,eAAe;IAC5C,IAAI,CAACJ,MAAMC,OAAO,CAACE,cAAc;QAC/BpC,MACE,oBACA,CAAC,kDAAkD,EAAER,iBAAiB;IAE1E,OAAO,IACL4C,YAAYxE,IAAI,CACd,CAAC0E,aACCA,CAAAA,8BAAAA,WAAYC,EAAE,MAAK7C,MAAM8C,YAAY,IACrCnF,kCAAkCuD,QAAQ,CAAC0B,8BAAAA,WAAYG,MAAM,IAEjE;QACAzC,MACE,oBACA;IAEJ;IAEA,yDAAyD;IACzD,MAAM0C,eAAe9D,8BAA8Bc,MAAMgD,YAAY;IACrE,MAAMlC,sBAAsBV,OACxBjD,2BAA2B8F,MAAM,CAC/B,CAACrE,OAAS,CAACF,yBAAyBsB,MAAMrB,YAAY,EAAEC,SAE1D,EAAE;IACN,IAAIkC,oBAAoBnB,MAAM,EAAE;QAC9B,MAAMuD,UAAUpC,oBAAoBqC,GAAG,CAAC,CAACvE,OAAS,CAAC,KAAK,EAAES,mBAAmB,CAACT,KAAK,EAAE;QACrF0B,MACE,wBACA,CAAC,wCAAwC,EAAEb,YAAYyD,SAAS,CAAC,CAAC;IAEtE;IACA,IAAI9C,QAAQ,CAAC4C,cAAc;QACzB1C,MACE,yBACA;IAEJ;IACA,IAAI0C,aAAarD,MAAM,GAAGvC,4BAA4B;QACpDkD,MACE,0BACA,CAAC,8CAA8C,EAAElD,2BAA2B,YAAY,CAAC;IAE7F;IAEA,OAAO;QACLuD,SAASN,OAAOV,MAAM,KAAK;QAC3BQ;QACAC;QACAQ;QACAP;QACAQ;QACAC;IACF;AACF"}
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
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 *
|
|
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"}
|