@aglyn/plugins-outreach 1.0.0-beta.195 → 1.0.0-beta.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/curate-step-dialog.d.ts +50 -0
  3. package/src/lib/components/curate-step-dialog.js +235 -0
  4. package/src/lib/components/curate-step-dialog.js.map +1 -0
  5. package/src/lib/components/curated-step-editor.d.ts +80 -0
  6. package/src/lib/components/curated-step-editor.js +255 -0
  7. package/src/lib/components/curated-step-editor.js.map +1 -0
  8. package/src/lib/components/enroll-dialog.d.ts +15 -0
  9. package/src/lib/components/enroll-dialog.js +214 -14
  10. package/src/lib/components/enroll-dialog.js.map +1 -1
  11. package/src/lib/components/enrollments-table.d.ts +2 -0
  12. package/src/lib/components/enrollments-table.js +62 -8
  13. package/src/lib/components/enrollments-table.js.map +1 -1
  14. package/src/lib/components/mailbox-card.js +11 -2
  15. package/src/lib/components/mailbox-card.js.map +1 -1
  16. package/src/lib/components/sequence-detail.js +1 -0
  17. package/src/lib/components/sequence-detail.js.map +1 -1
  18. package/src/lib/components/sequence-editor.d.ts +2 -0
  19. package/src/lib/components/sequence-editor.js +26 -2
  20. package/src/lib/components/sequence-editor.js.map +1 -1
  21. package/src/lib/components/sequence-step-card.d.ts +7 -0
  22. package/src/lib/components/sequence-step-card.js +37 -14
  23. package/src/lib/components/sequence-step-card.js.map +1 -1
  24. package/src/lib/components/sequences-section.js +12 -3
  25. package/src/lib/components/sequences-section.js.map +1 -1
  26. package/src/lib/components/step-test-dialog.d.ts +31 -0
  27. package/src/lib/components/step-test-dialog.js +247 -0
  28. package/src/lib/components/step-test-dialog.js.map +1 -0
  29. package/src/lib/components/use-outreach-api.d.ts +15 -1
  30. package/src/lib/components/use-outreach-api.js +16 -0
  31. package/src/lib/components/use-outreach-api.js.map +1 -1
  32. package/src/lib/components/use-outreach-data.d.ts +6 -1
  33. package/src/lib/components/use-outreach-data.js +6 -3
  34. package/src/lib/components/use-outreach-data.js.map +1 -1
  35. package/src/lib/constants/api-routes.d.ts +15 -0
  36. package/src/lib/constants/api-routes.js +12 -0
  37. package/src/lib/constants/api-routes.js.map +1 -1
  38. package/src/lib/engine/compose.d.ts +12 -2
  39. package/src/lib/engine/compose.js +32 -6
  40. package/src/lib/engine/compose.js.map +1 -1
  41. package/src/lib/engine/curation-prompt.d.ts +73 -0
  42. package/src/lib/engine/curation-prompt.js +151 -0
  43. package/src/lib/engine/curation-prompt.js.map +1 -0
  44. package/src/lib/engine/enrollment-activity.d.ts +24 -0
  45. package/src/lib/engine/enrollment-activity.js +25 -0
  46. package/src/lib/engine/enrollment-activity.js.map +1 -1
  47. package/src/lib/engine/sequence-validation.d.ts +10 -1
  48. package/src/lib/engine/sequence-validation.js +54 -0
  49. package/src/lib/engine/sequence-validation.js.map +1 -1
  50. package/src/lib/enrollment/step-overrides.d.ts +53 -0
  51. package/src/lib/enrollment/step-overrides.js +140 -0
  52. package/src/lib/enrollment/step-overrides.js.map +1 -0
  53. package/src/lib/model/outreach-api.d.ts +89 -2
  54. package/src/lib/model/outreach-api.js.map +1 -1
  55. package/src/lib/model/outreach.types.d.ts +42 -0
  56. package/src/lib/model/outreach.types.js +18 -0
  57. package/src/lib/model/outreach.types.js.map +1 -1
  58. package/src/lib/model/step-preview.d.ts +10 -2
  59. package/src/lib/model/step-preview.js +9 -5
  60. package/src/lib/model/step-preview.js.map +1 -1
  61. package/src/lib/model/stored-records.d.ts +9 -1
  62. package/src/lib/model/stored-records.js +47 -2
  63. package/src/lib/model/stored-records.js.map +1 -1
  64. package/src/lib/routes/curate-routes.d.ts +63 -0
  65. package/src/lib/routes/curate-routes.js +424 -0
  66. package/src/lib/routes/curate-routes.js.map +1 -0
  67. package/src/lib/routes/enroll-routes.d.ts +7 -0
  68. package/src/lib/routes/enroll-routes.js +58 -4
  69. package/src/lib/routes/enroll-routes.js.map +1 -1
  70. package/src/lib/routes/platform-deps.d.ts +2 -2
  71. package/src/lib/routes/platform-deps.js +2 -2
  72. package/src/lib/routes/platform-deps.js.map +1 -1
  73. package/src/lib/routes/preview-routes.d.ts +24 -0
  74. package/src/lib/routes/preview-routes.js +103 -85
  75. package/src/lib/routes/preview-routes.js.map +1 -1
  76. package/src/lib/routes/register-routes.d.ts +7 -0
  77. package/src/lib/routes/register-routes.js +41 -0
  78. package/src/lib/routes/register-routes.js.map +1 -1
  79. package/src/lib/routes/step-test-routes.d.ts +88 -0
  80. package/src/lib/routes/step-test-routes.js +275 -0
  81. package/src/lib/routes/step-test-routes.js.map +1 -0
  82. package/src/lib/runtime/click-events.js +10 -0
  83. package/src/lib/runtime/click-events.js.map +1 -1
  84. package/src/lib/runtime/click-link.d.ts +11 -0
  85. package/src/lib/runtime/click-link.js +9 -4
  86. package/src/lib/runtime/click-link.js.map +1 -1
  87. package/src/lib/runtime/click-route.js +3 -0
  88. package/src/lib/runtime/click-route.js.map +1 -1
  89. package/src/lib/runtime/send-job.js +2 -0
  90. package/src/lib/runtime/send-job.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-data.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { CampaignLinkRollup } from '@aglyn/shared-ui-email-campaigns/model'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n doc,\n documentId,\n getCountFromServer,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachDoNotContactDomainEntry,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport {\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../model/stored-records'\n\n/**\n * The Outreach documents the console reads LIVE (AGL-2980): sequences,\n * enrollments, and the mailboxes they send from.\n *\n * Read straight from Firestore — the rules let an org-wide member holding\n * `outreach.use` on an entitled organization read them, and every write goes\n * through a route — so what a page shows is what the routes and the sending\n * runtime wrote, the moment it lands.\n *\n * Every hook answers one of four states, and a page renders each: still\n * loading, ready, failed, or REFUSED — the rules said no, which is a reader\n * who may not see Outreach here rather than something that went wrong.\n */\n\nexport type OutreachLoadStatus = 'loading' | 'ready' | 'error' | 'refused'\n\nexport interface OutreachLoad<T> {\n status: OutreachLoadStatus\n data: T\n}\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\nfunction failed<T>(data: T, error: unknown, what: string): OutreachLoad<T> {\n if (denied(error)) return { status: 'refused', data }\n console.error(`[outreach] ${what} could not be read`, error)\n return { status: 'error', data }\n}\n\n/** The most sequences the list reads. */\nexport const OUTREACH_SEQUENCES_LIMIT = 200\n\n/** The 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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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/use-outreach-data.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { CampaignLinkRollup } from '@aglyn/shared-ui-email-campaigns/model'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport {\n collection,\n doc,\n documentId,\n getCountFromServer,\n limit,\n onSnapshot,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n OUTREACH_COLLECTIONS,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachDoNotContactDomainEntry,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachSequence,\n} from '../model/outreach.types'\nimport {\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../model/stored-records'\n\n/**\n * The Outreach documents the console reads LIVE (AGL-2980): sequences,\n * enrollments, and the mailboxes they send from.\n *\n * Read straight from Firestore — the rules let an org-wide member holding\n * `outreach.use` on an entitled organization read them, and every write goes\n * through a route — so what a page shows is what the routes and the sending\n * runtime wrote, the moment it lands.\n *\n * Every hook answers one of four states, and a page renders each: still\n * loading, ready, failed, or REFUSED — the rules said no, which is a reader\n * who may not see Outreach here rather than something that went wrong.\n */\n\nexport type OutreachLoadStatus = 'loading' | 'ready' | 'error' | 'refused'\n\nexport interface OutreachLoad<T> {\n status: OutreachLoadStatus\n data: T\n}\n\nconst denied = (error: unknown) =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\nfunction failed<T>(data: T, error: unknown, what: string): OutreachLoad<T> {\n if (denied(error)) return { status: 'refused', data }\n console.error(`[outreach] ${what} could not be read`, error)\n return { status: 'error', data }\n}\n\n/** The most sequences the list reads. */\nexport const OUTREACH_SEQUENCES_LIMIT = 200\n\n/** The sequences read, and whether the organization holds older ones. */\nexport interface OutreachSequencesLoad extends OutreachLoad<OutreachSequence[]> {\n /** True when there are sequences older than the `OUTREACH_SEQUENCES_LIMIT` read. */\n truncated?: boolean\n}\n\n/** The organization's sequences, newest first. */\nexport function useOutreachSequences(\n orgId: string | null,\n): OutreachSequencesLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequencesLoad>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences),\n // Ordered, so the cap keeps the newest rather than an arbitrary set;\n // every sequence carries `createdAtMs`, stamped by the save route.\n orderBy('createdAtMs', 'desc'),\n // One past the cap: the extra document says there are older\n // sequences, and is never listed.\n limit(OUTREACH_SEQUENCES_LIMIT + 1),\n ),\n (snapshot) => {\n const sequences = snapshot.docs\n .slice(0, OUTREACH_SEQUENCES_LIMIT)\n .map((entry) => readStoredOutreachSequence(entry.id, entry.data()))\n .filter((sequence): sequence is OutreachSequence => sequence !== null)\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n setResult({\n status: 'ready',\n data: sequences,\n truncated: snapshot.docs.length > OUTREACH_SEQUENCES_LIMIT,\n })\n },\n (error) => setResult(failed([], error, 'sequences')),\n )\n }, [firestore, orgId])\n return result\n}\n\n/** One sequence, or `null` once it is known not to exist. */\nexport function useOutreachSequence(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachLoad<OutreachSequence | null> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachSequence | null>>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.sequences, sequenceId),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: readStoredOutreachSequence(\n snapshot.id,\n snapshot.exists() ? snapshot.data() : undefined,\n ),\n }),\n (error) => setResult(failed(null, error, 'the sequence')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * A sequence's per-destination click rollup (AGL-3239), or `null` until a\n * click has been counted.\n *\n * One document, whatever the sequence's size: the aggregate exists so the\n * table is not a read of every enrollment. A sequence that tracks nothing\n * never has one, and an absent document is not an error.\n */\nexport type OutreachSequenceLinksLoad = OutreachLoad<CampaignLinkRollup | null>\n\nexport function useOutreachSequenceLinks(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachSequenceLinksLoad {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachSequenceLinksLoad>({\n status: 'loading',\n data: null,\n })\n useEffect(() => {\n setResult({ status: 'loading', data: null })\n if (!orgId || !sequenceId) return undefined\n return onSnapshot(\n doc(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.sequences,\n sequenceId,\n OUTREACH_LINK_ROLLUP_PATH[0],\n OUTREACH_LINK_ROLLUP_PATH[1],\n ),\n (snapshot) =>\n setResult({\n status: 'ready',\n data: snapshot.exists() ? (snapshot.data() as CampaignLinkRollup) : null,\n }),\n (error) => setResult(failed(null, error, 'the link report')),\n )\n }, [firestore, orgId, sequenceId])\n return result\n}\n\n/**\n * The domains on the organization's do-not-contact list (AGL-3244),\n * alphabetically, live: an add or a remove through the route shows up here\n * without a reload, and so does a domain the sending runtime files after a\n * gateway block.\n */\nexport function useOutreachDoNotContactDomains(\n orgId: string | null,\n): OutreachLoad<OutreachDoNotContactDomainEntry[]> {\n const firestore = useFirestore()\n const [result, setResult] = useState<OutreachLoad<OutreachDoNotContactDomainEntry[]>>({\n status: 'loading',\n data: [],\n })\n useEffect(() => {\n setResult({ status: 'loading', data: [] })\n if (!orgId) return undefined\n return onSnapshot(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.doNotContactDomains),\n (snapshot) => {\n const domains = snapshot.docs\n .map((entry) => ({ ...(entry.data() as OutreachDoNotContactDomainEntry), domain: entry.id }))\n .sort((a, b) => a.domain.localeCompare(b.domain))\n setResult({ status: 'ready', data: domains })\n },\n (error) => setResult(failed([], error, 'the do-not-contact domains')),\n )\n }, [firestore, orgId])\n return result\n}\n\n/** How many enrollments one page of the table reads; \"Show more\" reads another. */\nexport const OUTREACH_ENROLLMENTS_PAGE = 200\n\nexport interface OutreachEnrollmentsLoad extends OutreachLoad<\n OutreachEnrollment[]\n> {\n /** Whether a further page may exist. */\n hasMore: boolean\n showMore(): void\n}\n\n/**\n * A sequence's enrollments, the most recently enrolled first.\n *\n * Paged by document id — an equality on `sequenceId` in the id's own order\n * needs no composite index — and sorted by enrollment time once read, so a\n * table of up to a page reads in the order people were enrolled.\n */\nexport function useOutreachEnrollments(\n orgId: string | null,\n sequenceId: string | null,\n): OutreachEnrollmentsLoad {\n const firestore = useFirestore()\n const [pages, setPages] = useState(1)\n const [result, setResult] = useState<\n OutreachLoad<OutreachEnrollment[]> & { size: number }\n >({\n status: 'loading',\n data: [],\n size: 0,\n })\n useEffect(() => setPages(1), [orgId, sequenceId])\n useEffect(() => {\n if (!orgId || !sequenceId) {\n setResult({ status: 'loading', data: [], size: 0 })\n return undefined\n }\n return onSnapshot(\n query(\n collection(firestore, 'orgs', orgId, OUTREACH_COLLECTIONS.enrollments),\n where('sequenceId', '==', sequenceId),\n orderBy(documentId()),\n limit(pages * OUTREACH_ENROLLMENTS_PAGE),\n ),\n (snapshot) => {\n const enrollments = snapshot.docs\n .map((entry) => readStoredOutreachEnrollment(entry.id, entry.data()))\n .filter(\n (enrollment): enrollment is OutreachEnrollment =>\n enrollment !== null,\n )\n .sort((a, b) => b.createdAtMs - a.createdAtMs)\n setResult({ status: 'ready', data: enrollments, size: snapshot.size })\n },\n (error) =>\n setResult({ ...failed([], error, 'the enrollments'), size: 0 }),\n )\n }, [firestore, orgId, sequenceId, pages])\n return useMemo(\n () => ({\n status: result.status,\n data: result.data,\n hasMore: result.size >= pages * OUTREACH_ENROLLMENTS_PAGE,\n showMore: () => setPages((count) => count + 1),\n }),\n [result, pages],\n )\n}\n\n/** The counts the sequence list shows beside each sequence. */\nexport interface OutreachSequenceCounts {\n enrolled: number\n active: number\n replied: number\n bounced: number\n optedOut: number\n}\n\nconst COUNTED: ReadonlyArray<\n [keyof OutreachSequenceCounts, OutreachEnrollmentStatus | null]\n> = [\n ['enrolled', null],\n ['active', 'active'],\n ['replied', 'replied'],\n ['bounced', 'bounced'],\n ['optedOut', 'opted_out'],\n]\n\n/**\n * Each sequence's enrollment counts, by server-side aggregation — one count\n * per figure, never a read of the enrollments themselves. Recounted when the\n * sequences asked about change — the list asks about the page on screen —\n * and absent while counting, or if a count failed.\n */\nexport function useOutreachSequenceCounts(\n orgId: string | null,\n sequenceIds: readonly string[],\n): Record<string, OutreachSequenceCounts> {\n const firestore = useFirestore()\n const [counts, setCounts] = useState<Record<string, OutreachSequenceCounts>>(\n {},\n )\n const key = sequenceIds.join(',')\n useEffect(() => {\n if (!orgId || !key) return undefined\n let current = true\n const enrollments = collection(\n firestore,\n 'orgs',\n orgId,\n OUTREACH_COLLECTIONS.enrollments,\n )\n Promise.all(\n key.split(',').map(async (sequenceId) => {\n const figures = await Promise.all(\n COUNTED.map(async ([, status]) => {\n const counted = await getCountFromServer(\n status\n ? query(\n enrollments,\n where('sequenceId', '==', sequenceId),\n where('status', '==', status),\n )\n : query(enrollments, where('sequenceId', '==', sequenceId)),\n )\n return counted.data().count\n }),\n )\n return [\n sequenceId,\n Object.fromEntries(\n COUNTED.map(([name], index) => [name, figures[index]]),\n ) as unknown as OutreachSequenceCounts,\n ] as const\n }),\n )\n .then((entries) => current && setCounts(Object.fromEntries(entries)))\n .catch((error: unknown) =>\n console.error('[outreach] enrollment counts could not be read', error),\n )\n return () => {\n current = false\n }\n }, [firestore, orgId, key])\n return counts\n}\n"],"names":["useFirestore","collection","doc","documentId","getCountFromServer","limit","onSnapshot","orderBy","query","where","useEffect","useMemo","useState","OUTREACH_COLLECTIONS","OUTREACH_LINK_ROLLUP_PATH","readStoredOutreachEnrollment","readStoredOutreachSequence","denied","error","code","failed","data","what","status","console","OUTREACH_SEQUENCES_LIMIT","useOutreachSequences","orgId","firestore","result","setResult","undefined","sequences","snapshot","docs","slice","map","entry","id","filter","sequence","sort","a","b","createdAtMs","truncated","length","useOutreachSequence","sequenceId","exists","useOutreachSequenceLinks","useOutreachDoNotContactDomains","doNotContactDomains","domains","domain","localeCompare","OUTREACH_ENROLLMENTS_PAGE","useOutreachEnrollments","pages","setPages","size","enrollments","enrollment","hasMore","showMore","count","COUNTED","useOutreachSequenceCounts","sequenceIds","counts","setCounts","key","join","current","Promise","all","split","figures","counted","Object","fromEntries","name","index","then","entries","catch"],"mappings":"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"}
@@ -74,6 +74,21 @@ export declare const OUTREACH_API_ROUTES: {
74
74
  readonly linkDomains: "outreach/link-domains";
75
75
  /** `POST` — one email of a saved sequence, for a contact or a sample person. */
76
76
  readonly preview: "outreach/preview";
77
+ /**
78
+ * `POST` — one step of a saved sequence, rendered as the preview renders
79
+ * it and sent through the sequence's mailbox to the member (AGL-3325):
80
+ * `[Test]` in the subject, its links minted as test links, and counted
81
+ * on nothing but the mailbox's test tally.
82
+ */
83
+ readonly stepTest: "outreach/steps/test";
84
+ /**
85
+ * `POST` — one person's own copies of a sequence's email steps, drafted by
86
+ * the workspace's AI (AGL-3324): for a person about to be enrolled, or for
87
+ * the next step of an enrollment. Nothing is stored.
88
+ */
89
+ readonly curateDraft: "outreach/curate/draft";
90
+ /** `POST` — stores a member's confirmed copy of one step on an enrollment, or clears it. */
91
+ readonly curateSave: "outreach/curate/save";
77
92
  /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */
78
93
  readonly unsubscribe: "outreach/unsubscribe";
79
94
  /**
@@ -57,6 +57,18 @@
57
57
  * it. Setting up and removing are an owner's or admin's.
58
58
  */ linkDomains: 'outreach/link-domains',
59
59
  /** `POST` — one email of a saved sequence, for a contact or a sample person. */ preview: 'outreach/preview',
60
+ /**
61
+ * `POST` — one step of a saved sequence, rendered as the preview renders
62
+ * it and sent through the sequence's mailbox to the member (AGL-3325):
63
+ * `[Test]` in the subject, its links minted as test links, and counted
64
+ * on nothing but the mailbox's test tally.
65
+ */ stepTest: 'outreach/steps/test',
66
+ /**
67
+ * `POST` — one person's own copies of a sequence's email steps, drafted by
68
+ * the workspace's AI (AGL-3324): for a person about to be enrolled, or for
69
+ * the next step of an enrollment. Nothing is stored.
70
+ */ curateDraft: 'outreach/curate/draft',
71
+ /** `POST` — stores a member's confirmed copy of one step on an enrollment, or clears it. */ curateSave: 'outreach/curate/save',
60
72
  // The one route a RECIPIENT reaches (AGL-2981): no session, a signed
61
73
  // token instead, and registered as a recipient link so it answers whether
62
74
  // or not Outreach is released for the organization now.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/api-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Outreach's console API routes, as the dispatcher keys them (AGL-2974).\n *\n * One table both halves import, because the server registers the path and the\n * client fetches it, and a route spelled twice is a route that 404s the day\n * one spelling changes. Every path sits under the `outreach` prefix that\n * `plugins.config.json` gives this plugin, which is what lets the console's\n * `/api/[...pluginApi]` dispatcher gate a request on the plugin being enabled\n * and released before the handler runs. Client-safe: constants only.\n */\nexport const OUTREACH_API_ROUTES = {\n /** `GET` — proves the server bundle loaded and registered; see `server.ts`. */\n ping: 'outreach/ping',\n // Mailboxes (AGL-2978). Every one names its org (`orgId`, query or body)\n // so the dispatcher's release gate asks about that organization.\n /** `GET ?orgId` — whether this deployment can connect a Google mailbox. */\n mailboxesAvailability: 'outreach/mailboxes/availability',\n /** `POST` — Google's consent address for a new connect. */\n mailboxesConnect: 'outreach/mailboxes/connect',\n /**\n * `GET` — Google's redirect back. It carries no bearer token: the signed\n * state names the org and the member, and the route hands the code to the\n * Mailboxes page in a fragment rather than acting on it.\n */\n mailboxesOAuthCallback: 'outreach/mailboxes/oauth/callback',\n /** `POST` — finishes a connect with the member's own session. */\n mailboxesConnectComplete: 'outreach/mailboxes/connect/complete',\n /** `POST` — send-as, display name, daily cap, sending window, timezone. */\n mailboxesSettings: 'outreach/mailboxes/settings',\n /** `POST` — pause or resume. */\n mailboxesStatus: 'outreach/mailboxes/status',\n /** `POST` — a plain-text test to the account's own address. */\n mailboxesTest: 'outreach/mailboxes/test',\n /** `POST` — revoke the grant at Google and delete the mailbox. */\n mailboxesDisconnect: 'outreach/mailboxes/disconnect',\n // Settings, sequences and enrollments (AGL-2980). Every one names its org\n // (`orgId`, query or body); the contract is `model/outreach-api.ts`.\n /** `GET ?orgId` — the compliance settings; `POST` — save them. */\n settings: 'outreach/settings',\n /** `POST` — create a sequence, or save an edit to one. */\n sequencesSave: 'outreach/sequences/save',\n /** `POST` — activate, pause or archive a sequence. */\n sequencesStatus: 'outreach/sequences/status',\n /** `POST` — delete a draft nobody was enrolled in. */\n sequencesDelete: 'outreach/sequences/delete',\n /** `POST` — who a saved view or a search would enroll, and where each stands. */\n enrollPreview: 'outreach/enroll/preview',\n /** `POST` — enroll them, every gate checked again. */\n enroll: 'outreach/enroll',\n /** `POST` — pause, resume, stop, or mark do-not-contact. */\n enrollmentsAction: 'outreach/enrollments/action',\n /**\n * `GET ?orgId` — the domains on the organization's do-not-contact list\n * (AGL-3244); `POST` — add one, or take one off.\n */\n doNotContactDomains: 'outreach/do-not-contact/domains',\n /**\n * `GET ?orgId` — each connected mailbox domain's click-tracking host\n * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove\n * it. Setting up and removing are an owner's or admin's.\n */\n linkDomains: 'outreach/link-domains',\n /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\n // The one route a RECIPIENT reaches (AGL-2981): no session, a signed\n // token instead, and registered as a recipient link so it answers whether\n // or not Outreach is released for the organization now.\n /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */\n unsubscribe: 'outreach/unsubscribe',\n /**\n * `GET` — a link in a tracked sequence email (AGL-3239). `?t=<signed\n * token>`; answers a redirect to the destination the token names, and\n * records the click afterwards. A recipient link like the unsubscribe\n * one: no session, and it answers whether or not Outreach is released to\n * the organization now, because a link in an email that already left must\n * not stop working when a rollout is paused.\n */\n click: 'outreach/click',\n /**\n * `GET` — the short tracking link (AGL-3297): `outreach/l/<id>`, where the\n * id names a stored `outreachLinks` document holding the destination. It\n * replaced the signed `click` link for every send since; `click` keeps\n * answering the links already in inboxes. A recipient link, for `click`'s\n * reasons.\n */\n shortLink: 'outreach/l/:linkId',\n} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","doNotContactDomains","linkDomains","preview","unsubscribe","click","shortLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GACD,OAAO,MAAMA,sBAAsB;IACjC,6EAA6E,GAC7EC,MAAM;IACN,yEAAyE;IACzE,iEAAiE;IACjE,yEAAyE,GACzEC,uBAAuB;IACvB,yDAAyD,GACzDC,kBAAkB;IAClB;;;;GAIC,GACDC,wBAAwB;IACxB,+DAA+D,GAC/DC,0BAA0B;IAC1B,yEAAyE,GACzEC,mBAAmB;IACnB,8BAA8B,GAC9BC,iBAAiB;IACjB,6DAA6D,GAC7DC,eAAe;IACf,gEAAgE,GAChEC,qBAAqB;IACrB,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE,GAChEC,UAAU;IACV,wDAAwD,GACxDC,eAAe;IACf,oDAAoD,GACpDC,iBAAiB;IACjB,oDAAoD,GACpDC,iBAAiB;IACjB,+EAA+E,GAC/EC,eAAe;IACf,oDAAoD,GACpDC,QAAQ;IACR,0DAA0D,GAC1DC,mBAAmB;IACnB;;;GAGC,GACDC,qBAAqB;IACrB;;;;GAIC,GACDC,aAAa;IACb,8EAA8E,GAC9EC,SAAS;IACT,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;IACP;;;;;;GAMC,GACDC,WAAW;AACb,EAAU"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/constants/api-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Outreach's console API routes, as the dispatcher keys them (AGL-2974).\n *\n * One table both halves import, because the server registers the path and the\n * client fetches it, and a route spelled twice is a route that 404s the day\n * one spelling changes. Every path sits under the `outreach` prefix that\n * `plugins.config.json` gives this plugin, which is what lets the console's\n * `/api/[...pluginApi]` dispatcher gate a request on the plugin being enabled\n * and released before the handler runs. Client-safe: constants only.\n */\nexport const OUTREACH_API_ROUTES = {\n /** `GET` — proves the server bundle loaded and registered; see `server.ts`. */\n ping: 'outreach/ping',\n // Mailboxes (AGL-2978). Every one names its org (`orgId`, query or body)\n // so the dispatcher's release gate asks about that organization.\n /** `GET ?orgId` — whether this deployment can connect a Google mailbox. */\n mailboxesAvailability: 'outreach/mailboxes/availability',\n /** `POST` — Google's consent address for a new connect. */\n mailboxesConnect: 'outreach/mailboxes/connect',\n /**\n * `GET` — Google's redirect back. It carries no bearer token: the signed\n * state names the org and the member, and the route hands the code to the\n * Mailboxes page in a fragment rather than acting on it.\n */\n mailboxesOAuthCallback: 'outreach/mailboxes/oauth/callback',\n /** `POST` — finishes a connect with the member's own session. */\n mailboxesConnectComplete: 'outreach/mailboxes/connect/complete',\n /** `POST` — send-as, display name, daily cap, sending window, timezone. */\n mailboxesSettings: 'outreach/mailboxes/settings',\n /** `POST` — pause or resume. */\n mailboxesStatus: 'outreach/mailboxes/status',\n /** `POST` — a plain-text test to the account's own address. */\n mailboxesTest: 'outreach/mailboxes/test',\n /** `POST` — revoke the grant at Google and delete the mailbox. */\n mailboxesDisconnect: 'outreach/mailboxes/disconnect',\n // Settings, sequences and enrollments (AGL-2980). Every one names its org\n // (`orgId`, query or body); the contract is `model/outreach-api.ts`.\n /** `GET ?orgId` — the compliance settings; `POST` — save them. */\n settings: 'outreach/settings',\n /** `POST` — create a sequence, or save an edit to one. */\n sequencesSave: 'outreach/sequences/save',\n /** `POST` — activate, pause or archive a sequence. */\n sequencesStatus: 'outreach/sequences/status',\n /** `POST` — delete a draft nobody was enrolled in. */\n sequencesDelete: 'outreach/sequences/delete',\n /** `POST` — who a saved view or a search would enroll, and where each stands. */\n enrollPreview: 'outreach/enroll/preview',\n /** `POST` — enroll them, every gate checked again. */\n enroll: 'outreach/enroll',\n /** `POST` — pause, resume, stop, or mark do-not-contact. */\n enrollmentsAction: 'outreach/enrollments/action',\n /**\n * `GET ?orgId` — the domains on the organization's do-not-contact list\n * (AGL-3244); `POST` — add one, or take one off.\n */\n doNotContactDomains: 'outreach/do-not-contact/domains',\n /**\n * `GET ?orgId` — each connected mailbox domain's click-tracking host\n * (AGL-3306), `links.<domain>`; `POST` — set one up, check it, or remove\n * it. Setting up and removing are an owner's or admin's.\n */\n linkDomains: 'outreach/link-domains',\n /** `POST` — one email of a saved sequence, for a contact or a sample person. */\n preview: 'outreach/preview',\n /**\n * `POST` — one step of a saved sequence, rendered as the preview renders\n * it and sent through the sequence's mailbox to the member (AGL-3325):\n * `[Test]` in the subject, its links minted as test links, and counted\n * on nothing but the mailbox's test tally.\n */\n stepTest: 'outreach/steps/test',\n /**\n * `POST` — one person's own copies of a sequence's email steps, drafted by\n * the workspace's AI (AGL-3324): for a person about to be enrolled, or for\n * the next step of an enrollment. Nothing is stored.\n */\n curateDraft: 'outreach/curate/draft',\n /** `POST` — stores a member's confirmed copy of one step on an enrollment, or clears it. */\n curateSave: 'outreach/curate/save',\n // The one route a RECIPIENT reaches (AGL-2981): no session, a signed\n // token instead, and registered as a recipient link so it answers whether\n // or not Outreach is released for the organization now.\n /** `GET` and the RFC 8058 one-click `POST` — `?t=<signed token>`. */\n unsubscribe: 'outreach/unsubscribe',\n /**\n * `GET` — a link in a tracked sequence email (AGL-3239). `?t=<signed\n * token>`; answers a redirect to the destination the token names, and\n * records the click afterwards. A recipient link like the unsubscribe\n * one: no session, and it answers whether or not Outreach is released to\n * the organization now, because a link in an email that already left must\n * not stop working when a rollout is paused.\n */\n click: 'outreach/click',\n /**\n * `GET` — the short tracking link (AGL-3297): `outreach/l/<id>`, where the\n * id names a stored `outreachLinks` document holding the destination. It\n * replaced the signed `click` link for every send since; `click` keeps\n * answering the links already in inboxes. A recipient link, for `click`'s\n * reasons.\n */\n shortLink: 'outreach/l/:linkId',\n} as const\n"],"names":["OUTREACH_API_ROUTES","ping","mailboxesAvailability","mailboxesConnect","mailboxesOAuthCallback","mailboxesConnectComplete","mailboxesSettings","mailboxesStatus","mailboxesTest","mailboxesDisconnect","settings","sequencesSave","sequencesStatus","sequencesDelete","enrollPreview","enroll","enrollmentsAction","doNotContactDomains","linkDomains","preview","stepTest","curateDraft","curateSave","unsubscribe","click","shortLink"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;CASC,GACD,OAAO,MAAMA,sBAAsB;IACjC,6EAA6E,GAC7EC,MAAM;IACN,yEAAyE;IACzE,iEAAiE;IACjE,yEAAyE,GACzEC,uBAAuB;IACvB,yDAAyD,GACzDC,kBAAkB;IAClB;;;;GAIC,GACDC,wBAAwB;IACxB,+DAA+D,GAC/DC,0BAA0B;IAC1B,yEAAyE,GACzEC,mBAAmB;IACnB,8BAA8B,GAC9BC,iBAAiB;IACjB,6DAA6D,GAC7DC,eAAe;IACf,gEAAgE,GAChEC,qBAAqB;IACrB,0EAA0E;IAC1E,qEAAqE;IACrE,gEAAgE,GAChEC,UAAU;IACV,wDAAwD,GACxDC,eAAe;IACf,oDAAoD,GACpDC,iBAAiB;IACjB,oDAAoD,GACpDC,iBAAiB;IACjB,+EAA+E,GAC/EC,eAAe;IACf,oDAAoD,GACpDC,QAAQ;IACR,0DAA0D,GAC1DC,mBAAmB;IACnB;;;GAGC,GACDC,qBAAqB;IACrB;;;;GAIC,GACDC,aAAa;IACb,8EAA8E,GAC9EC,SAAS;IACT;;;;;GAKC,GACDC,UAAU;IACV;;;;GAIC,GACDC,aAAa;IACb,0FAA0F,GAC1FC,YAAY;IACZ,qEAAqE;IACrE,0EAA0E;IAC1E,wDAAwD;IACxD,mEAAmE,GACnEC,aAAa;IACb;;;;;;;GAOC,GACDC,OAAO;IACP;;;;;;GAMC,GACDC,WAAW;AACb,EAAU"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type CrmMergeContext } from '@aglyn/aglyn/app-utils/crm-email-templates';
18
- import type { OutreachEnrollment, OutreachOrgSettings, OutreachSequence } from '../model/outreach.types';
18
+ import type { OutreachEnrollment, OutreachOrgSettings, OutreachSequence, OutreachStepOverride } from '../model/outreach.types';
19
19
  /** The way out every email offers, word for word. */
20
20
  export declare const OUTREACH_OPT_OUT_LINE = "Not interested? Reply \"no\" and I won't email again.";
21
21
  /**
@@ -60,7 +60,7 @@ export interface OutreachComposeError {
60
60
  }
61
61
  export interface ComposeOutreachEmailInput {
62
62
  sequence: Pick<OutreachSequence, 'steps'>;
63
- enrollment: Pick<OutreachEnrollment, 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'>;
63
+ enrollment: Pick<OutreachEnrollment, 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'> & Partial<Pick<OutreachEnrollment, 'stepOverrides'>>;
64
64
  orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined;
65
65
  /**
66
66
  * What the CRM resolver reads: the contact, the sending site's group id,
@@ -98,7 +98,17 @@ export interface OutreachComposeResult {
98
98
  error: OutreachComposeError | null;
99
99
  /** Merge fields that rendered empty, unique, in order of appearance. */
100
100
  unresolvedFields: string[];
101
+ /**
102
+ * The person's own copy of the step the email was written from
103
+ * (AGL-3324), or `null` when the step was sent as the sequence wrote it.
104
+ */
105
+ override: OutreachStepOverride | null;
101
106
  }
107
+ /**
108
+ * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or
109
+ * `null`. A copy with neither a subject nor a body is no copy.
110
+ */
111
+ export declare function outreachStepOverrideFor(enrollment: Partial<Pick<OutreachEnrollment, 'stepOverrides'>> | null | undefined, stepIndex: number): OutreachStepOverride | null;
102
112
  /**
103
113
  * The footer every email ends with, or the reason it cannot be written.
104
114
  *
@@ -49,6 +49,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
49
49
  * `{{enrollment.personalLine}}` through the resolver's `extra` fields.
50
50
  * Fields that render empty are returned beside the message, for the
51
51
  * runtime to decide whether "Hi ," is worth sending.
52
+ *
53
+ * ## A curated step
54
+ *
55
+ * An enrollment may carry its own copy of a step (AGL-3324): a subject and
56
+ * a body drafted for that one person. The composer reads the copy in place
57
+ * of the step's subject and body — and of the template the step names —
58
+ * and then does everything it does to a step: the merge, the threading, the
59
+ * click tracking, the footer. A curated in-thread email still goes out as
60
+ * `Re:` the thread's subject, since it answers the same thread.
52
61
  *==========================================*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
53
62
  import { CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
54
63
  import { resolveCrmMergeFields } from "@aglyn/aglyn/app-utils/crm-email-templates";
@@ -65,6 +74,17 @@ import { isInThreadEmailStep, OUTREACH_PERSONAL_LINE_FIELD, outreachSubjectHasRe
65
74
  */ export function outreachSolicitationStatement(senderName) {
66
75
  return `This is a sales email from ${senderName}.`;
67
76
  }
77
+ /**
78
+ * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or
79
+ * `null`. A copy with neither a subject nor a body is no copy.
80
+ */ export function outreachStepOverrideFor(enrollment, stepIndex) {
81
+ var _enrollment_stepOverrides;
82
+ const override = enrollment == null ? void 0 : (_enrollment_stepOverrides = enrollment.stepOverrides) == null ? void 0 : _enrollment_stepOverrides[String(stepIndex)];
83
+ if (!override || typeof override !== 'object') return null;
84
+ const hasSubject = typeof override.subject === 'string';
85
+ const hasBody = typeof override.body === 'string';
86
+ return hasSubject || hasBody ? override : null;
87
+ }
68
88
  const oneLine = (value)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
69
89
  /**
70
90
  * The footer every email ends with, or the reason it cannot be written.
@@ -137,7 +157,8 @@ const refuse = (code, message)=>({
137
157
  code,
138
158
  message
139
159
  },
140
- unresolvedFields: []
160
+ unresolvedFields: [],
161
+ override: null
141
162
  });
142
163
  /** The email one step sends to one person — see the module note. */ export function composeOutreachEmail(input) {
143
164
  var _ref, _input_merge, _ref1, _input_templateBody, _step_body;
@@ -146,7 +167,8 @@ const refuse = (code, message)=>({
146
167
  if (error) return {
147
168
  email: null,
148
169
  error,
149
- unresolvedFields: []
170
+ unresolvedFields: [],
171
+ override: null
150
172
  };
151
173
  const steps = (_ref = (_input_sequence = input.sequence) == null ? void 0 : _input_sequence.steps) != null ? _ref : [];
152
174
  const stepIndex = (_input_enrollment = input.enrollment) == null ? void 0 : _input_enrollment.stepIndex;
@@ -169,6 +191,9 @@ const refuse = (code, message)=>({
169
191
  for (const key of result.unresolved)if (!unresolved.includes(key)) unresolved.push(key);
170
192
  return result.text;
171
193
  };
194
+ // This person's own copy of the step (AGL-3324), read in place of the
195
+ // step's own words — and of its template — from here on.
196
+ const override = outreachStepOverrideFor(input.enrollment, stepIndex);
172
197
  const email = {
173
198
  to,
174
199
  subject: '',
@@ -188,15 +213,15 @@ const refuse = (code, message)=>({
188
213
  email.inReplyTo = messageIds[messageIds.length - 1];
189
214
  email.references = messageIds.join(' ');
190
215
  } else {
191
- var _step_subject;
192
- const subject = oneLine(render(String((_step_subject = step.subject) != null ? _step_subject : '')));
216
+ var _ref2, _ref3;
217
+ const subject = oneLine(render(String((_ref2 = (_ref3 = override == null ? void 0 : override.subject) != null ? _ref3 : step.subject) != null ? _ref2 : '')));
193
218
  if (!subject) return refuse('missing_subject', 'This email has no subject.');
194
219
  if (outreachSubjectHasReplyPrefix(subject)) {
195
220
  return refuse('subject_reply_prefix', 'This email starts a thread, so its subject may not begin with "Re:" or "Fwd:".');
196
221
  }
197
222
  email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX);
198
223
  }
199
- const source = step.templateId ? String((_input_templateBody = input.templateBody) != null ? _input_templateBody : '') : String((_step_body = step.body) != null ? _step_body : '');
224
+ const source = typeof (override == null ? void 0 : override.body) === 'string' ? override.body : step.templateId ? String((_input_templateBody = input.templateBody) != null ? _input_templateBody : '') : String((_step_body = step.body) != null ? _step_body : '');
200
225
  const body = render(source).replace(/\r\n?/g, '\n').split('\n').map((line)=>line.replace(/[ \t]+$/, '')).join('\n').replace(/^\n+|\n+$/g, '');
201
226
  if (!body.trim()) return refuse('missing_body', 'This email has no body.');
202
227
  /*
@@ -224,7 +249,8 @@ const refuse = (code, message)=>({
224
249
  return {
225
250
  email,
226
251
  error: null,
227
- unresolvedFields: unresolved
252
+ unresolvedFields: unresolved,
253
+ override
228
254
  };
229
255
  }
230
256
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","trackedLinks","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","tracked","rewriteLink","links","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *\n * ## A curated step\n *\n * An enrollment may carry its own copy of a step (AGL-3324): a subject and\n * a body drafted for that one person. The composer reads the copy in place\n * of the step's subject and body — and of the template the step names —\n * and then does everything it does to a step: the merge, the threading, the\n * click tracking, the footer. A curated in-thread email still goes out as\n * `Re:` the thread's subject, since it answers the same thread.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n OutreachStepOverride,\n} from '../model/outreach.types'\nimport { rewriteOutreachBodyLinks } from './click-tracking'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n /**\n * The destinations whose links were rewritten for click tracking, in the\n * order a click token indexes them (AGL-3239). Empty when the sequence\n * does not track clicks, when the body carries no links, and when no\n * tracking link could be minted.\n */\n trackedLinks: string[]\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n > &\n Partial<Pick<OutreachEnrollment, 'stepOverrides'>>\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n /**\n * Answers the tracking link for one link in the body, or `null` to leave\n * that one as it was written (AGL-3239).\n *\n * Absent leaves every link alone, which is what a sequence with\n * `trackClicks` off passes and what every caller that is not the sending\n * runtime passes — a PREVIEW must show the rep the body their recipient\n * reads, and minting a live tracking link for a preview would count the\n * rep's own click against the sequence.\n *\n * It runs on the step's rendered body and never on the footer, so the\n * organization's identification and the way out are never rewritten.\n */\n rewriteLink?: ((link: { url: string; index: number }) => string | null) | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n /**\n * The person's own copy of the step the email was written from\n * (AGL-3324), or `null` when the step was sent as the sequence wrote it.\n */\n override: OutreachStepOverride | null\n}\n\n/**\n * The enrollment's own copy of the step at `stepIndex` (AGL-3324), or\n * `null`. A copy with neither a subject nor a body is no copy.\n */\nexport function outreachStepOverrideFor(\n enrollment: Partial<Pick<OutreachEnrollment, 'stepOverrides'>> | null | undefined,\n stepIndex: number,\n): OutreachStepOverride | null {\n const override = enrollment?.stepOverrides?.[String(stepIndex)]\n if (!override || typeof override !== 'object') return null\n const hasSubject = typeof override.subject === 'string'\n const hasBody = typeof override.body === 'string'\n return hasSubject || hasBody ? override : null\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [], override: null })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [], override: null }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n // This person's own copy of the step (AGL-3324), read in place of the\n // step's own words — and of its template — from here on.\n const override = outreachStepOverrideFor(input.enrollment, stepIndex)\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '', trackedLinks: [] }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(override?.subject ?? step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source =\n typeof override?.body === 'string'\n ? override.body\n : step.templateId\n ? String(input.templateBody ?? '')\n : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n /*\n * Click tracking rewrites the STEP'S links and nothing else (AGL-3239):\n * it runs here, on the rendered body, before the footer is appended. The\n * footer's opt-out `mailto:` and the organization's own identification are\n * never rewritten, and the one-click unsubscribe link never appears in a\n * body at all — it rides in `List-Unsubscribe`.\n */\n const tracked = input.rewriteLink\n ? rewriteOutreachBodyLinks(body, input.rewriteLink)\n : { text: body, links: [] }\n email.trackedLinks = tracked.links\n email.text = `${tracked.text}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved, override }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","rewriteOutreachBodyLinks","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","outreachStepOverrideFor","enrollment","stepIndex","override","stepOverrides","String","hasSubject","subject","hasBody","body","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","trackedLinks","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","tracked","rewriteLink","links","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
@@ -0,0 +1,73 @@
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
+ /** The standing instructions. Kept as one string so it caches as a prefix. */
18
+ export declare const OUTREACH_CURATION_SYSTEM: string;
19
+ /** The most of a lead's notes the prompt carries. */
20
+ export declare const OUTREACH_CURATION_NOTES_MAX = 600;
21
+ /** What an email address or a phone number in free text becomes before it leaves. */
22
+ export declare const OUTREACH_CURATION_EMAIL_PLACEHOLDER = "[email address]";
23
+ export declare const OUTREACH_CURATION_PHONE_PLACEHOLDER = "[phone number]";
24
+ /** Free text a person typed, with every address and number replaced. */
25
+ export declare function redactOutreachCurationText(text: unknown): string;
26
+ /** What the CRM holds about the person, as the prompt states it. */
27
+ export interface OutreachCurationFacts {
28
+ name: string;
29
+ company: string;
30
+ title: string;
31
+ website: string;
32
+ tags: readonly string[];
33
+ /** How the person came to the workspace: a form, an import, a booking. */
34
+ sources: readonly string[];
35
+ /** The campaigns the person is filed under, by name. */
36
+ campaigns: readonly string[];
37
+ /** A lead's notes, when it has any. */
38
+ notes: string;
39
+ /** Whether the person is a lead or a contact. */
40
+ record: 'lead' | 'contact';
41
+ siteName: string;
42
+ /** The sender, as the mailbox names them. */
43
+ senderName: string;
44
+ personalLine: string;
45
+ }
46
+ /** One email step as the prompt states it. */
47
+ export interface OutreachCurationStep {
48
+ stepIndex: number;
49
+ /** Which email this is, one-based, of how many the sequence sends. */
50
+ position: number;
51
+ count: number;
52
+ subject: string;
53
+ body: string;
54
+ /** Whether the email starts a thread; an in-thread email is answered with no subject. */
55
+ startsThread: boolean;
56
+ }
57
+ /** The prompt: the sender, the person, the reason, and each email as written. */
58
+ export declare function outreachCurationPrompt(facts: OutreachCurationFacts, steps: readonly OutreachCurationStep[]): string;
59
+ /** One drafted email, as the answer named it. */
60
+ export interface OutreachCurationDraft {
61
+ stepIndex: number;
62
+ /** `null` for an in-thread email, which has none. */
63
+ subject: string | null;
64
+ body: string;
65
+ }
66
+ /**
67
+ * The drafts in the model's answer, one per step asked for, or `null` when
68
+ * the answer is not what was asked for. A code fence around the JSON is
69
+ * forgiven; a step the answer left out, or named twice, is not — the member
70
+ * would be shown a draft for one email and a template for another without
71
+ * knowing which the model skipped.
72
+ */
73
+ export declare function parseOutreachCurationAnswer(text: string, steps: readonly OutreachCurationStep[]): OutreachCurationDraft[] | null;