@aglyn/plugins-outreach 1.0.0-beta.218 → 1.0.0-beta.220

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 (112) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +302 -180
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +12 -2
  6. package/src/lib/components/enroll-dialog.js +72 -18
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/enrollment-detail.d.ts +5 -1
  9. package/src/lib/components/enrollment-detail.js +14 -8
  10. package/src/lib/components/enrollment-detail.js.map +1 -1
  11. package/src/lib/components/outreach-console-sections.d.ts +1 -1
  12. package/src/lib/components/outreach-console-sections.js +1 -1
  13. package/src/lib/components/outreach-console-sections.js.map +1 -1
  14. package/src/lib/components/sequence-editor.d.ts +13 -0
  15. package/src/lib/components/sequence-editor.js +586 -335
  16. package/src/lib/components/sequence-editor.js.map +1 -1
  17. package/src/lib/components/sequence-preview.js +25 -10
  18. package/src/lib/components/sequence-preview.js.map +1 -1
  19. package/src/lib/components/sequence-step-card.d.ts +10 -0
  20. package/src/lib/components/sequence-step-card.js +320 -202
  21. package/src/lib/components/sequence-step-card.js.map +1 -1
  22. package/src/lib/components/use-outreach-api.d.ts +2 -1
  23. package/src/lib/components/use-outreach-api.js +5 -3
  24. package/src/lib/components/use-outreach-api.js.map +1 -1
  25. package/src/lib/engine/compose.d.ts +1 -1
  26. package/src/lib/engine/compose.js +10 -6
  27. package/src/lib/engine/compose.js.map +1 -1
  28. package/src/lib/engine/delivery-status.js +1 -1
  29. package/src/lib/engine/delivery-status.js.map +1 -1
  30. package/src/lib/engine/enrollment-activity.d.ts +9 -1
  31. package/src/lib/engine/enrollment-activity.js +15 -2
  32. package/src/lib/engine/enrollment-activity.js.map +1 -1
  33. package/src/lib/engine/enrollment-state.d.ts +33 -2
  34. package/src/lib/engine/enrollment-state.js +68 -9
  35. package/src/lib/engine/enrollment-state.js.map +1 -1
  36. package/src/lib/engine/gates.d.ts +2 -2
  37. package/src/lib/engine/gates.js.map +1 -1
  38. package/src/lib/engine/opt-out-intent.js +1 -1
  39. package/src/lib/engine/opt-out-intent.js.map +1 -1
  40. package/src/lib/engine/sending-capacity.d.ts +1 -1
  41. package/src/lib/engine/sending-capacity.js +1 -1
  42. package/src/lib/engine/sending-capacity.js.map +1 -1
  43. package/src/lib/engine/sequence-validation.d.ts +20 -3
  44. package/src/lib/engine/sequence-validation.js +32 -6
  45. package/src/lib/engine/sequence-validation.js.map +1 -1
  46. package/src/lib/engine/thread-classification.js +3 -3
  47. package/src/lib/engine/thread-classification.js.map +1 -1
  48. package/src/lib/enrollment/enroll-people.js +6 -4
  49. package/src/lib/enrollment/enroll-people.js.map +1 -1
  50. package/src/lib/enrollment/gate-lookups.js +5 -4
  51. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  52. package/src/lib/enrollment/step-overrides.d.ts +4 -2
  53. package/src/lib/enrollment/step-overrides.js +11 -4
  54. package/src/lib/enrollment/step-overrides.js.map +1 -1
  55. package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
  56. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  57. package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
  58. package/src/lib/mailboxes/mailbox-routes.js +10 -7
  59. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  60. package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
  61. package/src/lib/mailboxes/mailbox-settings.js +1 -1
  62. package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
  63. package/src/lib/model/enrollment-timeline.d.ts +1 -1
  64. package/src/lib/model/enrollment-timeline.js +22 -3
  65. package/src/lib/model/enrollment-timeline.js.map +1 -1
  66. package/src/lib/model/outreach-api.d.ts +19 -1
  67. package/src/lib/model/outreach-api.js.map +1 -1
  68. package/src/lib/model/outreach.types.d.ts +26 -1
  69. package/src/lib/model/outreach.types.js.map +1 -1
  70. package/src/lib/model/sales-topic.d.ts +27 -0
  71. package/src/lib/model/sales-topic.js +27 -0
  72. package/src/lib/model/sales-topic.js.map +1 -0
  73. package/src/lib/model/sequence-draft.d.ts +1 -1
  74. package/src/lib/model/sequence-draft.js +2 -2
  75. package/src/lib/model/sequence-draft.js.map +1 -1
  76. package/src/lib/model/step-preview.d.ts +2 -0
  77. package/src/lib/model/step-preview.js +11 -7
  78. package/src/lib/model/step-preview.js.map +1 -1
  79. package/src/lib/model/stored-records.js +28 -2
  80. package/src/lib/model/stored-records.js.map +1 -1
  81. package/src/lib/plugin.js +1 -1
  82. package/src/lib/plugin.js.map +1 -1
  83. package/src/lib/routes/curate-routes.js +7 -7
  84. package/src/lib/routes/curate-routes.js.map +1 -1
  85. package/src/lib/routes/enroll-routes.d.ts +6 -0
  86. package/src/lib/routes/enroll-routes.js +31 -19
  87. package/src/lib/routes/enroll-routes.js.map +1 -1
  88. package/src/lib/routes/preview-routes.js +14 -5
  89. package/src/lib/routes/preview-routes.js.map +1 -1
  90. package/src/lib/routes/sequence-routes.js +9 -9
  91. package/src/lib/routes/sequence-routes.js.map +1 -1
  92. package/src/lib/routes/step-test-routes.js +3 -3
  93. package/src/lib/routes/step-test-routes.js.map +1 -1
  94. package/src/lib/runtime/campaign-credit.js +2 -2
  95. package/src/lib/runtime/campaign-credit.js.map +1 -1
  96. package/src/lib/runtime/campaign-detach.js +3 -2
  97. package/src/lib/runtime/campaign-detach.js.map +1 -1
  98. package/src/lib/runtime/lead-records.d.ts +6 -1
  99. package/src/lib/runtime/lead-records.js +25 -6
  100. package/src/lib/runtime/lead-records.js.map +1 -1
  101. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  102. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  103. package/src/lib/runtime/send-job.js +20 -4
  104. package/src/lib/runtime/send-job.js.map +1 -1
  105. package/src/lib/runtime/sync-job.js +3 -3
  106. package/src/lib/runtime/sync-job.js.map +1 -1
  107. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  108. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  109. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
  110. package/src/lib/transport/gmail-errors.d.ts +27 -0
  111. package/src/lib/transport/gmail-errors.js +56 -0
  112. package/src/lib/transport/gmail-errors.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/thread-classification.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 CAME BACK (AGL-2979).\n *\n * The runtime reads each enrollment's threads and asks what the new\n * messages mean. Each message is one of:\n *\n * - `self` — the mailbox's own: a step, or the rep writing in the thread.\n * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).\n * - `delivery_report` — a mail system's message that reports no failure.\n * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the\n * next step and does not stop the sequence: nobody answered.\n * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),\n * or a message sent to the unsubscribe address.\n * - `reply` — any other message from anybody else.\n *\n * ## Automatic replies\n *\n * Read from the headers the standards give an autoresponder —\n * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,\n * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for\n * the servers that set none, from an out-of-office subject in the usual\n * languages.\n *\n * ## The thread's outcome\n *\n * `opted_out` outranks `replied`, which outranks `bounced`, which outranks\n * `postpone`: an opt-out is a reply that also has to reach the do-not-contact\n * list, a person who wrote back matters more than a report about an earlier\n * send, and an out-of-office only matters when nothing else happened. The\n * bounces that report this recipient are counted either way, because mailbox\n * health counts every one.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmThreadSubject, emailAddressOf, htmlToPlainText } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport { readOutreachDeliveryReport, isMailerDaemonMessage } from './delivery-status'\nimport { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from './opt-out-intent'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachMessageKind =\n | 'self'\n | 'hard_bounce'\n | 'soft_bounce'\n | 'delivery_report'\n | 'auto_reply'\n | 'opt_out'\n | 'reply'\n\nexport interface OutreachBounceDetail {\n /** The addresses the report says failed; empty when it names none. */\n recipients: string[]\n status: string | null\n diagnostic: string | null\n /** The receiving server the report names, when it does (AGL-3326). */\n remoteMta: string | null\n}\n\nexport interface OutreachMessageClassification {\n messageId: string\n atMs: number\n kind: OutreachMessageKind\n /** The sender's address, normalized, when the `From` carried one. */\n from: string | null\n /** Set on a bounce. */\n bounce: OutreachBounceDetail | null\n /** An opt-out that calls the email spam. */\n complaint: boolean\n /** What decided it, for the timeline and the log. */\n evidence: string | null\n}\n\nexport interface OutreachMessageContext {\n /** The mailbox's own addresses: the account's and every alias it sends as. */\n selfAddresses: readonly string[]\n /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */\n unsubscribeAddress?: string | null\n /** The subject the thread was started with, so a new subject can be read on its own. */\n threadSubject?: string | null\n}\n\nconst AUTO_REPLY_SUBJECT = new RegExp(\n [\n '^\\\\s*\\\\[?\\\\s*(',\n 'auto(matic)?[\\\\s-]*(reply|response|responder|answer)',\n '|out[\\\\s-]+of[\\\\s-]+(the[\\\\s-]+)?office',\n '|ooo\\\\b',\n \"|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))\",\n '|(on )?(vacation|holiday|annual leave)[\\\\s-]*(reply|response|notice|message|autoreply)',\n '|away from (the |my )?(office|desk)',\n '|abwesenheitsnotiz|abwesenheit',\n '|r[ée]ponse automatique|absence du bureau',\n '|respuesta autom[áa]tica|fuera de la oficina',\n '|risposta automatica|fuori ufficio',\n '|automatisch antwoord|afwezigheidsbericht',\n '|resposta autom[áa]tica|fora do escrit[óo]rio',\n '|autosvar|automaattinen vastaus',\n ')',\n ].join(''),\n 'i',\n)\n\n/**\n * Why a message is an automatic reply, or `null` when it is not one — see\n * the module note for the headers and subjects read.\n */\nexport function outreachAutoReplyEvidence(\n message: Pick<OutreachThreadMessage, 'headers' | 'subject'>,\n): string | null {\n const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase()\n if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`\n for (const name of ['X-Autoreply', 'X-Autorespond']) {\n const value = outreachHeader(message, name).trim()\n if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`\n }\n const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase()\n if (['auto_reply', 'bulk', 'junk'].includes(precedence)) return `Precedence: ${precedence}`\n const subject = String(message?.subject ?? '').replace(/^\\s*(re|fwd?)\\s*:\\s*/i, '')\n const match = AUTO_REPLY_SUBJECT.exec(subject)\n return match ? `Subject: ${match[0].trim()}` : null\n}\n\nfunction addressesIn(header: string): string[] {\n return header\n .split(',')\n .map((part) => emailAddressOf(part))\n .filter((address): address is string => address !== null)\n}\n\nfunction readableText(message: OutreachThreadMessage): string {\n if (message.textBody && message.textBody.trim()) return message.textBody\n if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody)\n return message.snippet ?? ''\n}\n\n/** What one message in an enrollment's thread is — see the module note. */\nexport function classifyOutreachMessage(\n message: OutreachThreadMessage,\n context: OutreachMessageContext,\n): OutreachMessageClassification {\n const from = emailAddressOf(message.from)\n const base = {\n messageId: String(message.id ?? ''),\n atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,\n from,\n bounce: null,\n complaint: false,\n evidence: null,\n }\n\n const report = readOutreachDeliveryReport(message)\n if (report) {\n if (!report.kind) return { ...base, kind: 'delivery_report', evidence: 'delivery report' }\n return {\n ...base,\n kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',\n bounce: {\n recipients: report.failedAddresses,\n status: report.status,\n diagnostic: report.diagnostic,\n remoteMta: report.remoteMta,\n },\n evidence: report.status ? `Status: ${report.status}` : 'delivery report',\n }\n }\n\n const self = new Set(\n (context.selfAddresses ?? [])\n .map((address) => normalizeContactEmail(address))\n .filter((address): address is string => address !== null),\n )\n if (from && self.has(from)) return { ...base, kind: 'self' }\n if (isMailerDaemonMessage(message)) {\n return { ...base, kind: 'delivery_report', evidence: 'mail system message' }\n }\n\n const unsubscribe = normalizeContactEmail(context.unsubscribeAddress)\n if (unsubscribe && addressesIn(message.to ?? '').includes(unsubscribe)) {\n return { ...base, kind: 'opt_out', evidence: `To: ${unsubscribe}` }\n }\n\n const autoReply = outreachAutoReplyEvidence(message)\n if (autoReply) return { ...base, kind: 'auto_reply', evidence: autoReply }\n\n const intent = detectOutreachOptOutIntent(readableText(message))\n if (intent.optOut) {\n return { ...base, kind: 'opt_out', complaint: intent.complaint, evidence: intent.matched }\n }\n // A subject is read only on a message that starts fresh — no `Re:`, and\n // not the thread's own — and only when it says nothing but stop.\n const subject = String(message.subject ?? '').replace(/\\s+/g, ' ').trim()\n const fresh = crmThreadSubject(subject) === subject\n if (subject && fresh && subject !== crmThreadSubject(context.threadSubject)) {\n const subjectIntent = detectOutreachOptOutSubject(subject)\n if (subjectIntent.optOut) {\n return { ...base, kind: 'opt_out', evidence: `Subject: ${subjectIntent.matched}` }\n }\n }\n return { ...base, kind: 'reply' }\n}\n\nexport type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out'\n\nexport interface OutreachThreadDecisionInput extends OutreachMessageContext {\n /** The thread's messages, in any order. */\n messages: readonly OutreachThreadMessage[]\n /** The enrollment's recipient. */\n recipient: string\n /** Messages received at or before this instant were handled by an earlier run. */\n afterMs?: number | null\n /** Ids of messages an earlier run handled. */\n handledMessageIds?: readonly string[]\n}\n\nexport interface OutreachThreadDecision {\n outcome: OutreachThreadOutcome\n /** The message that decided the outcome; `null` for `none`. */\n decidedBy: OutreachMessageClassification | null\n /** Every new message, classified, oldest first. */\n classifications: OutreachMessageClassification[]\n /** A new reply called the email spam: the mailbox is paused as well. */\n complaint: boolean\n /** New hard bounces that report this recipient. */\n hardBounces: number\n /** New soft bounces that report this recipient. */\n softBounces: number\n}\n\nconst RANK: Record<OutreachThreadOutcome, number> = {\n none: 0,\n postpone: 1,\n bounced: 2,\n replied: 3,\n opted_out: 4,\n}\n\nconst OUTCOME_OF: Partial<Record<OutreachMessageKind, OutreachThreadOutcome>> = {\n auto_reply: 'postpone',\n hard_bounce: 'bounced',\n reply: 'replied',\n opt_out: 'opted_out',\n}\n\n/** What the new messages in an enrollment's thread mean for it — see the module note. */\nexport function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision {\n const recipient = normalizeContactEmail(input.recipient)\n const handled = new Set(input.handledMessageIds ?? [])\n const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null\n const fresh = [...(input.messages ?? [])]\n .filter((message) => message && !handled.has(message.id))\n .filter((message) => afterMs === null || message.internalDateMs > afterMs)\n .sort((a, b) => a.internalDateMs - b.internalDateMs)\n\n const decision: OutreachThreadDecision = {\n outcome: 'none',\n decidedBy: null,\n classifications: [],\n complaint: false,\n hardBounces: 0,\n softBounces: 0,\n }\n for (const message of fresh) {\n const classification = classifyOutreachMessage(message, input)\n decision.classifications.push(classification)\n const bounce = classification.bounce\n const aboutRecipient =\n !bounce || !bounce.recipients.length || (recipient !== null && bounce.recipients.includes(recipient))\n if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1\n if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1\n if (classification.complaint) decision.complaint = true\n const outcome = OUTCOME_OF[classification.kind]\n if (!outcome || (classification.kind === 'hard_bounce' && !aboutRecipient)) continue\n if (RANK[outcome] > RANK[decision.outcome]) {\n decision.outcome = outcome\n decision.decidedBy = classification\n }\n }\n return decision\n}\n"],"names":["normalizeContactEmail","crmThreadSubject","emailAddressOf","htmlToPlainText","readOutreachDeliveryReport","isMailerDaemonMessage","detectOutreachOptOutIntent","detectOutreachOptOutSubject","outreachHeader","AUTO_REPLY_SUBJECT","RegExp","join","outreachAutoReplyEvidence","message","autoSubmitted","split","trim","toLowerCase","name","value","precedence","includes","subject","String","replace","match","exec","addressesIn","header","map","part","filter","address","readableText","textBody","htmlBody","snippet","classifyOutreachMessage","context","from","base","messageId","id","atMs","Number","isFinite","internalDateMs","bounce","complaint","evidence","report","kind","recipients","failedAddresses","status","diagnostic","remoteMta","self","Set","selfAddresses","has","unsubscribe","unsubscribeAddress","to","autoReply","intent","optOut","matched","fresh","threadSubject","subjectIntent","RANK","none","postpone","bounced","replied","opted_out","OUTCOME_OF","auto_reply","hard_bounce","reply","opt_out","decideOutreachThread","input","recipient","handled","handledMessageIds","afterMs","messages","sort","a","b","decision","outcome","decidedBy","classifications","hardBounces","softBounces","classification","push","aboutRecipient","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/thread-classification.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 CAME BACK (AGL-2979).\n *\n * The runtime reads each enrollment's threads and asks what the new\n * messages mean. Each message is one of:\n *\n * - `self` — the mailbox's own: a step, or the rep writing in the thread.\n * - `hard_bounce` / `soft_bounce` — a delivery report (`./delivery-status.ts`).\n * - `delivery_report` — a mail system's message that reports no failure.\n * - `auto_reply` — an out-of-office or an autoresponder. It POSTPONES the\n * next step and does not stop the sequence: nobody answered.\n * - `opt_out` — a reply that asks to be left alone (`./opt-out-intent.ts`),\n * or a message sent to the unsubscribe address.\n * - `reply` — any other message from anybody else.\n *\n * ## Automatic replies\n *\n * Read from the headers the standards give an autoresponder —\n * `Auto-Submitted` with any value but `no` (RFC 3834), `X-Autoreply`,\n * `X-Autorespond`, `Precedence: auto_reply`, `bulk` or `junk` — and, for\n * the servers that set none, from an out-of-office subject in the usual\n * languages.\n *\n * ## The thread's outcome\n *\n * `opted_out` outranks `replied`, which outranks `bounced`, which outranks\n * `postpone`: an opt-out is a reply that also has to reach the do-not-contact\n * list, a person who wrote back matters more than a report about an earlier\n * send, and an out-of-office only matters when nothing else happened. The\n * bounces that report this recipient are counted either way, because mailbox\n * health counts every one.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { emailAddressOf, htmlToPlainText, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport { readOutreachDeliveryReport, isMailerDaemonMessage } from './delivery-status'\nimport { detectOutreachOptOutIntent, detectOutreachOptOutSubject } from './opt-out-intent'\nimport { outreachHeader, type OutreachThreadMessage } from './thread-message'\n\nexport type OutreachMessageKind =\n | 'self'\n | 'hard_bounce'\n | 'soft_bounce'\n | 'delivery_report'\n | 'auto_reply'\n | 'opt_out'\n | 'reply'\n\nexport interface OutreachBounceDetail {\n /** The addresses the report says failed; empty when it names none. */\n recipients: string[]\n status: string | null\n diagnostic: string | null\n /** The receiving server the report names, when it does (AGL-3326). */\n remoteMta: string | null\n}\n\nexport interface OutreachMessageClassification {\n messageId: string\n atMs: number\n kind: OutreachMessageKind\n /** The sender's address, normalized, when the `From` carried one. */\n from: string | null\n /** Set on a bounce. */\n bounce: OutreachBounceDetail | null\n /** An opt-out that calls the email spam. */\n complaint: boolean\n /** What decided it, for the timeline and the log. */\n evidence: string | null\n}\n\nexport interface OutreachMessageContext {\n /** The mailbox's own addresses: the account's and every alias it sends as. */\n selfAddresses: readonly string[]\n /** The address a `List-Unsubscribe` mailto points at, when the runtime set one. */\n unsubscribeAddress?: string | null\n /** The subject the thread was started with, so a new subject can be read on its own. */\n threadSubject?: string | null\n}\n\nconst AUTO_REPLY_SUBJECT = new RegExp(\n [\n '^\\\\s*\\\\[?\\\\s*(',\n 'auto(matic)?[\\\\s-]*(reply|response|responder|answer)',\n '|out[\\\\s-]+of[\\\\s-]+(the[\\\\s-]+)?office',\n '|ooo\\\\b',\n \"|i('m| am) (currently )?(out of the office|away|on (vacation|leave|holiday))\",\n '|(on )?(vacation|holiday|annual leave)[\\\\s-]*(reply|response|notice|message|autoreply)',\n '|away from (the |my )?(office|desk)',\n '|abwesenheitsnotiz|abwesenheit',\n '|r[ée]ponse automatique|absence du bureau',\n '|respuesta autom[áa]tica|fuera de la oficina',\n '|risposta automatica|fuori ufficio',\n '|automatisch antwoord|afwezigheidsbericht',\n '|resposta autom[áa]tica|fora do escrit[óo]rio',\n '|autosvar|automaattinen vastaus',\n ')',\n ].join(''),\n 'i',\n)\n\n/**\n * Why a message is an automatic reply, or `null` when it is not one — see\n * the module note for the headers and subjects read.\n */\nexport function outreachAutoReplyEvidence(\n message: Pick<OutreachThreadMessage, 'headers' | 'subject'>,\n): string | null {\n const autoSubmitted = outreachHeader(message, 'Auto-Submitted').split(';')[0].trim().toLowerCase()\n if (autoSubmitted && autoSubmitted !== 'no') return `Auto-Submitted: ${autoSubmitted}`\n for (const name of ['X-Autoreply', 'X-Autorespond']) {\n const value = outreachHeader(message, name).trim()\n if (value && value.toLowerCase() !== 'no') return `${name}: ${value}`\n }\n const precedence = outreachHeader(message, 'Precedence').trim().toLowerCase()\n if (['auto_reply', 'bulk', 'junk'].includes(precedence)) return `Precedence: ${precedence}`\n const subject = String(message?.subject ?? '').replace(/^\\s*(re|fwd?)\\s*:\\s*/i, '')\n const match = AUTO_REPLY_SUBJECT.exec(subject)\n return match ? `Subject: ${match[0].trim()}` : null\n}\n\nfunction addressesIn(header: string): string[] {\n return header\n .split(',')\n .map((part) => emailAddressOf(part))\n .filter((address): address is string => address !== null)\n}\n\nfunction readableText(message: OutreachThreadMessage): string {\n if (message.textBody && message.textBody.trim()) return message.textBody\n if (message.htmlBody && message.htmlBody.trim()) return htmlToPlainText(message.htmlBody)\n return message.snippet ?? ''\n}\n\n/** What one message in an enrollment's thread is — see the module note. */\nexport function classifyOutreachMessage(\n message: OutreachThreadMessage,\n context: OutreachMessageContext,\n): OutreachMessageClassification {\n const from = emailAddressOf(message.from)\n const base = {\n messageId: String(message.id ?? ''),\n atMs: Number.isFinite(message.internalDateMs) ? message.internalDateMs : 0,\n from,\n bounce: null,\n complaint: false,\n evidence: null,\n }\n\n const report = readOutreachDeliveryReport(message)\n if (report) {\n if (!report.kind) return { ...base, kind: 'delivery_report', evidence: 'delivery report' }\n return {\n ...base,\n kind: report.kind === 'hard' ? 'hard_bounce' : 'soft_bounce',\n bounce: {\n recipients: report.failedAddresses,\n status: report.status,\n diagnostic: report.diagnostic,\n remoteMta: report.remoteMta,\n },\n evidence: report.status ? `Status: ${report.status}` : 'delivery report',\n }\n }\n\n const self = new Set(\n (context.selfAddresses ?? [])\n .map((address) => normalizeContactEmail(address))\n .filter((address): address is string => address !== null),\n )\n if (from && self.has(from)) return { ...base, kind: 'self' }\n if (isMailerDaemonMessage(message)) {\n return { ...base, kind: 'delivery_report', evidence: 'mail system message' }\n }\n\n const unsubscribe = normalizeContactEmail(context.unsubscribeAddress)\n if (unsubscribe && addressesIn(message.to ?? '').includes(unsubscribe)) {\n return { ...base, kind: 'opt_out', evidence: `To: ${unsubscribe}` }\n }\n\n const autoReply = outreachAutoReplyEvidence(message)\n if (autoReply) return { ...base, kind: 'auto_reply', evidence: autoReply }\n\n const intent = detectOutreachOptOutIntent(readableText(message))\n if (intent.optOut) {\n return { ...base, kind: 'opt_out', complaint: intent.complaint, evidence: intent.matched }\n }\n // A subject is read only on a message that starts fresh — no `Re:`, and\n // not the thread's own — and only when it says nothing but stop.\n const subject = String(message.subject ?? '').replace(/\\s+/g, ' ').trim()\n const fresh = threadSubject(subject) === subject\n if (subject && fresh && subject !== threadSubject(context.threadSubject)) {\n const subjectIntent = detectOutreachOptOutSubject(subject)\n if (subjectIntent.optOut) {\n return { ...base, kind: 'opt_out', evidence: `Subject: ${subjectIntent.matched}` }\n }\n }\n return { ...base, kind: 'reply' }\n}\n\nexport type OutreachThreadOutcome = 'none' | 'postpone' | 'bounced' | 'replied' | 'opted_out'\n\nexport interface OutreachThreadDecisionInput extends OutreachMessageContext {\n /** The thread's messages, in any order. */\n messages: readonly OutreachThreadMessage[]\n /** The enrollment's recipient. */\n recipient: string\n /** Messages received at or before this instant were handled by an earlier run. */\n afterMs?: number | null\n /** Ids of messages an earlier run handled. */\n handledMessageIds?: readonly string[]\n}\n\nexport interface OutreachThreadDecision {\n outcome: OutreachThreadOutcome\n /** The message that decided the outcome; `null` for `none`. */\n decidedBy: OutreachMessageClassification | null\n /** Every new message, classified, oldest first. */\n classifications: OutreachMessageClassification[]\n /** A new reply called the email spam: the mailbox is paused as well. */\n complaint: boolean\n /** New hard bounces that report this recipient. */\n hardBounces: number\n /** New soft bounces that report this recipient. */\n softBounces: number\n}\n\nconst RANK: Record<OutreachThreadOutcome, number> = {\n none: 0,\n postpone: 1,\n bounced: 2,\n replied: 3,\n opted_out: 4,\n}\n\nconst OUTCOME_OF: Partial<Record<OutreachMessageKind, OutreachThreadOutcome>> = {\n auto_reply: 'postpone',\n hard_bounce: 'bounced',\n reply: 'replied',\n opt_out: 'opted_out',\n}\n\n/** What the new messages in an enrollment's thread mean for it — see the module note. */\nexport function decideOutreachThread(input: OutreachThreadDecisionInput): OutreachThreadDecision {\n const recipient = normalizeContactEmail(input.recipient)\n const handled = new Set(input.handledMessageIds ?? [])\n const afterMs = typeof input.afterMs === 'number' ? input.afterMs : null\n const fresh = [...(input.messages ?? [])]\n .filter((message) => message && !handled.has(message.id))\n .filter((message) => afterMs === null || message.internalDateMs > afterMs)\n .sort((a, b) => a.internalDateMs - b.internalDateMs)\n\n const decision: OutreachThreadDecision = {\n outcome: 'none',\n decidedBy: null,\n classifications: [],\n complaint: false,\n hardBounces: 0,\n softBounces: 0,\n }\n for (const message of fresh) {\n const classification = classifyOutreachMessage(message, input)\n decision.classifications.push(classification)\n const bounce = classification.bounce\n const aboutRecipient =\n !bounce || !bounce.recipients.length || (recipient !== null && bounce.recipients.includes(recipient))\n if (classification.kind === 'hard_bounce' && aboutRecipient) decision.hardBounces += 1\n if (classification.kind === 'soft_bounce' && aboutRecipient) decision.softBounces += 1\n if (classification.complaint) decision.complaint = true\n const outcome = OUTCOME_OF[classification.kind]\n if (!outcome || (classification.kind === 'hard_bounce' && !aboutRecipient)) continue\n if (RANK[outcome] > RANK[decision.outcome]) {\n decision.outcome = outcome\n decision.decidedBy = classification\n }\n }\n return decision\n}\n"],"names":["normalizeContactEmail","emailAddressOf","htmlToPlainText","threadSubject","readOutreachDeliveryReport","isMailerDaemonMessage","detectOutreachOptOutIntent","detectOutreachOptOutSubject","outreachHeader","AUTO_REPLY_SUBJECT","RegExp","join","outreachAutoReplyEvidence","message","autoSubmitted","split","trim","toLowerCase","name","value","precedence","includes","subject","String","replace","match","exec","addressesIn","header","map","part","filter","address","readableText","textBody","htmlBody","snippet","classifyOutreachMessage","context","from","base","messageId","id","atMs","Number","isFinite","internalDateMs","bounce","complaint","evidence","report","kind","recipients","failedAddresses","status","diagnostic","remoteMta","self","Set","selfAddresses","has","unsubscribe","unsubscribeAddress","to","autoReply","intent","optOut","matched","fresh","subjectIntent","RANK","none","postpone","bounced","replied","opted_out","OUTCOME_OF","auto_reply","hard_bounce","reply","opt_out","decideOutreachThread","input","recipient","handled","handledMessageIds","afterMs","messages","sort","a","b","decision","outcome","decidedBy","classifications","hardBounces","softBounces","classification","push","aboutRecipient","length"],"mappings":"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"}
@@ -49,7 +49,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
49
49
  * at once write one enrollment.
50
50
  *==========================================*/ import { normalizeContactEmail, contactDisplayName, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
51
51
  import { crmLeadDisplayName, crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
52
- import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
52
+ import { seenOnlyThroughGrant, visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
53
53
  import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
54
54
  import { evaluateOutreachGates } from "../engine/gates.js";
55
55
  import { outreachAttestationsFrom } from "../engine/enrollment-state.js";
@@ -124,8 +124,10 @@ async function getAllChunked(firestore, refs) {
124
124
  leadId: person.id,
125
125
  contact: data ? leadAsContact(data, input.contactGroupId) : null,
126
126
  lead: data,
127
- // A lead is the site's own record: no scope to check.
128
- visible: data !== null,
127
+ // A lead is the site's own record — unless another site only
128
+ // SHARED it with this one (AGL-3336): seen, not held, and a
129
+ // sequence is a site's own outreach.
130
+ visible: data !== null && !seenOnlyThroughGrant(data, input.hostId),
129
131
  name: data ? crmLeadDisplayName(data) : '',
130
132
  email,
131
133
  companyId: '',
@@ -140,7 +142,7 @@ async function getAllChunked(firestore, refs) {
140
142
  leadId: null,
141
143
  contact: data,
142
144
  lead: null,
143
- visible: data ? visibleToHost(data['visibleTo'], input.hostId) : false,
145
+ visible: data ? visibleToHost(data['visibleTo'], input.hostId) && !seenOnlyThroughGrant(data, input.hostId) : false,
144
146
  name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',
145
147
  email: data ? normalizeContactEmail(data['email']) : null,
146
148
  companyId: data ? String((_readContactFacet_companyId = readContactFacet(data, input.contactGroupId).companyId) != null ? _readContactFacet_companyId : '') : '',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enroll-people.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 CAN BE ENROLLED, AND WHAT THE REP STILL OWES (AGL-2980).\n *\n * The enroll dialog shows every person a view or a search named, each\n * marked eligible, blocked, or waiting on the rep; Confirm asks the same\n * question again, on the server, with what the rep supplied. Both answers\n * come from the engine's `evaluateOutreachGates` — this module only reads\n * the people and asks it the right questions.\n *\n * ## Three questions of one pure function\n *\n * The gates refuse a cold contact until the rep writes a personal line and\n * ticks the attestations, and refuse an address in an unknown country until\n * the rep confirms it is a US business address. So a preview asks:\n *\n * 1. as the person stands, with nothing confirmed — allowed means\n * ELIGIBLE;\n * 2. as if the rep confirmed everything they can — refused means BLOCKED,\n * for the reasons that remain, which no tick can clear;\n * 3. otherwise the person is WAITING ON THE REP, for what (1) found\n * missing.\n *\n * The confirmation in (2) is hypothetical and never stored: Confirm stamps\n * only what the rep ticked, with their uid and the time, and asks again.\n *\n * ## One enrollment per person per sequence, ever\n *\n * The enrollment's id is the sequence and the contact\n * (`outreachEnrollmentId`), and it is written with `create()`. A person who\n * finished, replied or was stopped is not sent the same emails a second\n * time by being enrolled again, and two members confirming the same person\n * at once write one enrollment.\n *==========================================*/\n\nimport { normalizeContactEmail, contactDisplayName, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmLeadDisplayName, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n evaluateOutreachGates,\n type OutreachGateBlock,\n type OutreachGateHold,\n type OutreachGateLookups,\n} from '../engine/gates'\nimport { outreachAttestationsFrom } from '../engine/enrollment-state'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport type {\n OutreachEnrollBlock,\n OutreachEnrollGateway,\n OutreachEnrollPreviewPerson,\n OutreachPersonRef,\n} from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * One person as the preview and the enroll read them: a contact, or a lead\n * the sequence's site holds (AGL-3234).\n *\n * ## A lead is judged as the contact it would be\n *\n * The gates, the merge fields and the country rule all read a CONTACT\n * document — its facet's sources, stage, address, company and title. A lead\n * carries the same facts in its own shape, so rather than teach every gate\n * two records, a lead is handed to them as {@link leadAsContact}: a\n * contact-shaped view whose one facet, under the sequence's group, holds\n * what the lead holds. An imported lead has no inbound source and is cold;\n * one that wrote in through a form is not; a lead is never a customer; its\n * address decides its country; and `{{contact.firstName}}` or\n * `{{contact.company}}` in a step reads the lead's name and company text,\n * so a sequence written for contacts sends to leads unchanged — which is\n * also what lets an enrollment made on a lead keep sending after the lead\n * converts and the view becomes the real contact.\n */\nexport interface OutreachEnrollCandidate {\n /** The record's own id: the contact's, or the lead's person key. */\n personId: string\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n /**\n * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the\n * contact-shaped view of it — see the module note. `null` when the record\n * no longer exists.\n */\n contact: Record<string, unknown> | null\n /** The lead document as stored, for a lead; `null` for a contact. */\n lead: Record<string, unknown> | null\n /** Whether the sequence's site may see the record. A lead is the site's own. */\n visible: boolean\n /** The name the sequence's site knows them by; `''` for none. */\n name: string\n /** The primary address, normalized, or `null` when there is none. */\n email: string | null\n /** The company the contact is filed under at that site, when there is one. */\n company: Record<string, unknown> | null\n /** A lead already converted: the contact it became. Enroll that instead. */\n convertedContactId: string | null\n /** A lead whose address the workspace already holds as a contact. Enroll that instead. */\n heldAsContactId: string | null\n}\n\n/**\n * A lead as the gates and the merge fields read a contact — see the\n * module note. The facet is the sequence's group's, which is the one every\n * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,\n * `manual`, `api`, `signup` — become the facet's source flags by kind, so\n * the inbound rule reads them as it reads a contact's.\n */\nexport function leadAsContact(\n lead: Record<string, unknown>,\n contactGroupId: string,\n): Record<string, unknown> {\n const sources: Record<string, true> = {}\n for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []) {\n const source = String(raw)\n const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source\n if (kind) sources[kind] = true\n }\n return {\n email: lead['email'],\n name: lead['name'],\n // The verdict on the address (AGL-3245) is the lead's own top-level\n // field, as it is the contact's, so the gates quote it either way.\n ...(lead['emailState'] && typeof lead['emailState'] === 'object' ? { emailState: lead['emailState'] } : {}),\n facets: {\n [contactGroupId]: {\n sources,\n ...(lead['address'] && typeof lead['address'] === 'object' ? { address: lead['address'] } : {}),\n ...(typeof lead['company'] === 'string' ? { companyName: lead['company'] } : {}),\n ...(typeof lead['jobTitle'] === 'string' ? { jobTitle: lead['jobTitle'] } : {}),\n ...(typeof lead['phone'] === 'string' ? { phone: lead['phone'] } : {}),\n ...(Array.isArray(lead['tags']) ? { tags: lead['tags'] } : {}),\n },\n },\n }\n}\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * The people, as the sequence's site knows them, and the company each\n * contact is filed under there — batched reads for everyone: the contacts\n * and the leads by id, the companies once, and for each lead one address\n * lookup, because a lead the workspace already holds as a contact is that\n * contact's to enroll.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; people: readonly OutreachPersonRef[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.people.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n // Beside the contacts, on the org (AGL-3275).\n const leads = org.collection('leads')\n const snapshots = await getAllChunked(\n firestore,\n input.people.map((person) =>\n person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id),\n ),\n )\n const read = await Promise.all(\n input.people.map(async (person, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n if (person.kind === 'lead') {\n const email = data ? normalizeContactEmail(data['email']) : null\n const held =\n data && email ? await findContactByEmail(org.collection('contacts'), email) : null\n return {\n personId: person.id,\n target: 'lead' as const,\n contactId: '',\n leadId: person.id,\n contact: data ? leadAsContact(data, input.contactGroupId) : null,\n lead: data,\n // A lead is the site's own record: no scope to check.\n visible: data !== null,\n name: data ? crmLeadDisplayName(data) : '',\n email,\n companyId: '',\n convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,\n heldAsContactId: held ? held.id : null,\n }\n }\n return {\n personId: person.id,\n target: 'contact' as const,\n contactId: person.id,\n leadId: null,\n contact: data,\n lead: null,\n visible: data ? visibleToHost(data['visibleTo'] as string[] | undefined, input.hostId) : false,\n name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',\n email: data ? normalizeContactEmail(data['email']) : null,\n companyId: data ? String(readContactFacet(data, input.contactGroupId).companyId ?? '') : '',\n convertedContactId: null,\n heldAsContactId: null,\n }\n }),\n )\n const companyIds = [...new Set(read.map((entry) => entry.companyId).filter(Boolean))]\n const companies = new Map<string, Record<string, unknown>>()\n if (companyIds.length) {\n const found = await getAllChunked(\n firestore,\n companyIds.map((id) => org.collection('companies').doc(id)),\n )\n found.forEach((snapshot, index) => {\n if (snapshot.exists) companies.set(companyIds[index], snapshot.data() as Record<string, unknown>)\n })\n }\n return read.map(({ companyId, ...candidate }) => ({\n ...candidate,\n company: companyId ? (companies.get(companyId) ?? null) : null,\n }))\n}\n\n/** The person as every answer names them. */\nfunction personOf(candidate: OutreachEnrollCandidate) {\n return {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n}\n\n/** What stands in the way before the gates are asked anything. */\nexport function outreachCandidateBlocks(\n candidate: OutreachEnrollCandidate,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollBlock[] {\n if (!candidate.contact) {\n return candidate.target === 'lead'\n ? [{ code: 'lead_missing', reason: \"This lead no longer exists in this sequence's site's CRM.\" }]\n : [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\n if (candidate.target === 'lead') {\n // A lead that converted, or whose address the workspace already holds\n // as a contact, is that contact's to enroll — one person, one record.\n if (candidate.convertedContactId) {\n blocks.push({\n code: 'lead_converted',\n reason: 'This lead was converted into a contact. Enroll the contact instead.',\n })\n } else if (candidate.heldAsContactId) {\n blocks.push({\n code: 'lead_is_contact',\n reason: 'This address is already a contact in your CRM. Enroll the contact instead.',\n })\n }\n if (crmLeadStatus(candidate.lead) === 'unqualified') {\n blocks.push({\n code: 'lead_unqualified',\n reason: 'This lead was closed as unqualified. Reopen it before enrolling.',\n })\n }\n }\n if (!candidate.visible) {\n blocks.push({\n code: 'not_in_site',\n reason: `This contact isn't in ${facts.siteName || \"this sequence's site\"}'s CRM.`,\n })\n }\n if (!candidate.email) {\n blocks.push({ code: 'no_email', reason: 'This contact has no email address.' })\n }\n if (facts.alreadyInSequence) {\n blocks.push({\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n })\n }\n return blocks\n}\n\n/** Every attestation, stamped for a hypothetical question only — never stored. */\nconst EVERYTHING_CONFIRMED: OutreachAttestations = Object.fromEntries(\n OUTREACH_ATTESTATION_KINDS.map((kind) => [kind, { uid: 'preview', atMs: 1 }]),\n)\n\nconst blocksOf = (blocks: readonly OutreachGateBlock[]): OutreachEnrollBlock[] =>\n blocks.map(({ code, reason }) => ({ code, reason }))\n\nexport interface OutreachGateQuestion {\n candidate: OutreachEnrollCandidate\n lookups: OutreachGateLookups\n /** The sequence's settings, its countries already capped by the organization's. */\n settings: OutreachSequenceSettings\n contactGroupId: string\n}\n\n/**\n * The gateway as the dialog shows it (AGL-3326): the standing the lookups\n * read, and whether the gates would hold a send into it.\n */\nfunction gatewayOf(question: OutreachGateQuestion, hold: OutreachGateHold | null): OutreachEnrollGateway | null {\n const standing = question.lookups?.gateway\n return standing ? { ...standing, hold: hold !== null } : null\n}\n\n/** Where one person stands before the rep confirms anything — see the module note. */\nexport function previewOutreachPerson(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollPreviewPerson {\n const { candidate } = question\n const person = {\n ...personOf(candidate),\n name: candidate.name,\n email: candidate.email,\n }\n const early = outreachCandidateBlocks(candidate, facts)\n if (early.length) {\n return {\n ...person,\n cold: false,\n country: null,\n status: 'blocked',\n blocks: early,\n requires: { personalLine: false, attestations: [] },\n gateway: null,\n }\n }\n const ask = (personalLine: string, attestations: OutreachAttestations) =>\n evaluateOutreachGates({\n email: candidate.email ?? '',\n contact: candidate.contact,\n contactGroupId: question.contactGroupId,\n company: candidate.company,\n settings: question.settings,\n personalLine,\n attestations,\n lookups: question.lookups,\n })\n const asIs = ask('', {})\n const known = { cold: asIs.cold, country: asIs.country.country, gateway: gatewayOf(question, asIs.hold) }\n if (asIs.allowed) {\n return {\n ...person,\n ...known,\n status: 'eligible',\n blocks: [],\n requires: { personalLine: false, attestations: [] },\n }\n }\n // The line is only checked for being present, so any sentence stands in.\n const confirmed = ask('A personal line.', EVERYTHING_CONFIRMED)\n if (!confirmed.allowed) {\n return {\n ...person,\n ...known,\n status: 'blocked',\n blocks: blocksOf(confirmed.blocks),\n requires: { personalLine: false, attestations: [] },\n }\n }\n const attestations: OutreachAttestationKind[] = asIs.cold\n ? [...asIs.missingAttestations]\n : asIs.blocks.some((block) => block.code === 'country_unknown')\n ? ['us_business_address']\n : []\n return {\n ...person,\n ...known,\n status: 'needs_confirmation',\n blocks: [],\n requires: {\n personalLine: asIs.blocks.some((block) => block.code === 'personal_line_missing'),\n attestations,\n },\n }\n}\n\nexport interface OutreachEnrollDecision {\n allowed: boolean\n blocks: OutreachEnrollBlock[]\n cold: boolean\n /** The address as the gates normalized it. */\n email: string | null\n /** What the rep confirmed, stamped with who and when. */\n attestations: OutreachAttestations\n /**\n * The gateway hold the gates would put on the send (AGL-3326), which the\n * member enrolling past the red chip releases; `null` when none applies.\n */\n hold: OutreachGateHold | null\n}\n\n/** Confirm's answer for one person, with what the rep supplied — see the module note. */\nexport function decideOutreachEnrollment(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n supplied: {\n personalLine: string\n attestations: readonly unknown[]\n uid: string\n nowMs: number\n },\n): OutreachEnrollDecision {\n const attestations = outreachAttestationsFrom(supplied.attestations, supplied.uid, supplied.nowMs)\n const early = outreachCandidateBlocks(question.candidate, facts)\n if (early.length) {\n return { allowed: false, blocks: early, cold: false, email: question.candidate.email, attestations, hold: null }\n }\n const result = evaluateOutreachGates({\n email: question.candidate.email ?? '',\n contact: question.candidate.contact,\n contactGroupId: question.contactGroupId,\n company: question.candidate.company,\n settings: question.settings,\n personalLine: supplied.personalLine,\n attestations,\n lookups: question.lookups,\n })\n return {\n allowed: result.allowed,\n blocks: blocksOf(result.blocks),\n cold: result.cold,\n email: result.email,\n attestations,\n hold: result.hold,\n }\n}\n"],"names":["normalizeContactEmail","contactDisplayName","readContactFacet","crmLeadDisplayName","crmLeadStatus","visibleToHost","findContactByEmail","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","leadAsContact","lead","contactGroupId","sources","raw","Array","isArray","source","String","kind","startsWith","email","name","emailState","facets","address","companyName","jobTitle","phone","tags","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","people","org","collection","doc","orgId","leads","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","companyId","convertedContactId","heldAsContactId","hostId","trim","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","personOf","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","uid","atMs","blocksOf","gatewayOf","question","hold","standing","lookups","gateway","previewOutreachPerson","early","cold","country","status","requires","personalLine","attestations","ask","settings","asIs","known","allowed","confirmed","missingAttestations","some","block","decideOutreachEnrollment","supplied","nowMs","result"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/enroll-people.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 CAN BE ENROLLED, AND WHAT THE REP STILL OWES (AGL-2980).\n *\n * The enroll dialog shows every person a view or a search named, each\n * marked eligible, blocked, or waiting on the rep; Confirm asks the same\n * question again, on the server, with what the rep supplied. Both answers\n * come from the engine's `evaluateOutreachGates` — this module only reads\n * the people and asks it the right questions.\n *\n * ## Three questions of one pure function\n *\n * The gates refuse a cold contact until the rep writes a personal line and\n * ticks the attestations, and refuse an address in an unknown country until\n * the rep confirms it is a US business address. So a preview asks:\n *\n * 1. as the person stands, with nothing confirmed — allowed means\n * ELIGIBLE;\n * 2. as if the rep confirmed everything they can — refused means BLOCKED,\n * for the reasons that remain, which no tick can clear;\n * 3. otherwise the person is WAITING ON THE REP, for what (1) found\n * missing.\n *\n * The confirmation in (2) is hypothetical and never stored: Confirm stamps\n * only what the rep ticked, with their uid and the time, and asks again.\n *\n * ## One enrollment per person per sequence, ever\n *\n * The enrollment's id is the sequence and the contact\n * (`outreachEnrollmentId`), and it is written with `create()`. A person who\n * finished, replied or was stopped is not sent the same emails a second\n * time by being enrolled again, and two members confirming the same person\n * at once write one enrollment.\n *==========================================*/\n\nimport { normalizeContactEmail, contactDisplayName, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport { crmLeadDisplayName, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { seenOnlyThroughGrant, visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n evaluateOutreachGates,\n type OutreachGateBlock,\n type OutreachGateHold,\n type OutreachGateLookups,\n} from '../engine/gates'\nimport { outreachAttestationsFrom } from '../engine/enrollment-state'\nimport {\n OUTREACH_ATTESTATION_KINDS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachSequenceSettings,\n} from '../model/outreach.types'\nimport type {\n OutreachEnrollBlock,\n OutreachEnrollGateway,\n OutreachEnrollPreviewPerson,\n OutreachPersonRef,\n} from '../model/outreach-api'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/**\n * One person as the preview and the enroll read them: a contact, or a lead\n * the sequence's site holds (AGL-3234).\n *\n * ## A lead is judged as the contact it would be\n *\n * The gates, the merge fields and the country rule all read a CONTACT\n * document — its facet's sources, stage, address, company and title. A lead\n * carries the same facts in its own shape, so rather than teach every gate\n * two records, a lead is handed to them as {@link leadAsContact}: a\n * contact-shaped view whose one facet, under the sequence's group, holds\n * what the lead holds. An imported lead has no inbound source and is cold;\n * one that wrote in through a form is not; a lead is never a customer; its\n * address decides its country; and `{{contact.firstName}}` or\n * `{{contact.company}}` in a step reads the lead's name and company text,\n * so a sequence written for contacts sends to leads unchanged — which is\n * also what lets an enrollment made on a lead keep sending after the lead\n * converts and the view becomes the real contact.\n */\nexport interface OutreachEnrollCandidate {\n /** The record's own id: the contact's, or the lead's person key. */\n personId: string\n target: 'contact' | 'lead'\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key; `null` for a contact. */\n leadId: string | null\n /**\n * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the\n * contact-shaped view of it — see the module note. `null` when the record\n * no longer exists.\n */\n contact: Record<string, unknown> | null\n /** The lead document as stored, for a lead; `null` for a contact. */\n lead: Record<string, unknown> | null\n /** Whether the sequence's site may see the record. A lead is the site's own. */\n visible: boolean\n /** The name the sequence's site knows them by; `''` for none. */\n name: string\n /** The primary address, normalized, or `null` when there is none. */\n email: string | null\n /** The company the contact is filed under at that site, when there is one. */\n company: Record<string, unknown> | null\n /** A lead already converted: the contact it became. Enroll that instead. */\n convertedContactId: string | null\n /** A lead whose address the workspace already holds as a contact. Enroll that instead. */\n heldAsContactId: string | null\n}\n\n/**\n * A lead as the gates and the merge fields read a contact — see the\n * module note. The facet is the sequence's group's, which is the one every\n * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,\n * `manual`, `api`, `signup` — become the facet's source flags by kind, so\n * the inbound rule reads them as it reads a contact's.\n */\nexport function leadAsContact(\n lead: Record<string, unknown>,\n contactGroupId: string,\n): Record<string, unknown> {\n const sources: Record<string, true> = {}\n for (const raw of Array.isArray(lead['sources']) ? lead['sources'] : []) {\n const source = String(raw)\n const kind = source.startsWith('form:') ? 'form' : source === 'signup' ? 'member' : source\n if (kind) sources[kind] = true\n }\n return {\n email: lead['email'],\n name: lead['name'],\n // The verdict on the address (AGL-3245) is the lead's own top-level\n // field, as it is the contact's, so the gates quote it either way.\n ...(lead['emailState'] && typeof lead['emailState'] === 'object' ? { emailState: lead['emailState'] } : {}),\n facets: {\n [contactGroupId]: {\n sources,\n ...(lead['address'] && typeof lead['address'] === 'object' ? { address: lead['address'] } : {}),\n ...(typeof lead['company'] === 'string' ? { companyName: lead['company'] } : {}),\n ...(typeof lead['jobTitle'] === 'string' ? { jobTitle: lead['jobTitle'] } : {}),\n ...(typeof lead['phone'] === 'string' ? { phone: lead['phone'] } : {}),\n ...(Array.isArray(lead['tags']) ? { tags: lead['tags'] } : {}),\n },\n },\n }\n}\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * The people, as the sequence's site knows them, and the company each\n * contact is filed under there — batched reads for everyone: the contacts\n * and the leads by id, the companies once, and for each lead one address\n * lookup, because a lead the workspace already holds as a contact is that\n * contact's to enroll.\n */\nexport async function readOutreachEnrollCandidates(\n firestore: Firestore,\n input: { orgId: string; hostId: string; contactGroupId: string; people: readonly OutreachPersonRef[] },\n): Promise<OutreachEnrollCandidate[]> {\n if (!input.people.length) return []\n const org = firestore.collection('orgs').doc(input.orgId)\n // Beside the contacts, on the org (AGL-3275).\n const leads = org.collection('leads')\n const snapshots = await getAllChunked(\n firestore,\n input.people.map((person) =>\n person.kind === 'lead' ? leads.doc(person.id) : org.collection('contacts').doc(person.id),\n ),\n )\n const read = await Promise.all(\n input.people.map(async (person, index) => {\n const data = snapshots[index]?.exists ? (snapshots[index].data() as Record<string, unknown>) : null\n if (person.kind === 'lead') {\n const email = data ? normalizeContactEmail(data['email']) : null\n const held =\n data && email ? await findContactByEmail(org.collection('contacts'), email) : null\n return {\n personId: person.id,\n target: 'lead' as const,\n contactId: '',\n leadId: person.id,\n contact: data ? leadAsContact(data, input.contactGroupId) : null,\n lead: data,\n // A lead is the site's own record — unless another site only\n // SHARED it with this one (AGL-3336): seen, not held, and a\n // sequence is a site's own outreach.\n visible: data !== null && !seenOnlyThroughGrant(data, input.hostId),\n name: data ? crmLeadDisplayName(data) : '',\n email,\n companyId: '',\n convertedContactId: data && typeof data['convertedContactId'] === 'string' ? data['convertedContactId'] : null,\n heldAsContactId: held ? held.id : null,\n }\n }\n return {\n personId: person.id,\n target: 'contact' as const,\n contactId: person.id,\n leadId: null,\n contact: data,\n lead: null,\n visible: data\n ? visibleToHost(data['visibleTo'] as string[] | undefined, input.hostId) &&\n !seenOnlyThroughGrant(data, input.hostId)\n : false,\n name: data ? contactDisplayName(data, input.contactGroupId).trim() : '',\n email: data ? normalizeContactEmail(data['email']) : null,\n companyId: data ? String(readContactFacet(data, input.contactGroupId).companyId ?? '') : '',\n convertedContactId: null,\n heldAsContactId: null,\n }\n }),\n )\n const companyIds = [...new Set(read.map((entry) => entry.companyId).filter(Boolean))]\n const companies = new Map<string, Record<string, unknown>>()\n if (companyIds.length) {\n const found = await getAllChunked(\n firestore,\n companyIds.map((id) => org.collection('companies').doc(id)),\n )\n found.forEach((snapshot, index) => {\n if (snapshot.exists) companies.set(companyIds[index], snapshot.data() as Record<string, unknown>)\n })\n }\n return read.map(({ companyId, ...candidate }) => ({\n ...candidate,\n company: companyId ? (companies.get(companyId) ?? null) : null,\n }))\n}\n\n/** The person as every answer names them. */\nfunction personOf(candidate: OutreachEnrollCandidate) {\n return {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n}\n\n/** What stands in the way before the gates are asked anything. */\nexport function outreachCandidateBlocks(\n candidate: OutreachEnrollCandidate,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollBlock[] {\n if (!candidate.contact) {\n return candidate.target === 'lead'\n ? [{ code: 'lead_missing', reason: \"This lead no longer exists in this sequence's site's CRM.\" }]\n : [{ code: 'contact_missing', reason: 'This contact no longer exists in the CRM.' }]\n }\n const blocks: OutreachEnrollBlock[] = []\n if (candidate.target === 'lead') {\n // A lead that converted, or whose address the workspace already holds\n // as a contact, is that contact's to enroll — one person, one record.\n if (candidate.convertedContactId) {\n blocks.push({\n code: 'lead_converted',\n reason: 'This lead was converted into a contact. Enroll the contact instead.',\n })\n } else if (candidate.heldAsContactId) {\n blocks.push({\n code: 'lead_is_contact',\n reason: 'This address is already a contact in your CRM. Enroll the contact instead.',\n })\n }\n if (crmLeadStatus(candidate.lead) === 'unqualified') {\n blocks.push({\n code: 'lead_unqualified',\n reason: 'This lead was closed as unqualified. Reopen it before enrolling.',\n })\n }\n }\n if (!candidate.visible) {\n blocks.push({\n code: 'not_in_site',\n reason: `This contact isn't in ${facts.siteName || \"this sequence's site\"}'s CRM.`,\n })\n }\n if (!candidate.email) {\n blocks.push({ code: 'no_email', reason: 'This contact has no email address.' })\n }\n if (facts.alreadyInSequence) {\n blocks.push({\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n })\n }\n return blocks\n}\n\n/** Every attestation, stamped for a hypothetical question only — never stored. */\nconst EVERYTHING_CONFIRMED: OutreachAttestations = Object.fromEntries(\n OUTREACH_ATTESTATION_KINDS.map((kind) => [kind, { uid: 'preview', atMs: 1 }]),\n)\n\nconst blocksOf = (blocks: readonly OutreachGateBlock[]): OutreachEnrollBlock[] =>\n blocks.map(({ code, reason }) => ({ code, reason }))\n\nexport interface OutreachGateQuestion {\n candidate: OutreachEnrollCandidate\n lookups: OutreachGateLookups\n /** The sequence's settings, its countries already capped by the organization's. */\n settings: OutreachSequenceSettings\n contactGroupId: string\n}\n\n/**\n * The gateway as the dialog shows it (AGL-3326): the standing the lookups\n * read, and whether the gates would hold a send into it.\n */\nfunction gatewayOf(question: OutreachGateQuestion, hold: OutreachGateHold | null): OutreachEnrollGateway | null {\n const standing = question.lookups?.gateway\n return standing ? { ...standing, hold: hold !== null } : null\n}\n\n/** Where one person stands before the rep confirms anything — see the module note. */\nexport function previewOutreachPerson(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n): OutreachEnrollPreviewPerson {\n const { candidate } = question\n const person = {\n ...personOf(candidate),\n name: candidate.name,\n email: candidate.email,\n }\n const early = outreachCandidateBlocks(candidate, facts)\n if (early.length) {\n return {\n ...person,\n cold: false,\n country: null,\n status: 'blocked',\n blocks: early,\n requires: { personalLine: false, attestations: [] },\n gateway: null,\n }\n }\n const ask = (personalLine: string, attestations: OutreachAttestations) =>\n evaluateOutreachGates({\n email: candidate.email ?? '',\n contact: candidate.contact,\n contactGroupId: question.contactGroupId,\n company: candidate.company,\n settings: question.settings,\n personalLine,\n attestations,\n lookups: question.lookups,\n })\n const asIs = ask('', {})\n const known = { cold: asIs.cold, country: asIs.country.country, gateway: gatewayOf(question, asIs.hold) }\n if (asIs.allowed) {\n return {\n ...person,\n ...known,\n status: 'eligible',\n blocks: [],\n requires: { personalLine: false, attestations: [] },\n }\n }\n // The line is only checked for being present, so any sentence stands in.\n const confirmed = ask('A personal line.', EVERYTHING_CONFIRMED)\n if (!confirmed.allowed) {\n return {\n ...person,\n ...known,\n status: 'blocked',\n blocks: blocksOf(confirmed.blocks),\n requires: { personalLine: false, attestations: [] },\n }\n }\n const attestations: OutreachAttestationKind[] = asIs.cold\n ? [...asIs.missingAttestations]\n : asIs.blocks.some((block) => block.code === 'country_unknown')\n ? ['us_business_address']\n : []\n return {\n ...person,\n ...known,\n status: 'needs_confirmation',\n blocks: [],\n requires: {\n personalLine: asIs.blocks.some((block) => block.code === 'personal_line_missing'),\n attestations,\n },\n }\n}\n\nexport interface OutreachEnrollDecision {\n allowed: boolean\n blocks: OutreachEnrollBlock[]\n cold: boolean\n /** The address as the gates normalized it. */\n email: string | null\n /** What the rep confirmed, stamped with who and when. */\n attestations: OutreachAttestations\n /**\n * The gateway hold the gates would put on the send (AGL-3326), which the\n * member enrolling past the red chip releases; `null` when none applies.\n */\n hold: OutreachGateHold | null\n}\n\n/** Confirm's answer for one person, with what the rep supplied — see the module note. */\nexport function decideOutreachEnrollment(\n question: OutreachGateQuestion,\n facts: { siteName: string; alreadyInSequence: boolean },\n supplied: {\n personalLine: string\n attestations: readonly unknown[]\n uid: string\n nowMs: number\n },\n): OutreachEnrollDecision {\n const attestations = outreachAttestationsFrom(supplied.attestations, supplied.uid, supplied.nowMs)\n const early = outreachCandidateBlocks(question.candidate, facts)\n if (early.length) {\n return { allowed: false, blocks: early, cold: false, email: question.candidate.email, attestations, hold: null }\n }\n const result = evaluateOutreachGates({\n email: question.candidate.email ?? '',\n contact: question.candidate.contact,\n contactGroupId: question.contactGroupId,\n company: question.candidate.company,\n settings: question.settings,\n personalLine: supplied.personalLine,\n attestations,\n lookups: question.lookups,\n })\n return {\n allowed: result.allowed,\n blocks: blocksOf(result.blocks),\n cold: result.cold,\n email: result.email,\n attestations,\n hold: result.hold,\n }\n}\n"],"names":["normalizeContactEmail","contactDisplayName","readContactFacet","crmLeadDisplayName","crmLeadStatus","seenOnlyThroughGrant","visibleToHost","findContactByEmail","evaluateOutreachGates","outreachAttestationsFrom","OUTREACH_ATTESTATION_KINDS","leadAsContact","lead","contactGroupId","sources","raw","Array","isArray","source","String","kind","startsWith","email","name","emailState","facets","address","companyName","jobTitle","phone","tags","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","readOutreachEnrollCandidates","input","people","org","collection","doc","orgId","leads","map","person","id","read","Promise","all","index","data","exists","held","personId","target","contactId","leadId","contact","visible","hostId","companyId","convertedContactId","heldAsContactId","trim","companyIds","Set","entry","filter","Boolean","companies","Map","found","forEach","snapshot","set","candidate","company","get","personOf","outreachCandidateBlocks","facts","code","reason","blocks","siteName","alreadyInSequence","EVERYTHING_CONFIRMED","Object","fromEntries","uid","atMs","blocksOf","gatewayOf","question","hold","standing","lookups","gateway","previewOutreachPerson","early","cold","country","status","requires","personalLine","attestations","ask","settings","asIs","known","allowed","confirmed","missingAttestations","some","block","decideOutreachEnrollment","supplied","nowMs","result"],"mappings":"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"}
@@ -38,9 +38,10 @@
38
38
  * timed out. One failed list does not blank the others.
39
39
  */ import { consentGroupTopicState } from "@aglyn/aglyn/app-utils/consent-groups";
40
40
  import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
41
- import { EMAIL_TOPIC_SALES, readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
41
+ import { readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
42
+ import { SALES_TOPIC_ID } from "../model/sales-topic.js";
42
43
  import { personKey } from "@aglyn/aglyn/app-utils/person-key";
43
- import { loadCrmInboundRoster } from "@aglyn/tenant-data-admin/server/crm-inbound-email";
44
+ import { loadMemberAddressRoster } from "@aglyn/tenant-data-admin/server/member-email-aliases";
44
45
  import { EMAIL_SUPPRESSIONS_COLLECTION, HOST_SUPPRESSIONS_SUBCOLLECTION } from "@aglyn/tenant-data-admin/server/email-suppression";
45
46
  import { lookupOutreachDoNotContact, lookupOutreachDoNotContactDomains } from "../storage/do-not-contact-store.js";
46
47
  import { readOutreachGatewayStandings } from "../storage/domain-intel-store.js";
@@ -117,7 +118,7 @@ async function getAllChunked(firestore, refs) {
117
118
  (answers)=>answers.some(Boolean), 'site suppression'),
118
119
  keyedGroupLookup(firestore, people, hostIds, (host, key)=>host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key), (snapshot)=>{
119
120
  var _ref;
120
- return readTopicSubscriptionState(((_ref = snapshot.exists ? snapshot.get('topics') : null) != null ? _ref : {})[EMAIL_TOPIC_SALES]);
121
+ return readTopicSubscriptionState(((_ref = snapshot.exists ? snapshot.get('topics') : null) != null ? _ref : {})[SALES_TOPIC_ID]);
121
122
  }, // The group's standing, the way the topic filter folds it: leaving the
122
123
  // sales stream anywhere in the group is leaving the sender, and a
123
124
  // sibling's pending confirmation counts only where the org said the
@@ -137,7 +138,7 @@ async function getAllChunked(firestore, refs) {
137
138
  console.error('[outreach] mail gateway lookup failed; reading as unchecked', error);
138
139
  return new Map();
139
140
  }),
140
- loadCrmInboundRoster(firestore, orgId).catch((error)=>{
141
+ loadMemberAddressRoster(firestore, orgId).catch((error)=>{
141
142
  console.error('[outreach] roster lookup failed; reading as unchecked', error);
142
143
  return null;
143
144
  }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { consentGroupTopicState } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n EMAIL_TOPIC_SALES,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadCrmInboundRoster } from '@aglyn/tenant-data-admin/server/crm-inbound-email'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport { type OutreachDomainIntelReadInput, readOutreachGatewayStandings } from '../storage/domain-intel-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n /**\n * Every site in the sequence site's consent group, the site included —\n * `consentGroupForHost(org, hostId).hostIds`. An org that declared several\n * sites one sender is one sender to the person who unsubscribed from any of\n * them, so the site suppression list and the sales opt-outs are read on\n * each. Omitted, the site alone.\n */\n consentHostIds?: readonly string[]\n /**\n * The same group's `awaitsConfirmation`: whether a sibling's pending\n * confirmation counts in the sales stream's standing as the sequence\n * site's own does. Omitted, it does not.\n */\n consentAwaitsConfirmation?: boolean\n /**\n * The MX resolver and the clock the domain intel is read with (AGL-3326):\n * a domain's gateway is looked up once a week and cached on the org, and\n * its ledger is read for the windows the hold and the chip judge.\n */\n gateway: OutreachDomainIntelReadInput\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/**\n * The same keyed lookup on every site of a consent group, folded into one\n * answer per person by `fold`. A read that failed anywhere is `null` for the\n * people it covered, so a sibling's unreadable list refuses as the site's own\n * would. A group of one is one lookup, exactly as the site alone.\n */\nasync function keyedGroupLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n hostIds: readonly string[],\n collection: (host: FirebaseFirestore.DocumentReference, key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n fold: (answers: readonly T[]) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const bySite = await Promise.all(\n hostIds.map((id) => {\n const host = firestore.collection('hosts').doc(id)\n return keyedLookup(firestore, people, (key) => collection(host, key), answer, label)\n }),\n )\n if (bySite.length === 1) return bySite[0]\n const folded = new Map<string, T | null>()\n for (const person of people) {\n const answers = bySite.map((site) => site.get(person.personId) ?? null)\n folded.set(\n person.personId,\n answers.some((answer) => answer === null) ? null : fold(answers as T[]),\n )\n }\n return folded\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n // The sequence's site first, then the rest of its consent group.\n const hostIds = [\n hostId,\n ...new Set((input.consentHostIds ?? []).filter((id) => id && id !== hostId)),\n ]\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, doNotContactDomain, gateway, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedGroupLookup(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n // An entry on any site of the group suppresses.\n (answers) => answers.some(Boolean),\n 'site suppression',\n ),\n keyedGroupLookup<TopicSubscriptionState>(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[EMAIL_TOPIC_SALES],\n ),\n // The group's standing, the way the topic filter folds it: leaving the\n // sales stream anywhere in the group is leaving the sender, and a\n // sibling's pending confirmation counts only where the org said the\n // group waits. What a pending standing means for a sales email is the\n // gates' question, not this one.\n (states) =>\n consentGroupTopicState(\n { awaitsConfirmation: input.consentAwaitsConfirmation === true },\n states,\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The domain half of the same list (AGL-3244), one `getAll` over the\n // distinct domains the people are at.\n lookupOutreachDoNotContactDomains(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The mail gateway in front of each domain and its record here\n // (AGL-3326), one read over the distinct domains and one over the\n // gateways they name.\n readOutreachGatewayStandings(\n firestore,\n orgId,\n people.map((person) => person.email),\n input.gateway,\n ).catch((error: unknown) => {\n console.error('[outreach] mail gateway lookup failed; reading as unchecked', error)\n return new Map<string, null>()\n }),\n loadCrmInboundRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n doNotContactDomain: doNotContactDomain.get(person.email) ?? null,\n gateway: gateway.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["consentGroupTopicState","CRM_COLLECTIONS","EMAIL_TOPIC_SALES","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","personKey","loadCrmInboundRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","readOutreachGatewayStandings","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","keyedGroupLookup","hostIds","fold","bySite","Promise","all","id","host","doc","folded","site","get","some","readOutreachGateLookups","input","orgId","hostId","Set","consentHostIds","filter","enrollments","activities","platform","sales","doNotContact","doNotContactDomain","gateway","roster","perPerson","snapshot","exists","Boolean","states","awaitsConfirmation","consentAwaitsConfirmation","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/gate-lookups.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE LOOKUPS THE ENROLLMENT GATES DECIDE ON (AGL-2980).\n *\n * `evaluateOutreachGates` is pure: it reads a contact as stored and the\n * RESULTS of the reads the runtime makes. This is those reads, for many\n * people at once — the enroll preview asks about everyone a view names in\n * one pass, and the sending runtime asks about one person before each send\n * with the same function, so the two can never consult different lists.\n *\n * Server only. Every list keyed by address is read with one `getAll` over\n * `personKey` ids — the suppression lists, the sales topic opt-outs, the\n * do-not-contact list — so a lookup costs one round trip however many\n * people it covers, and needs no index that could go missing. The roster\n * is read once. The two per-person facts that are queries — the person's\n * other enrollments and whether they ever wrote in — are equality filters\n * on single fields.\n *\n * ## A read that failed is `null`, and `null` refuses\n *\n * Each lookup is caught on its own and answers `null` for everyone it\n * covered, which the gates read as \"we couldn't check\" and refuse: a\n * person who asked not to be emailed must not be emailed because a read\n * timed out. One failed list does not blank the others.\n */\n\nimport { consentGroupTopicState } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionState,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport { SALES_TOPIC_ID } from '../model/sales-topic'\nimport type { MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { loadMemberAddressRoster } from '@aglyn/tenant-data-admin/server/member-email-aliases'\nimport {\n EMAIL_SUPPRESSIONS_COLLECTION,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n} from '@aglyn/tenant-data-admin/server/email-suppression'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport type { OutreachEnrollmentStatus } from '../model/outreach.types'\nimport {\n lookupOutreachDoNotContact,\n lookupOutreachDoNotContactDomains,\n} from '../storage/do-not-contact-store'\nimport { type OutreachDomainIntelReadInput, readOutreachGatewayStandings } from '../storage/domain-intel-store'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * One person to look up: the record they are, and the address the steps go\n * to. `personId` keys the answer — the contact's id, or the lead's person\n * key (AGL-3234); a lead has no contact to look up enrollments or inbound\n * email by, so those reads go by the lead instead.\n */\nexport interface OutreachGateLookupPerson {\n personId: string\n /** The contact's id, or `null` for a lead. */\n contactId: string | null\n /** The lead's person key, or `null` for a contact. */\n leadId: string | null\n email: string\n}\n\nexport interface ReadOutreachGateLookupsInput {\n orgId: string\n /** The sequence's site: whose suppression list and topic opt-outs apply. */\n hostId: string\n /**\n * Every site in the sequence site's consent group, the site included —\n * `consentGroupForHost(org, hostId).hostIds`. An org that declared several\n * sites one sender is one sender to the person who unsubscribed from any of\n * them, so the site suppression list and the sales opt-outs are read on\n * each. Omitted, the site alone.\n */\n consentHostIds?: readonly string[]\n /**\n * The same group's `awaitsConfirmation`: whether a sibling's pending\n * confirmation counts in the sales stream's standing as the sequence\n * site's own does. Omitted, it does not.\n */\n consentAwaitsConfirmation?: boolean\n /**\n * The MX resolver and the clock the domain intel is read with (AGL-3326):\n * a domain's gateway is looked up once a week and cached on the org, and\n * its ledger is read for the windows the hold and the chip judge.\n */\n gateway: OutreachDomainIntelReadInput\n people: readonly OutreachGateLookupPerson[]\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** `getAll` takes this many references at most per call. */\nconst GET_ALL_CHUNK = 300\n\nasync function getAllChunked(\n firestore: Firestore,\n refs: readonly FirebaseFirestore.DocumentReference[],\n): Promise<FirebaseFirestore.DocumentSnapshot[]> {\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < refs.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...refs.slice(start, start + GET_ALL_CHUNK))))\n }\n return snapshots\n}\n\n/**\n * One keyed list, read for every person at once: `answer` turns each\n * person's document into their answer. An address that cannot be keyed has\n * no document anywhere and is answered as unchecked, and so is everyone\n * when the read fails.\n */\nasync function keyedLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n collection: (key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const answers = new Map<string, T | null>()\n const keyed: Array<{ personId: string; key: string }> = []\n for (const person of people) {\n const key = personKey(person.email)\n if (key) keyed.push({ personId: person.personId, key })\n else answers.set(person.personId, null)\n }\n if (!keyed.length) return answers\n try {\n const snapshots = await getAllChunked(\n firestore,\n keyed.map((entry) => collection(entry.key)),\n )\n keyed.forEach((entry, index) => answers.set(entry.personId, answer(snapshots[index])))\n } catch (error) {\n console.error(`[outreach] ${label} lookup failed; reading as unchecked`, error)\n for (const entry of keyed) answers.set(entry.personId, null)\n }\n return answers\n}\n\n/**\n * The same keyed lookup on every site of a consent group, folded into one\n * answer per person by `fold`. A read that failed anywhere is `null` for the\n * people it covered, so a sibling's unreadable list refuses as the site's own\n * would. A group of one is one lookup, exactly as the site alone.\n */\nasync function keyedGroupLookup<T>(\n firestore: Firestore,\n people: readonly OutreachGateLookupPerson[],\n hostIds: readonly string[],\n collection: (host: FirebaseFirestore.DocumentReference, key: string) => FirebaseFirestore.DocumentReference,\n answer: (snapshot: FirebaseFirestore.DocumentSnapshot) => T,\n fold: (answers: readonly T[]) => T,\n label: string,\n): Promise<Map<string, T | null>> {\n const bySite = await Promise.all(\n hostIds.map((id) => {\n const host = firestore.collection('hosts').doc(id)\n return keyedLookup(firestore, people, (key) => collection(host, key), answer, label)\n }),\n )\n if (bySite.length === 1) return bySite[0]\n const folded = new Map<string, T | null>()\n for (const person of people) {\n const answers = bySite.map((site) => site.get(person.personId) ?? null)\n folded.set(\n person.personId,\n answers.some((answer) => answer === null) ? null : fold(answers as T[]),\n )\n }\n return folded\n}\n\n/** Every lookup the gates take, for each person, keyed by person id. */\nexport async function readOutreachGateLookups(\n firestore: Firestore,\n input: ReadOutreachGateLookupsInput,\n): Promise<Map<string, OutreachGateLookups>> {\n const { orgId, hostId, people } = input\n // The sequence's site first, then the rest of its consent group.\n const hostIds = [\n hostId,\n ...new Set((input.consentHostIds ?? []).filter((id) => id && id !== hostId)),\n ]\n const enrollments = outreachOrgCollection(firestore, orgId, 'enrollments')\n const activities = firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.activities)\n\n const [platform, site, sales, doNotContact, doNotContactDomain, gateway, roster, perPerson] = await Promise.all([\n keyedLookup(\n firestore,\n people,\n (key) => firestore.collection(EMAIL_SUPPRESSIONS_COLLECTION).doc(key),\n // A released record no longer suppresses.\n (snapshot) => snapshot.exists && !snapshot.get('releasedAt'),\n 'platform suppression',\n ),\n keyedGroupLookup(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key),\n (snapshot) => snapshot.exists,\n // An entry on any site of the group suppresses.\n (answers) => answers.some(Boolean),\n 'site suppression',\n ),\n keyedGroupLookup<TopicSubscriptionState>(\n firestore,\n people,\n hostIds,\n (host, key) => host.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key),\n (snapshot) =>\n readTopicSubscriptionState(\n ((snapshot.exists ? snapshot.get('topics') : null) ?? {})[SALES_TOPIC_ID],\n ),\n // The group's standing, the way the topic filter folds it: leaving the\n // sales stream anywhere in the group is leaving the sender, and a\n // sibling's pending confirmation counts only where the org said the\n // group waits. What a pending standing means for a sales email is the\n // gates' question, not this one.\n (states) =>\n consentGroupTopicState(\n { awaitsConfirmation: input.consentAwaitsConfirmation === true },\n states,\n ),\n 'sales topic',\n ),\n lookupOutreachDoNotContact(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The domain half of the same list (AGL-3244), one `getAll` over the\n // distinct domains the people are at.\n lookupOutreachDoNotContactDomains(\n firestore,\n orgId,\n people.map((person) => person.email),\n ),\n // The mail gateway in front of each domain and its record here\n // (AGL-3326), one read over the distinct domains and one over the\n // gateways they name.\n readOutreachGatewayStandings(\n firestore,\n orgId,\n people.map((person) => person.email),\n input.gateway,\n ).catch((error: unknown) => {\n console.error('[outreach] mail gateway lookup failed; reading as unchecked', error)\n return new Map<string, null>()\n }),\n loadMemberAddressRoster(firestore, orgId).catch((error: unknown): MemberAddresses[] | null => {\n console.error('[outreach] roster lookup failed; reading as unchecked', error)\n return null\n }),\n Promise.all(\n people.map(async (person) => {\n const open = await Promise.all([\n person.contactId\n ? enrollments.where('contactId', '==', person.contactId).get()\n : enrollments.where('leadId', '==', person.leadId ?? '').get(),\n enrollments.where('email', '==', person.email).get(),\n ])\n .then(([byContact, byEmail]) => {\n const found = new Map<string, { id: string; sequenceId: string; status: OutreachEnrollmentStatus }>()\n for (const doc of [...byContact.docs, ...byEmail.docs]) {\n found.set(doc.id, {\n id: doc.id,\n sequenceId: String(doc.get('sequenceId') ?? ''),\n status: doc.get('status') as OutreachEnrollmentStatus,\n })\n }\n return [...found.values()]\n })\n .catch((error: unknown) => {\n console.error('[outreach] enrollment lookup failed; reading as unchecked', error)\n return null\n })\n // An email the person wrote: filed on the contact, or on the lead\n // while they were one (AGL-3234).\n const inbound = await activities\n .where(person.contactId ? 'contactId' : 'leadId', '==', person.contactId ?? person.leadId ?? '')\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n .then((snapshot) => !snapshot.empty)\n .catch((error: unknown) => {\n console.error('[outreach] inbound email lookup failed; reading as cold', error)\n return null\n })\n return { personId: person.personId, open, inbound }\n }),\n ),\n ])\n\n const byPerson = new Map(perPerson.map((entry) => [entry.personId, entry]))\n const lookups = new Map<string, OutreachGateLookups>()\n for (const person of people) {\n const own = byPerson.get(person.personId)\n lookups.set(person.personId, {\n platformSuppressed: platform.get(person.personId) ?? null,\n hostSuppressed: site.get(person.personId) ?? null,\n salesTopicState: sales.get(person.personId) ?? null,\n doNotContact: doNotContact.get(person.email) ?? null,\n doNotContactDomain: doNotContactDomain.get(person.email) ?? null,\n gateway: gateway.get(person.email) ?? null,\n workspaceMembers: roster,\n openEnrollments: own?.open ?? null,\n hasInboundEmail: own?.inbound ?? null,\n })\n }\n return lookups\n}\n"],"names":["consentGroupTopicState","CRM_COLLECTIONS","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","SALES_TOPIC_ID","personKey","loadMemberAddressRoster","EMAIL_SUPPRESSIONS_COLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","lookupOutreachDoNotContact","lookupOutreachDoNotContactDomains","readOutreachGatewayStandings","outreachOrgCollection","GET_ALL_CHUNK","getAllChunked","firestore","refs","snapshots","start","length","push","getAll","slice","keyedLookup","people","collection","answer","label","answers","Map","keyed","person","key","email","personId","set","map","entry","forEach","index","error","console","keyedGroupLookup","hostIds","fold","bySite","Promise","all","id","host","doc","folded","site","get","some","readOutreachGateLookups","input","orgId","hostId","Set","consentHostIds","filter","enrollments","activities","platform","sales","doNotContact","doNotContactDomain","gateway","roster","perPerson","snapshot","exists","Boolean","states","awaitsConfirmation","consentAwaitsConfirmation","catch","open","contactId","where","leadId","then","byContact","byEmail","found","docs","sequenceId","String","status","values","inbound","limit","empty","byPerson","lookups","own","platformSuppressed","hostSuppressed","salesTopicState","workspaceMembers","openEnrollments","hasInboundEmail"],"mappings":"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"}
@@ -31,7 +31,7 @@ export interface OutreachStepOverrideRead {
31
31
  * One override request as a stored override, before the caller's stamp.
32
32
  * `null` when the request is not one.
33
33
  */
34
- export declare function readOutreachStepOverrideRequest(raw: unknown, steps: readonly OutreachSequenceStep[]): {
34
+ export declare function readOutreachStepOverrideRequest(raw: unknown, steps: readonly OutreachSequenceStep[], startStepIndex?: number): {
35
35
  stepIndex: number;
36
36
  override: Omit<OutreachStepOverride, 'draftedAtMs' | 'draftedByUid'>;
37
37
  issues: OutreachValidationIssue[];
@@ -46,7 +46,9 @@ export declare function readOutreachStepOverrideRequest(raw: unknown, steps: rea
46
46
  export declare function readOutreachStepOverrideRequests(raw: unknown, steps: readonly OutreachSequenceStep[], stamp: {
47
47
  uid: string;
48
48
  nowMs: number;
49
- }): OutreachStepOverrideRead;
49
+ },
50
+ /** The step the enrollment begins at (AGL-3228); the first when absent. */
51
+ startStepIndex?: number): OutreachStepOverrideRead;
50
52
  /** Whether a read carries an error that keeps its overrides from being stored. */
51
53
  export declare function outreachStepOverridesRefused(read: OutreachStepOverrideRead): boolean;
52
54
  /** The requests' shape, for a caller that builds one from stored overrides. */
@@ -35,7 +35,7 @@ import { OUTREACH_STEP_OVERRIDE_SOURCES } from "../model/outreach.types.js";
35
35
  /**
36
36
  * One override request as a stored override, before the caller's stamp.
37
37
  * `null` when the request is not one.
38
- */ export function readOutreachStepOverrideRequest(raw, steps) {
38
+ */ export function readOutreachStepOverrideRequest(raw, steps, startStepIndex = 0) {
39
39
  if (!raw || typeof raw !== 'object') return {
40
40
  refusal: 'A curated step is an object.'
41
41
  };
@@ -47,7 +47,14 @@ import { OUTREACH_STEP_OVERRIDE_SOURCES } from "../model/outreach.types.js";
47
47
  refusal: 'A curated step names one of the sequence’s email steps.'
48
48
  };
49
49
  }
50
- const startsThread = !isInThreadEmailStep(steps, stepIndex);
50
+ // A step the enrollment begins past (AGL-3228) is never sent, so a copy
51
+ // of it would be stored for nothing.
52
+ if (stepIndex < startStepIndex) {
53
+ return {
54
+ refusal: `Step ${stepIndex + 1} is skipped for this person, so it has nothing to curate.`
55
+ };
56
+ }
57
+ const startsThread = !isInThreadEmailStep(steps, stepIndex, startStepIndex);
51
58
  const body = typeof entry['body'] === 'string' ? entry['body'].replace(/\r\n?/g, '\n') : '';
52
59
  const subject = startsThread && typeof entry['subject'] === 'string' ? entry['subject'] : undefined;
53
60
  const source = entry['source'];
@@ -84,12 +91,12 @@ import { OUTREACH_STEP_OVERRIDE_SOURCES } from "../model/outreach.types.js";
84
91
  * Every override a request carries, read and validated, and stamped with
85
92
  * who confirmed them and when. An empty or absent list reads as no
86
93
  * overrides and no issues.
87
- */ export function readOutreachStepOverrideRequests(raw, steps, stamp) {
94
+ */ export function readOutreachStepOverrideRequests(raw, steps, stamp, /** The step the enrollment begins at (AGL-3228); the first when absent. */ startStepIndex = 0) {
88
95
  const list = Array.isArray(raw) ? raw : [];
89
96
  const overrides = {};
90
97
  const issues = [];
91
98
  for (const entry of list){
92
- const read = readOutreachStepOverrideRequest(entry, steps);
99
+ const read = readOutreachStepOverrideRequest(entry, steps, startStepIndex);
93
100
  if ('refusal' in read) return {
94
101
  overrides: {},
95
102
  issues: [],
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/step-overrides.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 A REQUEST MAY STORE AS ONE PERSON'S COPY OF A STEP (AGL-3324).\n *\n * The enroll route, the preview route and the curate-save route all take\n * the console's `OutreachStepOverrideRequest`, and all three read it the\n * same way: a step index that names an EMAIL step of the sequence, a body,\n * a subject only where the email starts a thread, and the validator's\n * verdict on each. A request that names a task step, a step the sequence\n * does not have, or the same step twice is refused whole — nothing here is\n * guessed, because what is read here is what gets sent.\n *\n * The prompt and the model ride through untouched, for the audit log; who\n * confirmed and when are the route's to stamp, never the request's to\n * claim.\n */\n\nimport { CRM_EMAIL_BODY_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n hasOutreachValidationErrors,\n isInThreadEmailStep,\n validateOutreachCuratedStep,\n type OutreachValidationIssue,\n} from '../engine/sequence-validation'\nimport type { OutreachStepOverrideRequest } from '../model/outreach-api'\nimport {\n OUTREACH_STEP_OVERRIDE_SOURCES,\n type OutreachSequenceStep,\n type OutreachStepOverride,\n type OutreachStepOverrides,\n} from '../model/outreach.types'\n\n/** The most of a prompt an override keeps for the audit log. */\nexport const OUTREACH_OVERRIDE_PROMPT_MAX = 12_000\n\nexport interface OutreachStepOverrideRead {\n /** The overrides as they would be stored, keyed by step index, stamped by the caller. */\n overrides: OutreachStepOverrides\n /** Every issue, each path prefixed `stepOverrides.<index>`. */\n issues: OutreachValidationIssue[]\n /** Why the request could not be read at all, in a sentence; `null` when it could. */\n refusal: string | null\n}\n\n/**\n * One override request as a stored override, before the caller's stamp.\n * `null` when the request is not one.\n */\nexport function readOutreachStepOverrideRequest(\n raw: unknown,\n steps: readonly OutreachSequenceStep[],\n): { stepIndex: number; override: Omit<OutreachStepOverride, 'draftedAtMs' | 'draftedByUid'>; issues: OutreachValidationIssue[] } | { refusal: string } {\n if (!raw || typeof raw !== 'object') return { refusal: 'A curated step is an object.' }\n const entry = raw as Record<string, unknown>\n const stepIndex = Number(entry['stepIndex'])\n const step = Number.isInteger(stepIndex) && stepIndex >= 0 ? steps[stepIndex] : undefined\n if (step?.kind !== 'email') {\n return { refusal: 'A curated step names one of the sequence’s email steps.' }\n }\n const startsThread = !isInThreadEmailStep(steps, stepIndex)\n const body = typeof entry['body'] === 'string' ? entry['body'].replace(/\\r\\n?/g, '\\n') : ''\n const subject = startsThread && typeof entry['subject'] === 'string' ? entry['subject'] : undefined\n const source = entry['source']\n if (!(OUTREACH_STEP_OVERRIDE_SOURCES as readonly unknown[]).includes(source)) {\n return { refusal: 'A curated step says who wrote it: the AI, or the member.' }\n }\n const issues = validateOutreachCuratedStep({ subject, body, startsThread }, `stepOverrides.${stepIndex}`)\n const prompt = typeof entry['prompt'] === 'string' ? entry['prompt'].slice(0, OUTREACH_OVERRIDE_PROMPT_MAX) : ''\n const model = typeof entry['model'] === 'string' ? entry['model'].trim().slice(0, 200) : ''\n return {\n stepIndex,\n issues,\n override: {\n ...(subject !== undefined ? { subject: subject.replace(/\\s+/g, ' ').trim() } : {}),\n body: body.slice(0, CRM_EMAIL_BODY_MAX),\n source: source as OutreachStepOverride['source'],\n ...(source === 'ai' && entry['edited'] === true ? { edited: true } : {}),\n ...(source === 'ai' && prompt ? { prompt } : {}),\n ...(source === 'ai' && model ? { model } : {}),\n },\n }\n}\n\n/**\n * Every override a request carries, read and validated, and stamped with\n * who confirmed them and when. An empty or absent list reads as no\n * overrides and no issues.\n */\nexport function readOutreachStepOverrideRequests(\n raw: unknown,\n steps: readonly OutreachSequenceStep[],\n stamp: { uid: string; nowMs: number },\n): OutreachStepOverrideRead {\n const list = Array.isArray(raw) ? (raw as unknown[]) : []\n const overrides: OutreachStepOverrides = {}\n const issues: OutreachValidationIssue[] = []\n for (const entry of list) {\n const read = readOutreachStepOverrideRequest(entry, steps)\n if ('refusal' in read) return { overrides: {}, issues: [], refusal: read.refusal }\n if (overrides[String(read.stepIndex)]) {\n return { overrides: {}, issues: [], refusal: 'A step is curated once per person.' }\n }\n issues.push(...read.issues)\n overrides[String(read.stepIndex)] = {\n ...read.override,\n draftedAtMs: stamp.nowMs,\n draftedByUid: stamp.uid,\n }\n }\n return { overrides, issues, refusal: null }\n}\n\n/** Whether a read carries an error that keeps its overrides from being stored. */\nexport function outreachStepOverridesRefused(read: OutreachStepOverrideRead): boolean {\n return read.refusal !== null || hasOutreachValidationErrors(read.issues)\n}\n\n/** The requests' shape, for a caller that builds one from stored overrides. */\nexport function outreachStepOverrideRequests(\n overrides: OutreachStepOverrides | null | undefined,\n): OutreachStepOverrideRequest[] {\n return Object.entries(overrides ?? {})\n .map(([key, override]) => ({\n stepIndex: Number(key),\n ...(typeof override.subject === 'string' ? { subject: override.subject } : {}),\n body: override.body ?? '',\n source: override.source,\n ...(override.edited ? { edited: true } : {}),\n ...(override.prompt ? { prompt: override.prompt } : {}),\n ...(override.model ? { model: override.model } : {}),\n }))\n .sort((a, b) => a.stepIndex - b.stepIndex)\n}\n"],"names":["CRM_EMAIL_BODY_MAX","hasOutreachValidationErrors","isInThreadEmailStep","validateOutreachCuratedStep","OUTREACH_STEP_OVERRIDE_SOURCES","OUTREACH_OVERRIDE_PROMPT_MAX","readOutreachStepOverrideRequest","raw","steps","refusal","entry","stepIndex","Number","step","isInteger","undefined","kind","startsThread","body","replace","subject","source","includes","issues","prompt","slice","model","trim","override","edited","readOutreachStepOverrideRequests","stamp","list","Array","isArray","overrides","read","String","push","draftedAtMs","nowMs","draftedByUid","uid","outreachStepOverridesRefused","outreachStepOverrideRequests","Object","entries","map","key","sort","a","b"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/enrollment/step-overrides.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 A REQUEST MAY STORE AS ONE PERSON'S COPY OF A STEP (AGL-3324).\n *\n * The enroll route, the preview route and the curate-save route all take\n * the console's `OutreachStepOverrideRequest`, and all three read it the\n * same way: a step index that names an EMAIL step of the sequence, a body,\n * a subject only where the email starts a thread, and the validator's\n * verdict on each. A request that names a task step, a step the sequence\n * does not have, or the same step twice is refused whole — nothing here is\n * guessed, because what is read here is what gets sent.\n *\n * The prompt and the model ride through untouched, for the audit log; who\n * confirmed and when are the route's to stamp, never the request's to\n * claim.\n */\n\nimport { CRM_EMAIL_BODY_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n hasOutreachValidationErrors,\n isInThreadEmailStep,\n validateOutreachCuratedStep,\n type OutreachValidationIssue,\n} from '../engine/sequence-validation'\nimport type { OutreachStepOverrideRequest } from '../model/outreach-api'\nimport {\n OUTREACH_STEP_OVERRIDE_SOURCES,\n type OutreachSequenceStep,\n type OutreachStepOverride,\n type OutreachStepOverrides,\n} from '../model/outreach.types'\n\n/** The most of a prompt an override keeps for the audit log. */\nexport const OUTREACH_OVERRIDE_PROMPT_MAX = 12_000\n\nexport interface OutreachStepOverrideRead {\n /** The overrides as they would be stored, keyed by step index, stamped by the caller. */\n overrides: OutreachStepOverrides\n /** Every issue, each path prefixed `stepOverrides.<index>`. */\n issues: OutreachValidationIssue[]\n /** Why the request could not be read at all, in a sentence; `null` when it could. */\n refusal: string | null\n}\n\n/**\n * One override request as a stored override, before the caller's stamp.\n * `null` when the request is not one.\n */\nexport function readOutreachStepOverrideRequest(\n raw: unknown,\n steps: readonly OutreachSequenceStep[],\n startStepIndex = 0,\n): { stepIndex: number; override: Omit<OutreachStepOverride, 'draftedAtMs' | 'draftedByUid'>; issues: OutreachValidationIssue[] } | { refusal: string } {\n if (!raw || typeof raw !== 'object') return { refusal: 'A curated step is an object.' }\n const entry = raw as Record<string, unknown>\n const stepIndex = Number(entry['stepIndex'])\n const step = Number.isInteger(stepIndex) && stepIndex >= 0 ? steps[stepIndex] : undefined\n if (step?.kind !== 'email') {\n return { refusal: 'A curated step names one of the sequence’s email steps.' }\n }\n // A step the enrollment begins past (AGL-3228) is never sent, so a copy\n // of it would be stored for nothing.\n if (stepIndex < startStepIndex) {\n return { refusal: `Step ${stepIndex + 1} is skipped for this person, so it has nothing to curate.` }\n }\n const startsThread = !isInThreadEmailStep(steps, stepIndex, startStepIndex)\n const body = typeof entry['body'] === 'string' ? entry['body'].replace(/\\r\\n?/g, '\\n') : ''\n const subject = startsThread && typeof entry['subject'] === 'string' ? entry['subject'] : undefined\n const source = entry['source']\n if (!(OUTREACH_STEP_OVERRIDE_SOURCES as readonly unknown[]).includes(source)) {\n return { refusal: 'A curated step says who wrote it: the AI, or the member.' }\n }\n const issues = validateOutreachCuratedStep({ subject, body, startsThread }, `stepOverrides.${stepIndex}`)\n const prompt = typeof entry['prompt'] === 'string' ? entry['prompt'].slice(0, OUTREACH_OVERRIDE_PROMPT_MAX) : ''\n const model = typeof entry['model'] === 'string' ? entry['model'].trim().slice(0, 200) : ''\n return {\n stepIndex,\n issues,\n override: {\n ...(subject !== undefined ? { subject: subject.replace(/\\s+/g, ' ').trim() } : {}),\n body: body.slice(0, CRM_EMAIL_BODY_MAX),\n source: source as OutreachStepOverride['source'],\n ...(source === 'ai' && entry['edited'] === true ? { edited: true } : {}),\n ...(source === 'ai' && prompt ? { prompt } : {}),\n ...(source === 'ai' && model ? { model } : {}),\n },\n }\n}\n\n/**\n * Every override a request carries, read and validated, and stamped with\n * who confirmed them and when. An empty or absent list reads as no\n * overrides and no issues.\n */\nexport function readOutreachStepOverrideRequests(\n raw: unknown,\n steps: readonly OutreachSequenceStep[],\n stamp: { uid: string; nowMs: number },\n /** The step the enrollment begins at (AGL-3228); the first when absent. */\n startStepIndex = 0,\n): OutreachStepOverrideRead {\n const list = Array.isArray(raw) ? (raw as unknown[]) : []\n const overrides: OutreachStepOverrides = {}\n const issues: OutreachValidationIssue[] = []\n for (const entry of list) {\n const read = readOutreachStepOverrideRequest(entry, steps, startStepIndex)\n if ('refusal' in read) return { overrides: {}, issues: [], refusal: read.refusal }\n if (overrides[String(read.stepIndex)]) {\n return { overrides: {}, issues: [], refusal: 'A step is curated once per person.' }\n }\n issues.push(...read.issues)\n overrides[String(read.stepIndex)] = {\n ...read.override,\n draftedAtMs: stamp.nowMs,\n draftedByUid: stamp.uid,\n }\n }\n return { overrides, issues, refusal: null }\n}\n\n/** Whether a read carries an error that keeps its overrides from being stored. */\nexport function outreachStepOverridesRefused(read: OutreachStepOverrideRead): boolean {\n return read.refusal !== null || hasOutreachValidationErrors(read.issues)\n}\n\n/** The requests' shape, for a caller that builds one from stored overrides. */\nexport function outreachStepOverrideRequests(\n overrides: OutreachStepOverrides | null | undefined,\n): OutreachStepOverrideRequest[] {\n return Object.entries(overrides ?? {})\n .map(([key, override]) => ({\n stepIndex: Number(key),\n ...(typeof override.subject === 'string' ? { subject: override.subject } : {}),\n body: override.body ?? '',\n source: override.source,\n ...(override.edited ? { edited: true } : {}),\n ...(override.prompt ? { prompt: override.prompt } : {}),\n ...(override.model ? { model: override.model } : {}),\n }))\n .sort((a, b) => a.stepIndex - b.stepIndex)\n}\n"],"names":["CRM_EMAIL_BODY_MAX","hasOutreachValidationErrors","isInThreadEmailStep","validateOutreachCuratedStep","OUTREACH_STEP_OVERRIDE_SOURCES","OUTREACH_OVERRIDE_PROMPT_MAX","readOutreachStepOverrideRequest","raw","steps","startStepIndex","refusal","entry","stepIndex","Number","step","isInteger","undefined","kind","startsThread","body","replace","subject","source","includes","issues","prompt","slice","model","trim","override","edited","readOutreachStepOverrideRequests","stamp","list","Array","isArray","overrides","read","String","push","draftedAtMs","nowMs","draftedByUid","uid","outreachStepOverridesRefused","outreachStepOverrideRequests","Object","entries","map","key","sort","a","b"],"mappings":"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"}
@@ -22,7 +22,11 @@ import type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.type
22
22
  * Client-safe — types, constants and two string functions.
23
23
  */
24
24
  /** Every reason a mailbox route refuses with, for a caller to branch on. */
25
- export type OutreachApiRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'not-configured' | 'method-not-allowed' | 'invalid-request' | 'mailbox-not-found' | 'not-your-mailbox' | 'mailbox-limit' | 'state-invalid' | 'state-expired' | 'state-user-mismatch' | 'state-org-mismatch' | 'state-replayed' | 'state-superseded' | 'code-rejected' | 'refresh-token-missing' | 'scopes-missing' | 'identity-unverified' | 'account-mismatch' | 'reconnect-required' | 'invalid-settings' | 'rate-limited' | 'google-unavailable';
25
+ export type OutreachApiRefusalReason = 'unauthenticated' | 'email-unverified' | 'org-required' | 'not-a-member' | 'not-org-wide' | 'permission' | 'entitlement' | 'not-configured' | 'method-not-allowed' | 'invalid-request' | 'mailbox-not-found' | 'not-your-mailbox' | 'mailbox-limit' | 'state-invalid' | 'state-expired' | 'state-user-mismatch' | 'state-org-mismatch' | 'state-replayed' | 'state-superseded' | 'code-rejected' | 'refresh-token-missing' | 'scopes-missing' | 'identity-unverified' | 'account-mismatch' | 'reconnect-required' | 'invalid-settings' | 'rate-limited'
26
+ /** The Google account has no Gmail service: off for it, or not yet provisioned. */
27
+ | 'mail-service-unavailable'
28
+ /** Google refused the account itself, such as an administrator barring Gmail API access. */
29
+ | 'google-refused' | 'google-unavailable';
26
30
  export interface OutreachApiRefusal {
27
31
  error: string;
28
32
  reason: OutreachApiRefusalReason;