@aglyn/plugins-outreach 1.0.0-beta.159 → 1.0.0-beta.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +4 -0
  2. package/package.json +13 -13
  3. package/src/lib/components/compliance-section.d.ts +3 -2
  4. package/src/lib/components/compliance-section.js +7 -2
  5. package/src/lib/components/compliance-section.js.map +1 -1
  6. package/src/lib/components/do-not-contact-domains.d.ts +29 -0
  7. package/src/lib/components/do-not-contact-domains.js +208 -0
  8. package/src/lib/components/do-not-contact-domains.js.map +1 -0
  9. package/src/lib/components/use-outreach-api.d.ts +5 -1
  10. package/src/lib/components/use-outreach-api.js +12 -0
  11. package/src/lib/components/use-outreach-api.js.map +1 -1
  12. package/src/lib/components/use-outreach-data.d.ts +8 -1
  13. package/src/lib/components/use-outreach-data.js +32 -0
  14. package/src/lib/components/use-outreach-data.js.map +1 -1
  15. package/src/lib/constants/api-routes.d.ts +5 -0
  16. package/src/lib/constants/api-routes.js +4 -0
  17. package/src/lib/constants/api-routes.js.map +1 -1
  18. package/src/lib/engine/do-not-contact-domain.d.ts +39 -0
  19. package/src/lib/engine/do-not-contact-domain.js +51 -0
  20. package/src/lib/engine/do-not-contact-domain.js.map +1 -0
  21. package/src/lib/engine/gates.d.ts +6 -1
  22. package/src/lib/engine/gates.js +6 -0
  23. package/src/lib/engine/gates.js.map +1 -1
  24. package/src/lib/engine/gateway-block.d.ts +31 -0
  25. package/src/lib/engine/gateway-block.js +54 -0
  26. package/src/lib/engine/gateway-block.js.map +1 -0
  27. package/src/lib/engine/index.d.ts +3 -0
  28. package/src/lib/engine/index.js +3 -0
  29. package/src/lib/engine/index.js.map +1 -1
  30. package/src/lib/engine/mailbox-health.d.ts +5 -0
  31. package/src/lib/engine/mailbox-health.js +12 -5
  32. package/src/lib/engine/mailbox-health.js.map +1 -1
  33. package/src/lib/engine/mailbox-notice.d.ts +46 -0
  34. package/src/lib/engine/mailbox-notice.js +64 -0
  35. package/src/lib/engine/mailbox-notice.js.map +1 -0
  36. package/src/lib/enrollment/gate-lookups.js +7 -3
  37. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  38. package/src/lib/model/outreach-api.d.ts +23 -2
  39. package/src/lib/model/outreach-api.js.map +1 -1
  40. package/src/lib/model/outreach.types.d.ts +33 -1
  41. package/src/lib/model/outreach.types.js +11 -1
  42. package/src/lib/model/outreach.types.js.map +1 -1
  43. package/src/lib/routes/do-not-contact-routes.d.ts +35 -0
  44. package/src/lib/routes/do-not-contact-routes.js +92 -0
  45. package/src/lib/routes/do-not-contact-routes.js.map +1 -0
  46. package/src/lib/routes/enroll-routes.js +1 -0
  47. package/src/lib/routes/enroll-routes.js.map +1 -1
  48. package/src/lib/routes/register-routes.js +4 -0
  49. package/src/lib/routes/register-routes.js.map +1 -1
  50. package/src/lib/runtime/mailbox-health-store.d.ts +1 -1
  51. package/src/lib/runtime/mailbox-health-store.js +17 -4
  52. package/src/lib/runtime/mailbox-health-store.js.map +1 -1
  53. package/src/lib/runtime/mailbox-notices.d.ts +39 -0
  54. package/src/lib/runtime/mailbox-notices.js +117 -0
  55. package/src/lib/runtime/mailbox-notices.js.map +1 -0
  56. package/src/lib/runtime/person-erasure.d.ts +4 -0
  57. package/src/lib/runtime/person-erasure.js.map +1 -1
  58. package/src/lib/runtime/platform-runtime-deps.js +25 -0
  59. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  60. package/src/lib/runtime/runtime-deps.d.ts +21 -0
  61. package/src/lib/runtime/runtime-deps.js.map +1 -1
  62. package/src/lib/runtime/send-job.js +6 -6
  63. package/src/lib/runtime/send-job.js.map +1 -1
  64. package/src/lib/runtime/sync-job.d.ts +2 -0
  65. package/src/lib/runtime/sync-job.js +34 -7
  66. package/src/lib/runtime/sync-job.js.map +1 -1
  67. package/src/lib/server.d.ts +1 -1
  68. package/src/lib/server.js +1 -1
  69. package/src/lib/server.js.map +1 -1
  70. package/src/lib/storage/do-not-contact-store.d.ts +28 -1
  71. package/src/lib/storage/do-not-contact-store.js +117 -9
  72. package/src/lib/storage/do-not-contact-store.js.map +1 -1
@@ -106,6 +106,38 @@ export function useOutreachSequenceLinks(orgId, sequenceId) {
106
106
  ]);
107
107
  return result;
108
108
  }
109
+ /**
110
+ * The domains on the organization's do-not-contact list (AGL-3244),
111
+ * alphabetically, live: an add or a remove through the route shows up here
112
+ * without a reload, and so does a domain the sending runtime files after a
113
+ * gateway block.
114
+ */ export function useOutreachDoNotContactDomains(orgId) {
115
+ const firestore = useFirestore();
116
+ const [result, setResult] = useState({
117
+ status: 'loading',
118
+ data: []
119
+ });
120
+ useEffect(()=>{
121
+ setResult({
122
+ status: 'loading',
123
+ data: []
124
+ });
125
+ if (!orgId) return undefined;
126
+ return onSnapshot(collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains), (snapshot)=>{
127
+ const domains = snapshot.docs.map((entry)=>_extends({}, entry.data(), {
128
+ domain: entry.id
129
+ })).sort((a, b)=>a.domain.localeCompare(b.domain));
130
+ setResult({
131
+ status: 'ready',
132
+ data: domains
133
+ });
134
+ }, (error)=>setResult(failed([], error, 'the do-not-contact domains')));
135
+ }, [
136
+ firestore,
137
+ orgId
138
+ ]);
139
+ return result;
140
+ }
109
141
  /** How many enrollments one page of the table reads; "Show more" reads another. */ export const OUTREACH_ENROLLMENTS_PAGE = 200;
110
142
  /**
111
143
  * A sequence's enrollments, the most recently enrolled first.
@@ -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 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 organization's sequences, newest first. */\nexport function useOutreachSequences(\n orgId: string | null,\n): OutreachLoad<OutreachSequence[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence[]>>({\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 limit(OUTREACH_SEQUENCES_LIMIT),\n ),\n (snapshot) => {\n const sequences = snapshot.docs\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({ status: 'ready', data: sequences })\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/** 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","map","entry","id","filter","sequence","sort","a","b","createdAtMs","useOutreachSequence","sequenceId","exists","useOutreachSequenceLinks","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 {\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 organization's sequences, newest first. */\nexport function useOutreachSequences(\n orgId: string | null,\n): OutreachLoad<OutreachSequence[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence[]>>({\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 limit(OUTREACH_SEQUENCES_LIMIT),\n ),\n (snapshot) => {\n const sequences = snapshot.docs\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({ status: 'ready', data: sequences })\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","map","entry","id","filter","sequence","sort","a","b","createdAtMs","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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"}
@@ -61,6 +61,11 @@ 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
+ /**
65
+ * `GET ?orgId` — the domains on the organization's do-not-contact list
66
+ * (AGL-3244); `POST` — add one, or take one off.
67
+ */
68
+ readonly doNotContactDomains: "outreach/do-not-contact/domains";
64
69
  /** `POST` — one email of a saved sequence, for a contact or a sample person. */
65
70
  readonly preview: "outreach/preview";
66
71
  /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */
@@ -47,6 +47,10 @@
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
+ /**
51
+ * `GET ?orgId` — the domains on the organization's do-not-contact list
52
+ * (AGL-3244); `POST` — add one, or take one off.
53
+ */ doNotContactDomains: 'outreach/do-not-contact/domains',
50
54
  /** `POST` — one email of a saved sequence, for a contact or a sample person. */ preview: 'outreach/preview',
51
55
  // The one route a RECIPIENT reaches (AGL-2981): no session, a signed
52
56
  // token instead, and registered as a recipient link so it answers whether
@@ -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 /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\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} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","preview","unsubscribe","click"],"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,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;AACT,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 /**\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 /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\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} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","doNotContactDomains","preview","unsubscribe","click"],"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,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;AACT,EAAU"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A DOMAIN ON THE DO-NOT-CONTACT LIST (AGL-3244).
19
+ *
20
+ * A gateway block — Barracuda, Proofpoint, Mimecast refusing the sender
21
+ * outright — is the recipient organization's verdict on the sender, not on
22
+ * one address, so the next person at that company bounces the same way and
23
+ * counts against the same bounce rule. The list therefore holds domains
24
+ * beside addresses, and a domain is spelled one way: what
25
+ * {@link normalizeOutreachDomain} answers, which is also its document id.
26
+ *
27
+ * Client-safe — no hashing — so the Compliance page validates what a member
28
+ * types with the same function the route stores it by.
29
+ */
30
+ /** RFC 1035's ceiling on a name, which no real domain reaches. */
31
+ export declare const OUTREACH_DOMAIN_MAX = 253;
32
+ /**
33
+ * The domain as the list spells it — lower-cased, a leading `@` or `www.`
34
+ * and any scheme or path dropped — or `null` for anything that is not a
35
+ * domain with at least two labels.
36
+ */
37
+ export declare function normalizeOutreachDomain(value: unknown): string | null;
38
+ /** The domain of an address, as the list spells it, or `null` for a value that is not an address. */
39
+ export declare function outreachEmailDomain(email: unknown): string | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
17
+ /**
18
+ * A DOMAIN ON THE DO-NOT-CONTACT LIST (AGL-3244).
19
+ *
20
+ * A gateway block — Barracuda, Proofpoint, Mimecast refusing the sender
21
+ * outright — is the recipient organization's verdict on the sender, not on
22
+ * one address, so the next person at that company bounces the same way and
23
+ * counts against the same bounce rule. The list therefore holds domains
24
+ * beside addresses, and a domain is spelled one way: what
25
+ * {@link normalizeOutreachDomain} answers, which is also its document id.
26
+ *
27
+ * Client-safe — no hashing — so the Compliance page validates what a member
28
+ * types with the same function the route stores it by.
29
+ */ /** RFC 1035's ceiling on a name, which no real domain reaches. */ export const OUTREACH_DOMAIN_MAX = 253;
30
+ /** A label: letters, digits and hyphens, not starting or ending on a hyphen. */ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
31
+ /**
32
+ * The domain as the list spells it — lower-cased, a leading `@` or `www.`
33
+ * and any scheme or path dropped — or `null` for anything that is not a
34
+ * domain with at least two labels.
35
+ */ export function normalizeOutreachDomain(value) {
36
+ let domain = String(value != null ? value : '').trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/^@/, '').replace(/[/?#].*$/, '').replace(/^www\./, '').replace(/\.+$/, '');
37
+ // An address typed where a domain was meant: its domain is what was meant.
38
+ if (domain.includes('@')) domain = domain.slice(domain.lastIndexOf('@') + 1);
39
+ if (!domain || domain.length > OUTREACH_DOMAIN_MAX) return null;
40
+ const labels = domain.split('.');
41
+ if (labels.length < 2 || !labels.every((label)=>LABEL.test(label))) return null;
42
+ // The last label is a top-level domain, which has no digits in it.
43
+ if (/^\d+$/.test(labels[labels.length - 1])) return null;
44
+ return domain;
45
+ }
46
+ /** The domain of an address, as the list spells it, or `null` for a value that is not an address. */ export function outreachEmailDomain(email) {
47
+ const address = normalizeContactEmail(email);
48
+ return address ? normalizeOutreachDomain(address.slice(address.lastIndexOf('@') + 1)) : null;
49
+ }
50
+
51
+ //# sourceMappingURL=do-not-contact-domain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/do-not-contact-domain.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\n\n/**\n * A DOMAIN ON THE DO-NOT-CONTACT LIST (AGL-3244).\n *\n * A gateway block — Barracuda, Proofpoint, Mimecast refusing the sender\n * outright — is the recipient organization's verdict on the sender, not on\n * one address, so the next person at that company bounces the same way and\n * counts against the same bounce rule. The list therefore holds domains\n * beside addresses, and a domain is spelled one way: what\n * {@link normalizeOutreachDomain} answers, which is also its document id.\n *\n * Client-safe — no hashing — so the Compliance page validates what a member\n * types with the same function the route stores it by.\n */\n\n/** RFC 1035's ceiling on a name, which no real domain reaches. */\nexport const OUTREACH_DOMAIN_MAX = 253\n\n/** A label: letters, digits and hyphens, not starting or ending on a hyphen. */\nconst LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\n\n/**\n * The domain as the list spells it — lower-cased, a leading `@` or `www.`\n * and any scheme or path dropped — or `null` for anything that is not a\n * domain with at least two labels.\n */\nexport function normalizeOutreachDomain(value: unknown): string | null {\n let domain = String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/^[a-z][a-z0-9+.-]*:\\/\\//, '')\n .replace(/^@/, '')\n .replace(/[/?#].*$/, '')\n .replace(/^www\\./, '')\n .replace(/\\.+$/, '')\n // An address typed where a domain was meant: its domain is what was meant.\n if (domain.includes('@')) domain = domain.slice(domain.lastIndexOf('@') + 1)\n if (!domain || domain.length > OUTREACH_DOMAIN_MAX) return null\n const labels = domain.split('.')\n if (labels.length < 2 || !labels.every((label) => LABEL.test(label))) return null\n // The last label is a top-level domain, which has no digits in it.\n if (/^\\d+$/.test(labels[labels.length - 1])) return null\n return domain\n}\n\n/** The domain of an address, as the list spells it, or `null` for a value that is not an address. */\nexport function outreachEmailDomain(email: unknown): string | null {\n const address = normalizeContactEmail(email)\n return address ? normalizeOutreachDomain(address.slice(address.lastIndexOf('@') + 1)) : null\n}\n"],"names":["normalizeContactEmail","OUTREACH_DOMAIN_MAX","LABEL","normalizeOutreachDomain","value","domain","String","trim","toLowerCase","replace","includes","slice","lastIndexOf","length","labels","split","every","label","test","outreachEmailDomain","email","address"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,QAAQ,kCAAiC;AAEvE;;;;;;;;;;;;CAYC,GAED,gEAAgE,GAChE,OAAO,MAAMC,sBAAsB,IAAG;AAEtC,8EAA8E,GAC9E,MAAMC,QAAQ;AAEd;;;;CAIC,GACD,OAAO,SAASC,wBAAwBC,KAAc;IACpD,IAAIC,SAASC,OAAOF,gBAAAA,QAAS,IAC1BG,IAAI,GACJC,WAAW,GACXC,OAAO,CAAC,2BAA2B,IACnCA,OAAO,CAAC,MAAM,IACdA,OAAO,CAAC,YAAY,IACpBA,OAAO,CAAC,UAAU,IAClBA,OAAO,CAAC,QAAQ;IACnB,2EAA2E;IAC3E,IAAIJ,OAAOK,QAAQ,CAAC,MAAML,SAASA,OAAOM,KAAK,CAACN,OAAOO,WAAW,CAAC,OAAO;IAC1E,IAAI,CAACP,UAAUA,OAAOQ,MAAM,GAAGZ,qBAAqB,OAAO;IAC3D,MAAMa,SAAST,OAAOU,KAAK,CAAC;IAC5B,IAAID,OAAOD,MAAM,GAAG,KAAK,CAACC,OAAOE,KAAK,CAAC,CAACC,QAAUf,MAAMgB,IAAI,CAACD,SAAS,OAAO;IAC7E,mEAAmE;IACnE,IAAI,QAAQC,IAAI,CAACJ,MAAM,CAACA,OAAOD,MAAM,GAAG,EAAE,GAAG,OAAO;IACpD,OAAOR;AACT;AAEA,mGAAmG,GACnG,OAAO,SAASc,oBAAoBC,KAAc;IAChD,MAAMC,UAAUrB,sBAAsBoB;IACtC,OAAOC,UAAUlB,wBAAwBkB,QAAQV,KAAK,CAACU,QAAQT,WAAW,CAAC,OAAO,MAAM;AAC1F"}
@@ -50,6 +50,11 @@ export interface OutreachGateLookups {
50
50
  salesTopicState: TopicSubscriptionState | null;
51
51
  /** The address is on the organization's Outreach do-not-contact list. */
52
52
  doNotContact: boolean | null;
53
+ /**
54
+ * The address's DOMAIN is on that list (AGL-3244): a member put it there,
55
+ * or the domain's mail gateway blocked the sender outright.
56
+ */
57
+ doNotContactDomain: boolean | null;
53
58
  /**
54
59
  * The organization's roster: each member's sign-in address and confirmed
55
60
  * aliases, as `crmInboundRoster` builds it.
@@ -90,7 +95,7 @@ export interface OutreachGateInput {
90
95
  enrollmentId?: string | null;
91
96
  lookups: OutreachGateLookups;
92
97
  }
93
- export type OutreachGateCode = 'contact_missing' | 'invalid_email' | 'email_not_on_contact' | 'free_mail' | 'country_not_allowed' | 'country_unknown' | 'platform_suppressed' | 'host_suppressed' | 'sales_opted_out' | 'do_not_contact' | 'workspace_member' | 'customer' | 'already_enrolled' | 'attestations_missing' | 'personal_line_missing' | 'personal_line_too_long';
98
+ export type OutreachGateCode = 'contact_missing' | 'invalid_email' | 'email_not_on_contact' | 'free_mail' | 'country_not_allowed' | 'country_unknown' | 'platform_suppressed' | 'host_suppressed' | 'sales_opted_out' | 'do_not_contact' | 'do_not_contact_domain' | 'workspace_member' | 'customer' | 'already_enrolled' | 'attestations_missing' | 'personal_line_missing' | 'personal_line_too_long';
94
99
  export interface OutreachGateBlock {
95
100
  code: OutreachGateCode;
96
101
  /** A sentence a rep can act on. */
@@ -188,6 +188,12 @@ const COULD_NOT_CHECK = 'Try again in a moment.';
188
188
  } else if ((lookups == null ? void 0 : lookups.doNotContact) !== false) {
189
189
  block('do_not_contact', `We couldn't check your do-not-contact list for ${email}. ${COULD_NOT_CHECK}`);
190
190
  }
191
+ const domain = email.slice(email.lastIndexOf('@') + 1);
192
+ if ((lookups == null ? void 0 : lookups.doNotContactDomain) === true) {
193
+ block('do_not_contact_domain', `${email} is at ${domain}, which is on your organization's do-not-contact list: no address there is emailed.`);
194
+ } else if ((lookups == null ? void 0 : lookups.doNotContactDomain) !== false) {
195
+ block('do_not_contact_domain', `We couldn't check your do-not-contact list for ${domain}. ${COULD_NOT_CHECK}`);
196
+ }
191
197
  // 5. Not one of the workspace's own people.
192
198
  const members = lookups == null ? void 0 : lookups.workspaceMembers;
193
199
  if (!Array.isArray(members)) {
@@ -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\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 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 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}`. Only `opted-out` refuses: a pending\n * confirmation is about a subscription, and a sales email does not rest on\n * 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 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\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 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 | 'sales_opted_out'\n | 'do_not_contact'\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\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 /** 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 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.\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\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 missingAttestations,\n }\n}\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","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","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gates.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHO MAY BE EMAILED (AGL-2979).\n *\n * One pure decision, run twice: when a rep enrolls a person, so the dialog\n * can say everything that stands in the way at once, and again before every\n * send, because a person enrolled on Monday can unsubscribe, become a\n * customer or join the team by Thursday. It reads a contact as stored and\n * the RESULTS of the lookups the runtime makes; it makes none itself.\n *\n * ## Every block, not the first\n *\n * The result lists each gate that refuses, in the order below, with a\n * sentence a rep can act on. A dialog that revealed one problem per attempt\n * would be a dialog people learn to fight.\n *\n * ## Cold\n *\n * A contact is COLD when no inbound door ever brought them to the sending\n * site's business — no form, sign-up, order or booking recorded on that\n * site's facet of the contact, and no email they wrote to the workspace.\n * Cold contacts carry the heavier rules: a business address rather than a\n * personal mailbox, a United States address, a personal line from the rep,\n * and the three attestations only the rep can make. The facet is read, never\n * the whole document, because a form filled in on another client's site is\n * that client's relationship and not this one's.\n *\n * ## Lookups fail closed\n *\n * A lookup the runtime could not complete is passed as `null`, and `null`\n * refuses — with a sentence that says the check could not be made rather\n * than one that accuses the address of something. A person who asked not to\n * be emailed must not be emailed because a read timed out.\n *==========================================*/\n\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 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 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}`. Only `opted-out` refuses: a pending\n * confirmation is about a subscription, and a sales email does not rest on\n * 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\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 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 | 'sales_opted_out'\n | 'do_not_contact'\n | 'do_not_contact_domain'\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\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 /** 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 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.\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 // 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 missingAttestations,\n }\n}\n"],"names":["contactEmails","normalizeContactEmail","readContactFacet","isPublicMailboxDomain","findMemberByEmailAddress","OUTREACH_ATTESTATION_KINDS","OUTREACH_PERSONAL_LINE_MAX","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","missingAttestations","String","test","lastIndexOf","includes","companyAddress","company","contactAddress","address","attestedUs","name","evidence","allowedCountries","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","domain","doNotContactDomain","members","workspaceMembers","Array","isArray","allowCustomers","enrollments","openEnrollments","enrollment","id","enrollmentId","status","personalLine","filter","phrases","map"],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** What a bounce says, as the classifier hands it over. */
18
+ export interface OutreachBounceReading {
19
+ /** The enhanced status code, `5.7.1`, when the report carried one. */
20
+ status: string | null;
21
+ /** The server's diagnostic line, when it gave one. */
22
+ diagnostic: string | null;
23
+ }
24
+ /** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */
25
+ export declare function isOutreachGatewayBlock(bounce: OutreachBounceReading | null | undefined): boolean;
26
+ /**
27
+ * What the enrollment's stop detail says when its bounce was a gateway
28
+ * block: that the domain was blocked and is on the list, then the
29
+ * diagnostic — already scrubbed of the address — for the record.
30
+ */
31
+ export declare function outreachGatewayBlockDetail(domain: string, diagnostic: string | null): string;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244).
18
+ *
19
+ * Two hard bounces look alike to the delivery report — `failed`, a `5.x.x`
20
+ * status — and mean different things. `550 5.1.1 no such user` is a verdict
21
+ * on ONE ADDRESS: the person left, or the address was never right. `550
22
+ * 5.7.1 ... :blocked` from a Barracuda, a Proofpoint or a Mimecast is a
23
+ * verdict on THE SENDER by the whole recipient organization: its gateway
24
+ * refused the mail on policy or reputation, and the next address at that
25
+ * company will bounce the same way and count against the same bounce rule.
26
+ *
27
+ * So a hard bounce is read once more, for which of the two it is. The words
28
+ * that name a gateway or a policy make it a block, and so does a bare
29
+ * `5.7.0` / `5.7.1` (RFC 3463's "delivery not authorized" and "message
30
+ * refused"); any wording that says the ADDRESS is the problem — user
31
+ * unknown, mailbox not found, a `5.1.x` or `5.2.x` status — outranks them,
32
+ * because a server that names the address is talking about the address.
33
+ * The tie goes to the address: only the block is filed against the domain.
34
+ *==========================================*/ /** What a bounce says, as the classifier hands it over. */ /** The words a gateway or a policy refusal is written in. */ const GATEWAY_WORDS = /barracuda|proofpoint|mimecast|:blocked\b|blocked using|\bpolicy\b|spamhaus|reputation|\bblock ?list|\bblacklist|\bdenylist|\bbanned\b|\brbl\b|\bdnsbl\b/i;
35
+ /** The words, and the status classes, that say one address is the problem. */ const ADDRESS_WORDS = /no such (user|mailbox|recipient|address|person)|unknown (user|recipient|address|mailbox|account)|(user|mailbox|recipient|address|account|email)\b[^.;\n]{0,40}\b(unknown|not found|does ?n[o']?t exist|doesn't exist|invalid|disabled|unavailable|inactive)|recipient ?not ?found|does not exist|doesn't exist|invalid (recipient|mailbox|address)|mailbox (unavailable|full|disabled)|address rejected|\b5\.[12]\.\d{1,3}\b/i;
36
+ /** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */ export function isOutreachGatewayBlock(bounce) {
37
+ var _bounce_status, _bounce_diagnostic;
38
+ if (!bounce) return false;
39
+ const text = `${(_bounce_status = bounce.status) != null ? _bounce_status : ''} ${(_bounce_diagnostic = bounce.diagnostic) != null ? _bounce_diagnostic : ''}`.trim();
40
+ if (!text) return false;
41
+ if (ADDRESS_WORDS.test(text)) return false;
42
+ if (GATEWAY_WORDS.test(text)) return true;
43
+ return /\b5\.7\.[01]\b/.test(text);
44
+ }
45
+ /**
46
+ * What the enrollment's stop detail says when its bounce was a gateway
47
+ * block: that the domain was blocked and is on the list, then the
48
+ * diagnostic — already scrubbed of the address — for the record.
49
+ */ export function outreachGatewayBlockDetail(domain, diagnostic) {
50
+ const said = String(diagnostic != null ? diagnostic : '').trim();
51
+ return `The mail gateway at ${domain} blocked the email, so ${domain} is on your organization's ` + `do-not-contact list and no address there will be emailed.${said ? ` The server said: ${said}` : ''}`;
52
+ }
53
+
54
+ //# sourceMappingURL=gateway-block.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/gateway-block.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244).\n *\n * Two hard bounces look alike to the delivery report — `failed`, a `5.x.x`\n * status — and mean different things. `550 5.1.1 no such user` is a verdict\n * on ONE ADDRESS: the person left, or the address was never right. `550\n * 5.7.1 ... :blocked` from a Barracuda, a Proofpoint or a Mimecast is a\n * verdict on THE SENDER by the whole recipient organization: its gateway\n * refused the mail on policy or reputation, and the next address at that\n * company will bounce the same way and count against the same bounce rule.\n *\n * So a hard bounce is read once more, for which of the two it is. The words\n * that name a gateway or a policy make it a block, and so does a bare\n * `5.7.0` / `5.7.1` (RFC 3463's \"delivery not authorized\" and \"message\n * refused\"); any wording that says the ADDRESS is the problem — user\n * unknown, mailbox not found, a `5.1.x` or `5.2.x` status — outranks them,\n * because a server that names the address is talking about the address.\n * The tie goes to the address: only the block is filed against the domain.\n *==========================================*/\n\n/** What a bounce says, as the classifier hands it over. */\nexport interface OutreachBounceReading {\n /** The enhanced status code, `5.7.1`, when the report carried one. */\n status: string | null\n /** The server's diagnostic line, when it gave one. */\n diagnostic: string | null\n}\n\n/** The words a gateway or a policy refusal is written in. */\nconst GATEWAY_WORDS =\n /barracuda|proofpoint|mimecast|:blocked\\b|blocked using|\\bpolicy\\b|spamhaus|reputation|\\bblock ?list|\\bblacklist|\\bdenylist|\\bbanned\\b|\\brbl\\b|\\bdnsbl\\b/i\n\n/** The words, and the status classes, that say one address is the problem. */\nconst ADDRESS_WORDS =\n /no such (user|mailbox|recipient|address|person)|unknown (user|recipient|address|mailbox|account)|(user|mailbox|recipient|address|account|email)\\b[^.;\\n]{0,40}\\b(unknown|not found|does ?n[o']?t exist|doesn't exist|invalid|disabled|unavailable|inactive)|recipient ?not ?found|does not exist|doesn't exist|invalid (recipient|mailbox|address)|mailbox (unavailable|full|disabled)|address rejected|\\b5\\.[12]\\.\\d{1,3}\\b/i\n\n/** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */\nexport function isOutreachGatewayBlock(bounce: OutreachBounceReading | null | undefined): boolean {\n if (!bounce) return false\n const text = `${bounce.status ?? ''} ${bounce.diagnostic ?? ''}`.trim()\n if (!text) return false\n if (ADDRESS_WORDS.test(text)) return false\n if (GATEWAY_WORDS.test(text)) return true\n return /\\b5\\.7\\.[01]\\b/.test(text)\n}\n\n/**\n * What the enrollment's stop detail says when its bounce was a gateway\n * block: that the domain was blocked and is on the list, then the\n * diagnostic — already scrubbed of the address — for the record.\n */\nexport function outreachGatewayBlockDetail(domain: string, diagnostic: string | null): string {\n const said = String(diagnostic ?? '').trim()\n return (\n `The mail gateway at ${domain} blocked the email, so ${domain} is on your organization's ` +\n `do-not-contact list and no address there will be emailed.${said ? ` The server said: ${said}` : ''}`\n )\n}\n"],"names":["GATEWAY_WORDS","ADDRESS_WORDS","isOutreachGatewayBlock","bounce","text","status","diagnostic","trim","test","outreachGatewayBlockDetail","domain","said","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;4CAkB4C,GAE5C,yDAAyD,GAQzD,2DAA2D,GAC3D,MAAMA,gBACJ;AAEF,4EAA4E,GAC5E,MAAMC,gBACJ;AAEF,mGAAmG,GACnG,OAAO,SAASC,uBAAuBC,MAAgD;QAErEA,gBAAuBA;IADvC,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMC,OAAO,IAAGD,iBAAAA,OAAOE,MAAM,YAAbF,iBAAiB,GAAG,CAAC,GAAEA,qBAAAA,OAAOG,UAAU,YAAjBH,qBAAqB,IAAI,CAACI,IAAI;IACrE,IAAI,CAACH,MAAM,OAAO;IAClB,IAAIH,cAAcO,IAAI,CAACJ,OAAO,OAAO;IACrC,IAAIJ,cAAcQ,IAAI,CAACJ,OAAO,OAAO;IACrC,OAAO,iBAAiBI,IAAI,CAACJ;AAC/B;AAEA;;;;CAIC,GACD,OAAO,SAASK,2BAA2BC,MAAc,EAAEJ,UAAyB;IAClF,MAAMK,OAAOC,OAAON,qBAAAA,aAAc,IAAIC,IAAI;IAC1C,OACE,CAAC,oBAAoB,EAAEG,OAAO,uBAAuB,EAAEA,OAAO,2BAA2B,CAAC,GAC1F,CAAC,yDAAyD,EAAEC,OAAO,CAAC,kBAAkB,EAAEA,MAAM,GAAG,IAAI;AAEzG"}
@@ -26,3 +26,6 @@ export * from './delivery-status';
26
26
  export * from './opt-out-intent';
27
27
  export * from './thread-classification';
28
28
  export * from './mailbox-health';
29
+ export * from './mailbox-notice';
30
+ export * from './gateway-block';
31
+ export * from './do-not-contact-domain';
@@ -29,5 +29,8 @@ export * from "./delivery-status.js";
29
29
  export * from "./opt-out-intent.js";
30
30
  export * from "./thread-classification.js";
31
31
  export * from "./mailbox-health.js";
32
+ export * from "./mailbox-notice.js";
33
+ export * from "./gateway-block.js";
34
+ export * from "./do-not-contact-domain.js";
32
35
 
33
36
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The Outreach engine (AGL-2979): pure decisions, no I/O, safe for the\n * console and the server alike. `./do-not-contact` is deliberately absent —\n * it hashes with `node:crypto` — and is imported by its own path.\n */\nexport * from './sequence-validation'\nexport * from './recipient-country'\nexport * from './gates'\nexport * from './schedule'\nexport * from './sending-capacity'\nexport * from './enrollment-state'\nexport * from './compose'\nexport * from './thread-message'\nexport * from './delivery-status'\nexport * from './opt-out-intent'\nexport * from './thread-classification'\nexport * from './mailbox-health'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,2BAAuB;AACrC,cAAc,yBAAqB;AACnC,cAAc,aAAS;AACvB,cAAc,gBAAY;AAC1B,cAAc,wBAAoB;AAClC,cAAc,wBAAoB;AAClC,cAAc,eAAW;AACzB,cAAc,sBAAkB;AAChC,cAAc,uBAAmB;AACjC,cAAc,sBAAkB;AAChC,cAAc,6BAAyB;AACvC,cAAc,sBAAkB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The Outreach engine (AGL-2979): pure decisions, no I/O, safe for the\n * console and the server alike. `./do-not-contact` is deliberately absent —\n * it hashes with `node:crypto` — and is imported by its own path.\n */\nexport * from './sequence-validation'\nexport * from './recipient-country'\nexport * from './gates'\nexport * from './schedule'\nexport * from './sending-capacity'\nexport * from './enrollment-state'\nexport * from './compose'\nexport * from './thread-message'\nexport * from './delivery-status'\nexport * from './opt-out-intent'\nexport * from './thread-classification'\nexport * from './mailbox-health'\nexport * from './mailbox-notice'\nexport * from './gateway-block'\nexport * from './do-not-contact-domain'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;CAIC,GACD,cAAc,2BAAuB;AACrC,cAAc,yBAAqB;AACnC,cAAc,aAAS;AACvB,cAAc,gBAAY;AAC1B,cAAc,wBAAoB;AAClC,cAAc,wBAAoB;AAClC,cAAc,eAAW;AACzB,cAAc,sBAAkB;AAChC,cAAc,uBAAmB;AACjC,cAAc,sBAAkB;AAChC,cAAc,6BAAyB;AACvC,cAAc,sBAAkB;AAChC,cAAc,sBAAkB;AAChC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB"}
@@ -20,6 +20,11 @@ export declare const OUTREACH_PAUSE_BOUNCES_PER_DAY = 2;
20
20
  export declare const OUTREACH_BOUNCE_RATE_WINDOW_SENDS = 50;
21
21
  /** The bounce rate a mailbox may not exceed over that window. */
22
22
  export declare const OUTREACH_PAUSE_BOUNCE_RATE = 0.03;
23
+ /**
24
+ * The fewest sends the window must hold before its bounce rate is judged
25
+ * (AGL-3244). Below it the daily rule alone pauses the mailbox.
26
+ */
27
+ export declare const OUTREACH_BOUNCE_RATE_MIN_SENDS = 25;
23
28
  /** How long a spam complaint keeps a mailbox paused. */
24
29
  export declare const OUTREACH_COMPLAINT_PAUSE_MS: number;
25
30
  export type OutreachHealthPauseReason = 'bounces_today' | 'bounce_rate' | 'complaint';