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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/compliance-section.js +72 -34
  3. package/src/lib/components/compliance-section.js.map +1 -1
  4. package/src/lib/components/enroll-dialog.d.ts +12 -2
  5. package/src/lib/components/enroll-dialog.js +72 -18
  6. package/src/lib/components/enroll-dialog.js.map +1 -1
  7. package/src/lib/components/enrollment-detail.d.ts +5 -1
  8. package/src/lib/components/enrollment-detail.js +12 -6
  9. package/src/lib/components/enrollment-detail.js.map +1 -1
  10. package/src/lib/components/outreach-console-sections.d.ts +1 -1
  11. package/src/lib/components/outreach-console-sections.js +1 -1
  12. package/src/lib/components/outreach-console-sections.js.map +1 -1
  13. package/src/lib/components/sequence-editor.js +85 -40
  14. package/src/lib/components/sequence-editor.js.map +1 -1
  15. package/src/lib/components/use-outreach-api.d.ts +2 -1
  16. package/src/lib/components/use-outreach-api.js +5 -3
  17. package/src/lib/components/use-outreach-api.js.map +1 -1
  18. package/src/lib/engine/compose.d.ts +1 -1
  19. package/src/lib/engine/compose.js +8 -4
  20. package/src/lib/engine/compose.js.map +1 -1
  21. package/src/lib/engine/enrollment-activity.d.ts +9 -1
  22. package/src/lib/engine/enrollment-activity.js +15 -2
  23. package/src/lib/engine/enrollment-activity.js.map +1 -1
  24. package/src/lib/engine/enrollment-state.d.ts +33 -2
  25. package/src/lib/engine/enrollment-state.js +66 -7
  26. package/src/lib/engine/enrollment-state.js.map +1 -1
  27. package/src/lib/engine/sending-capacity.d.ts +1 -1
  28. package/src/lib/engine/sending-capacity.js +1 -1
  29. package/src/lib/engine/sending-capacity.js.map +1 -1
  30. package/src/lib/engine/sequence-validation.d.ts +20 -3
  31. package/src/lib/engine/sequence-validation.js +30 -4
  32. package/src/lib/engine/sequence-validation.js.map +1 -1
  33. package/src/lib/enrollment/enroll-people.js +6 -4
  34. package/src/lib/enrollment/enroll-people.js.map +1 -1
  35. package/src/lib/enrollment/step-overrides.d.ts +4 -2
  36. package/src/lib/enrollment/step-overrides.js +11 -4
  37. package/src/lib/enrollment/step-overrides.js.map +1 -1
  38. package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
  39. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  40. package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
  41. package/src/lib/mailboxes/mailbox-routes.js +10 -7
  42. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  43. package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
  44. package/src/lib/mailboxes/mailbox-settings.js +1 -1
  45. package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
  46. package/src/lib/model/enrollment-timeline.d.ts +1 -1
  47. package/src/lib/model/enrollment-timeline.js +22 -3
  48. package/src/lib/model/enrollment-timeline.js.map +1 -1
  49. package/src/lib/model/outreach-api.d.ts +19 -1
  50. package/src/lib/model/outreach-api.js.map +1 -1
  51. package/src/lib/model/outreach.types.d.ts +25 -0
  52. package/src/lib/model/outreach.types.js.map +1 -1
  53. package/src/lib/model/step-preview.d.ts +2 -0
  54. package/src/lib/model/step-preview.js +11 -7
  55. package/src/lib/model/step-preview.js.map +1 -1
  56. package/src/lib/model/stored-records.js +26 -0
  57. package/src/lib/model/stored-records.js.map +1 -1
  58. package/src/lib/plugin.js +1 -1
  59. package/src/lib/plugin.js.map +1 -1
  60. package/src/lib/routes/enroll-routes.d.ts +6 -0
  61. package/src/lib/routes/enroll-routes.js +21 -6
  62. package/src/lib/routes/enroll-routes.js.map +1 -1
  63. package/src/lib/routes/preview-routes.js +14 -5
  64. package/src/lib/routes/preview-routes.js.map +1 -1
  65. package/src/lib/routes/step-test-routes.js +3 -3
  66. package/src/lib/routes/step-test-routes.js.map +1 -1
  67. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  68. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  69. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
  70. package/src/lib/transport/gmail-errors.d.ts +27 -0
  71. package/src/lib/transport/gmail-errors.js +56 -0
  72. package/src/lib/transport/gmail-errors.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.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 SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/** The index of the first email step, or `-1` for a sequence with none. */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n): number {\n return (steps ?? []).findIndex(isEmailStep)\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3395): a sequence stays plain text unless it asks\n // for the HTML part, and every one written before the setting did not.\n countOpens: data['countOpens'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","findIndex","isInThreadEmailStep","index","first","replyInThread","outreachSubjectHasReplyPrefix","subject","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","length","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","countOpens","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","list","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/sequence-validation.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 SEQUENCE MAY BE (AGL-2979).\n *\n * One validator for the three readers that need it: the editor, which shows\n * every issue beside the field it belongs to; the route that saves, which\n * refuses a sequence with an error; and activation, which also refuses while\n * the organization's footer cannot be written. Each issue names its field by\n * a dotted path (`steps.2.subject`) and says what to do in a sentence.\n *\n * ERRORS AND WARNINGS. An error is a sequence the engine cannot send or the\n * rules forbid: a fifth email, a first email with no subject, a `Re:` on a\n * message that answers nothing. A warning is a sequence that sends and\n * probably is not what its author meant — a merge field nobody fills, a\n * first email that never uses the personal line the rep writes at\n * enrollment.\n *==========================================*/\n\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport { readListUnsubscribeSetting } from '@aglyn/shared-util-email/list-unsubscribe'\nimport {\n CRM_MERGE_FIELDS,\n type CrmMergeFieldGroup,\n crmMergeFieldsIn,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport {\n OUTREACH_DEFAULT_ALLOWED_COUNTRIES,\n OUTREACH_MAX_EMAIL_STEPS,\n OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS,\n OUTREACH_MAX_STEPS,\n OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS,\n OUTREACH_TASK_KINDS,\n type OutreachEmailStep,\n type OutreachOrgSettings,\n type OutreachSendWindow,\n type OutreachSequence,\n type OutreachSequenceSettings,\n type OutreachSequenceStep,\n} from '../model/outreach.types'\nimport { outreachBodyLinks } from './click-tracking'\n\n/** The merge field the rep's signal sentence is written into a step with. */\nexport const OUTREACH_PERSONAL_LINE_FIELD = 'enrollment.personalLine'\n\n/** A merge field as the Outreach editor's picker offers it. */\nexport interface OutreachMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup | 'Enrollment'\n}\n\n/**\n * Every field an Outreach step may use: the CRM's, which the composer fills\n * through the CRM's own resolver, and the enrollment's.\n */\nexport const OUTREACH_MERGE_FIELDS: readonly OutreachMergeFieldDefinition[] = [\n ...CRM_MERGE_FIELDS,\n { key: OUTREACH_PERSONAL_LINE_FIELD, label: 'Personal line', group: 'Enrollment' },\n]\n\n/** The longest name a sequence carries. */\nexport const OUTREACH_SEQUENCE_NAME_MAX = 120\n\n/** The longest title a task step carries. */\nexport const OUTREACH_TASK_TITLE_MAX = 200\n\nexport type OutreachValidationSeverity = 'error' | 'warning'\n\nexport type OutreachValidationCode =\n | 'name_required'\n | 'name_too_long'\n | 'mailbox_required'\n | 'steps_required'\n | 'too_many_steps'\n | 'too_many_email_steps'\n | 'step_id_required'\n | 'duplicate_step_id'\n | 'unknown_step_kind'\n | 'delay_invalid'\n | 'subject_required'\n | 'subject_too_long'\n | 'subject_reply_prefix'\n | 'body_required'\n | 'body_and_template'\n | 'body_too_long'\n | 'unknown_merge_field'\n | 'personal_line_unused'\n | 'task_kind_invalid'\n | 'task_title_required'\n | 'task_title_too_long'\n | 'window_days_invalid'\n | 'window_hours_invalid'\n | 'countries_required'\n | 'country_invalid'\n | 'legal_name_required'\n | 'postal_address_required'\n | 'too_many_links'\n | 'discount_language'\n | 'not_plain_text'\n\nexport interface OutreachValidationIssue {\n /** The field, dotted: `name`, `steps.2.subject`, `settings.window`, `orgSettings.postalAddress`. */\n path: string\n code: OutreachValidationCode\n message: string\n severity: OutreachValidationSeverity\n}\n\n/** Whether any issue in the list stops a save or an activation. */\nexport function hasOutreachValidationErrors(\n issues: readonly OutreachValidationIssue[],\n): boolean {\n return issues.some((issue) => issue.severity === 'error')\n}\n\nconst error = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'error' })\n\nconst warning = (\n path: string,\n code: OutreachValidationCode,\n message: string,\n): OutreachValidationIssue => ({ path, code, message, severity: 'warning' })\n\nconst asText = (value: unknown): string => (typeof value === 'string' ? value : '')\n\nconst isEmailStep = (step: OutreachSequenceStep | null | undefined): step is OutreachEmailStep =>\n step?.kind === 'email'\n\n/**\n * The index of the first email step at or after `fromIndex` — the first\n * email an enrollment that began there sends (AGL-3228) — or `-1` when\n * there is none.\n */\nexport function firstEmailStepIndex(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n fromIndex = 0,\n): number {\n const list = steps ?? []\n for (let index = Math.max(0, fromIndex); index < list.length; index += 1) {\n if (isEmailStep(list[index])) return index\n }\n return -1\n}\n\n/**\n * Whether the email at `index` is sent as a reply in the thread an earlier\n * email started: every email after the first unless it says otherwise. The\n * first email starts the thread whatever its `replyInThread` says.\n *\n * `startStepIndex` is where the enrollment began (AGL-3228): the first email\n * it sends from there starts the thread, because the steps before it were\n * sent by hand, if at all, and there is no email of ours to answer.\n */\nexport function isInThreadEmailStep(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n startStepIndex = 0,\n): boolean {\n const step = steps?.[index]\n if (!isEmailStep(step)) return false\n const first = firstEmailStepIndex(steps, startStepIndex)\n return index > first && step.replyInThread !== false\n}\n\n/**\n * The subject an email step starts a thread with, before merge fields\n * (AGL-3228). A step written to start a thread carries its own. One written\n * as a reply may start the thread only for an enrollment that began at it,\n * and its subject field is not what its author wrote for it — so it takes\n * the sequence's first email's subject, the one the person already had by\n * hand.\n */\nexport function outreachThreadStartSubject(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n index: number,\n): string {\n const step = steps?.[index]\n if (!isEmailStep(step)) return ''\n if (!isInThreadEmailStep(steps, index)) return asText(step.subject)\n const first = steps?.[firstEmailStepIndex(steps)]\n return isEmailStep(first) ? asText(first.subject) : ''\n}\n\n/**\n * Whether a subject opens with a reply or forward prefix — `Re:`, `Fwd:`,\n * `AW:` — which on an email that answers nothing tells the recipient a\n * conversation exists that does not. The prefixes are the CRM thread\n * reader's, so the two agree on what one looks like.\n */\nexport function outreachSubjectHasReplyPrefix(subject: unknown): boolean {\n const collapsed = asText(subject).replace(/\\s+/g, ' ').trim()\n return collapsed !== '' && crmThreadSubject(collapsed) !== collapsed.slice(0, CRM_EMAIL_SUBJECT_MAX)\n}\n\nconst WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']\n\n/** A sending window's shape: at least one weekday, and an opening before a close. */\nexport function validateOutreachSendWindow(\n window: OutreachSendWindow | null | undefined,\n path = 'window',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const days = Array.isArray(window?.days) ? window.days : []\n const daysValid =\n days.length > 0 &&\n days.every((day) => Number.isInteger(day) && day >= 0 && day < WEEKDAY_NAMES.length) &&\n new Set(days).size === days.length\n if (!daysValid) {\n issues.push(\n error(`${path}.days`, 'window_days_invalid', 'Pick at least one day of the week to send on.'),\n )\n }\n const start = window?.startMinute\n const end = window?.endMinute\n const hoursValid =\n Number.isInteger(start) &&\n Number.isInteger(end) &&\n start >= 0 &&\n end <= 24 * 60 &&\n start < end\n if (!hoursValid) {\n issues.push(\n error(\n path,\n 'window_hours_invalid',\n 'The sending hours need a start time before the end time, within one day.',\n ),\n )\n }\n return issues\n}\n\n/**\n * The allowed countries, as stored or typed, reduced to unique uppercase\n * alpha-2 codes in the order given. Anything that is not two letters is\n * dropped, so a caller that needs to report it checks the raw value.\n */\nexport function normalizeOutreachAllowedCountries(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n const codes: string[] = []\n for (const entry of value) {\n const code = asText(entry).trim().toUpperCase()\n if (/^[A-Z]{2}$/.test(code) && !codes.includes(code)) codes.push(code)\n }\n return codes\n}\n\n/**\n * A stored settings map with the defaults applied: the United States alone,\n * no customers, the mailbox's own window. A field a writer never set reads\n * as its default rather than as a permission nobody gave.\n */\nexport function readOutreachSequenceSettings(\n raw: Partial<OutreachSequenceSettings> | Record<string, unknown> | null | undefined,\n): OutreachSequenceSettings {\n const data = (raw ?? {}) as Record<string, unknown>\n const countries = normalizeOutreachAllowedCountries(data['allowedCountries'])\n const window = data['window'] as OutreachSendWindow | null | undefined\n return {\n window: window && typeof window === 'object' ? window : null,\n allowedCountries: Array.isArray(data['allowedCountries'])\n ? countries\n : [...OUTREACH_DEFAULT_ALLOWED_COUNTRIES],\n allowCustomers: data['allowCustomers'] === true,\n // Absent is off (AGL-3239). Every sequence written before the setting\n // existed reads as untracked, which is what it was.\n trackClicks: data['trackClicks'] === true,\n // Absent is off (AGL-3395): a sequence stays plain text unless it asks\n // for the HTML part, and every one written before the setting did not.\n countOpens: data['countOpens'] === true,\n // Absent is off (AGL-3296): a sequence is one-to-one mail, and every\n // sequence written before the setting existed reads as headerless.\n // Read by the shared reader campaigns use too (AGL-3307), with the\n // sequence's own default.\n listUnsubscribe: readListUnsubscribeSetting(data['listUnsubscribe'], false),\n }\n}\n\nexport function validateOutreachSequenceSettings(\n settings: OutreachSequenceSettings | null | undefined,\n path = 'settings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (settings?.window) {\n issues.push(...validateOutreachSendWindow(settings.window, `${path}.window`))\n }\n const raw = Array.isArray(settings?.allowedCountries) ? settings.allowedCountries : []\n if (!raw.length) {\n issues.push(\n error(\n `${path}.allowedCountries`,\n 'countries_required',\n 'Choose at least one country this sequence may send to.',\n ),\n )\n }\n raw.forEach((entry, index) => {\n if (!/^[A-Z]{2}$/.test(asText(entry))) {\n issues.push(\n error(\n `${path}.allowedCountries.${index}`,\n 'country_invalid',\n `\"${asText(entry)}\" isn't a two-letter country code, like US.`,\n ),\n )\n }\n })\n return issues\n}\n\nconst KNOWN_MERGE_FIELDS = new Set(OUTREACH_MERGE_FIELDS.map((field) => field.key))\n\nfunction mergeFieldWarnings(text: string, path: string): OutreachValidationIssue[] {\n return crmMergeFieldsIn(text)\n .filter((key) => !KNOWN_MERGE_FIELDS.has(key))\n .map((key) =>\n warning(\n path,\n 'unknown_merge_field',\n `{{${key}}} isn't a merge field, so it will be sent empty.`,\n ),\n )\n}\n\nfunction delayIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachSequenceStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const delay = step.delayBusinessDays\n const followsAnEmail = isEmailStep(step) && index > firstEmailStepIndex(steps)\n const min = followsAnEmail ? OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS : 0\n if (Number.isInteger(delay) && delay >= min && delay <= OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS) {\n return []\n }\n const message =\n min > 0\n ? `Wait ${min}–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this email.`\n : `Wait 0–${OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS} business days before this step.`\n return [error(`${path}.delayBusinessDays`, 'delay_invalid', message)]\n}\n\nfunction emailStepIssues(\n steps: readonly OutreachSequenceStep[],\n step: OutreachEmailStep,\n index: number,\n path: string,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const first = firstEmailStepIndex(steps) === index\n const startsThread = !isInThreadEmailStep(steps, index)\n const subject = asText(step.subject)\n if (startsThread) {\n if (!subject.trim()) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_required',\n first\n ? 'The first email needs a subject.'\n : 'An email that starts a new thread needs a subject.',\n ),\n )\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_too_long',\n `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`,\n ),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(step.body)\n const templateId = asText(step.templateId).trim()\n if (body.trim() && templateId) {\n issues.push(\n error(\n `${path}.body`,\n 'body_and_template',\n 'Write the email here or pick a template, not both.',\n ),\n )\n } else if (!body.trim() && !templateId) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email, or pick a template.'))\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n if (body.trim()) {\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n if (first && !crmMergeFieldsIn(body).includes(OUTREACH_PERSONAL_LINE_FIELD)) {\n issues.push(\n warning(\n `${path}.body`,\n 'personal_line_unused',\n `The first email doesn't use {{${OUTREACH_PERSONAL_LINE_FIELD}}}, so the line written for each person won't appear.`,\n ),\n )\n }\n }\n return issues\n}\n\n/** The steps of a sequence: their count, their ids, and each one's own fields. */\nexport function validateOutreachSteps(\n steps: readonly OutreachSequenceStep[] | null | undefined,\n path = 'steps',\n): OutreachValidationIssue[] {\n const list = Array.isArray(steps) ? steps : []\n const issues: OutreachValidationIssue[] = []\n if (!list.length) {\n issues.push(error(path, 'steps_required', 'Add at least one step.'))\n return issues\n }\n if (list.length > OUTREACH_MAX_STEPS) {\n issues.push(\n error(path, 'too_many_steps', `A sequence holds at most ${OUTREACH_MAX_STEPS} steps.`),\n )\n }\n const emailCount = list.filter(isEmailStep).length\n if (emailCount > OUTREACH_MAX_EMAIL_STEPS) {\n issues.push(\n error(\n path,\n 'too_many_email_steps',\n `A sequence sends at most ${OUTREACH_MAX_EMAIL_STEPS} emails to one person.`,\n ),\n )\n }\n const seen = new Set<string>()\n list.forEach((step, index) => {\n const stepPath = `${path}.${index}`\n const id = asText(step?.id).trim()\n if (!id) {\n issues.push(error(`${stepPath}.id`, 'step_id_required', 'Every step needs an id.'))\n } else if (seen.has(id)) {\n issues.push(error(`${stepPath}.id`, 'duplicate_step_id', 'Two steps share an id.'))\n }\n seen.add(id)\n if (isEmailStep(step)) {\n issues.push(...delayIssues(list, step, index, stepPath))\n issues.push(...emailStepIssues(list, step, index, stepPath))\n } else if (step?.kind === 'task') {\n issues.push(...delayIssues(list, step, index, stepPath))\n if (!(OUTREACH_TASK_KINDS as readonly string[]).includes(step.taskKind)) {\n issues.push(\n error(`${stepPath}.taskKind`, 'task_kind_invalid', 'Pick LinkedIn, Call or To-do.'),\n )\n }\n const title = asText(step.title)\n if (!title.trim()) {\n issues.push(error(`${stepPath}.title`, 'task_title_required', 'Give the task a title.'))\n } else if (title.length > OUTREACH_TASK_TITLE_MAX) {\n issues.push(\n error(\n `${stepPath}.title`,\n 'task_title_too_long',\n `Keep the title under ${OUTREACH_TASK_TITLE_MAX} characters.`,\n ),\n )\n }\n } else {\n issues.push(\n error(stepPath, 'unknown_step_kind', 'A step is an email or a task.'),\n )\n }\n })\n return issues\n}\n\n/** Everything a stored sequence has to be before it may be saved. */\nexport function validateOutreachSequence(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const name = asText(sequence?.name)\n if (!name.trim()) {\n issues.push(error('name', 'name_required', 'Name the sequence.'))\n } else if (name.length > OUTREACH_SEQUENCE_NAME_MAX) {\n issues.push(\n error('name', 'name_too_long', `Keep the name under ${OUTREACH_SEQUENCE_NAME_MAX} characters.`),\n )\n }\n const steps = sequence?.steps ?? []\n if (steps.some(isEmailStep) && !asText(sequence?.mailboxId).trim()) {\n issues.push(\n error('mailboxId', 'mailbox_required', 'Choose the mailbox this sequence sends from.'),\n )\n }\n issues.push(...validateOutreachSteps(steps))\n issues.push(...validateOutreachSequenceSettings(sequence?.settings))\n return issues\n}\n\n/*==========================================\n * A CURATED STEP (AGL-3324).\n *\n * One person's own copy of a step is held to everything a step is held to —\n * a subject where the email starts a thread, a body, the lengths, the merge\n * fields — and to the outbound playbook's rules the sequence editor trusts\n * its author with but an AI draft cannot be trusted with: one link at most,\n * list price only, plain text. Each is an ERROR: a draft that breaks one is\n * refused rather than stored, whoever wrote it.\n *==========================================*/\n\n/**\n * Words the playbook keeps out of every message: a discount, a coupon, a\n * cohort, a slot count, a deadline. Matched on word boundaries, so \"count\"\n * and \"expired domain\" pass and \"coupon\" and \"20% off\" do not.\n */\nconst DISCOUNT_LANGUAGE =\n /\\b(discounts?|discounted|coupons?|promo(?:tional)?\\s+codes?|promo\\b|vouchers?|\\d+\\s*%\\s*off|percent\\s+off|money\\s+off|special\\s+offer|limited[-\\s]time|expires?\\s+(?:on|at|in|soon|today|tomorrow)|deadline|(?:only|last|first)\\s+\\d+\\s+(?:spots?|slots?|seats?|places?)|cohorts?)\\b/i\n\n/** Markup a plain-text email would show as typed: bold stars, a markdown link, a heading line. */\nconst MARKDOWN = /\\*\\*[^*\\n]+\\*\\*|\\[[^\\]\\n]+\\]\\([^)\\s]+\\)|^#{1,6}\\s|<\\/?[a-z][^>]*>/im\n\nexport interface OutreachCuratedStepInput {\n /** The subject, when the copy carries one. Read only where the email starts a thread. */\n subject?: string | null\n body?: string | null\n /** Whether the email this copy is for starts a thread — `!isInThreadEmailStep`. */\n startsThread: boolean\n}\n\n/** Everything one person's copy of a step has to be before it may be stored — see the section note. */\nexport function validateOutreachCuratedStep(\n input: OutreachCuratedStepInput,\n path = 'override',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n const subject = asText(input.subject)\n if (input.startsThread && input.subject !== undefined && input.subject !== null) {\n if (!subject.trim()) {\n issues.push(error(`${path}.subject`, 'subject_required', 'This email starts a thread, so it needs a subject.'))\n } else if (outreachSubjectHasReplyPrefix(subject)) {\n issues.push(\n error(\n `${path}.subject`,\n 'subject_reply_prefix',\n 'Remove the \"Re:\" or \"Fwd:\" — this email starts a conversation, it doesn\\'t answer one.',\n ),\n )\n }\n if (subject.length > CRM_EMAIL_SUBJECT_MAX) {\n issues.push(\n error(`${path}.subject`, 'subject_too_long', `Keep the subject under ${CRM_EMAIL_SUBJECT_MAX} characters.`),\n )\n }\n if (DISCOUNT_LANGUAGE.test(subject)) {\n issues.push(\n error(`${path}.subject`, 'discount_language', 'The subject offers a discount or a deadline. List price only.'),\n )\n }\n issues.push(...mergeFieldWarnings(subject, `${path}.subject`))\n }\n const body = asText(input.body)\n if (!body.trim()) {\n issues.push(error(`${path}.body`, 'body_required', 'Write the email.'))\n return issues\n }\n if (body.length > CRM_EMAIL_BODY_MAX) {\n issues.push(\n error(\n `${path}.body`,\n 'body_too_long',\n `Keep the email under ${CRM_EMAIL_BODY_MAX.toLocaleString('en-US')} characters.`,\n ),\n )\n }\n const links = outreachBodyLinks(body)\n if (links.length > 1) {\n issues.push(\n error(\n `${path}.body`,\n 'too_many_links',\n `This email carries ${links.length} links. A sequence email carries one at most.`,\n ),\n )\n }\n if (DISCOUNT_LANGUAGE.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'discount_language',\n 'This email offers a discount, a coupon, a cohort, a slot count or a deadline. List price only.',\n ),\n )\n }\n if (MARKDOWN.test(body)) {\n issues.push(\n error(\n `${path}.body`,\n 'not_plain_text',\n 'This email carries markup — bold stars, a markdown link or a heading — which a plain-text email shows as typed.',\n ),\n )\n }\n issues.push(...mergeFieldWarnings(body, `${path}.body`))\n return issues\n}\n\n/**\n * The organization's footer fields: a legal name and a postal address, the\n * two things the law requires every commercial email to carry.\n */\nexport function validateOutreachOrgSettings(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n path = 'orgSettings',\n): OutreachValidationIssue[] {\n const issues: OutreachValidationIssue[] = []\n if (!asText(settings?.legalName).trim()) {\n issues.push(\n error(\n `${path}.legalName`,\n 'legal_name_required',\n \"Add your organization's legal name in Sequences settings. It goes in every email's footer.\",\n ),\n )\n }\n if (!asText(settings?.postalAddress).trim()) {\n issues.push(\n error(\n `${path}.postalAddress`,\n 'postal_address_required',\n \"Add your organization's postal address in Sequences settings. The law requires one in every sales email.\",\n ),\n )\n }\n return issues\n}\n\n/**\n * What activating a sequence refuses on: every issue saving refuses on, and\n * an organization whose footer cannot be written yet.\n */\nexport function validateOutreachSequenceActivation(\n sequence: Pick<OutreachSequence, 'name' | 'mailboxId' | 'steps' | 'settings'> | null | undefined,\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'postalAddress'> | null | undefined,\n): OutreachValidationIssue[] {\n return [...validateOutreachSequence(sequence), ...validateOutreachOrgSettings(orgSettings)]\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","readListUnsubscribeSetting","CRM_MERGE_FIELDS","crmMergeFieldsIn","crmThreadSubject","OUTREACH_DEFAULT_ALLOWED_COUNTRIES","OUTREACH_MAX_EMAIL_STEPS","OUTREACH_MAX_STEP_DELAY_BUSINESS_DAYS","OUTREACH_MAX_STEPS","OUTREACH_MIN_EMAIL_FOLLOW_UP_BUSINESS_DAYS","OUTREACH_TASK_KINDS","outreachBodyLinks","OUTREACH_PERSONAL_LINE_FIELD","OUTREACH_MERGE_FIELDS","key","label","group","OUTREACH_SEQUENCE_NAME_MAX","OUTREACH_TASK_TITLE_MAX","hasOutreachValidationErrors","issues","some","issue","severity","error","path","code","message","warning","asText","value","isEmailStep","step","kind","firstEmailStepIndex","steps","fromIndex","list","index","Math","max","length","isInThreadEmailStep","startStepIndex","first","replyInThread","outreachThreadStartSubject","subject","outreachSubjectHasReplyPrefix","collapsed","replace","trim","slice","WEEKDAY_NAMES","validateOutreachSendWindow","window","days","Array","isArray","daysValid","every","day","Number","isInteger","Set","size","push","start","startMinute","end","endMinute","hoursValid","normalizeOutreachAllowedCountries","codes","entry","toUpperCase","test","includes","readOutreachSequenceSettings","raw","data","countries","allowedCountries","allowCustomers","trackClicks","countOpens","listUnsubscribe","validateOutreachSequenceSettings","settings","forEach","KNOWN_MERGE_FIELDS","map","field","mergeFieldWarnings","text","filter","has","delayIssues","delay","delayBusinessDays","followsAnEmail","min","emailStepIssues","startsThread","body","templateId","toLocaleString","validateOutreachSteps","emailCount","seen","stepPath","id","add","taskKind","title","validateOutreachSequence","sequence","name","mailboxId","DISCOUNT_LANGUAGE","MARKDOWN","validateOutreachCuratedStep","input","undefined","links","validateOutreachOrgSettings","legalName","postalAddress","validateOutreachSequenceActivation","orgSettings"],"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":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAgC4C,GAE5C,SAASA,qBAAqB,EAAEC,kBAAkB,EAAEC,gBAAgB,QAAQ,kCAAiC;AAC7G,SAASC,kBAAkB,EAAEC,aAAa,QAAQ,6BAA4B;AAC9E,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,kBAAkB,QAAQ,sDAAqD;AACxF,SACEC,qBAAqB,QAIhB,qBAAiB;AACxB,SAASC,wBAAwB,QAAQ,gCAA4B;AACrE,SACEC,0BAA0B,QAIrB,6BAAyB;AA2DhC;;;;;;CAMC,GACD,OAAO,SAASC,cACdC,IAA6B,EAC7BC,cAAsB;IAEtB,MAAMC,UAAgC,CAAC;IACvC,KAAK,MAAMC,OAAOC,MAAMC,OAAO,CAACL,IAAI,CAAC,UAAU,IAAIA,IAAI,CAAC,UAAU,GAAG,EAAE,CAAE;QACvE,MAAMM,SAASC,OAAOJ;QACtB,MAAMK,OAAOF,OAAOG,UAAU,CAAC,WAAW,SAASH,WAAW,WAAW,WAAWA;QACpF,IAAIE,MAAMN,OAAO,CAACM,KAAK,GAAG;IAC5B;IACA,OAAO;QACLE,OAAOV,IAAI,CAAC,QAAQ;QACpBW,MAAMX,IAAI,CAAC,OAAO;OAGdA,IAAI,CAAC,aAAa,IAAI,OAAOA,IAAI,CAAC,aAAa,KAAK,WAAW;QAAEY,YAAYZ,IAAI,CAAC,aAAa;IAAC,IAAI,CAAC;QACzGa,QAAQ;YACN,CAACZ,eAAe,EAAE;gBAChBC;eACIF,IAAI,CAAC,UAAU,IAAI,OAAOA,IAAI,CAAC,UAAU,KAAK,WAAW;gBAAEc,SAASd,IAAI,CAAC,UAAU;YAAC,IAAI,CAAC,GACzF,OAAOA,IAAI,CAAC,UAAU,KAAK,WAAW;gBAAEe,aAAaf,IAAI,CAAC,UAAU;YAAC,IAAI,CAAC,GAC1E,OAAOA,IAAI,CAAC,WAAW,KAAK,WAAW;gBAAEgB,UAAUhB,IAAI,CAAC,WAAW;YAAC,IAAI,CAAC,GACzE,OAAOA,IAAI,CAAC,QAAQ,KAAK,WAAW;gBAAEiB,OAAOjB,IAAI,CAAC,QAAQ;YAAC,IAAI,CAAC,GAChEI,MAAMC,OAAO,CAACL,IAAI,CAAC,OAAO,IAAI;gBAAEkB,MAAMlB,IAAI,CAAC,OAAO;YAAC,IAAI,CAAC;QAEhE;;AAEJ;AAEA,0DAA0D,GAC1D,MAAMmB,gBAAgB;AAEtB,eAAeC,cACbC,SAAoB,EACpBC,IAAoD;IAEpD,MAAMC,YAAkD,EAAE;IAC1D,IAAK,IAAIC,QAAQ,GAAGA,QAAQF,KAAKG,MAAM,EAAED,SAASL,cAAe;QAC/DI,UAAUG,IAAI,IAAK,MAAML,UAAUM,MAAM,IAAIL,KAAKM,KAAK,CAACJ,OAAOA,QAAQL;IACzE;IACA,OAAOI;AACT;AAEA;;;;;;CAMC,GACD,OAAO,eAAeM,6BACpBR,SAAoB,EACpBS,KAAsG;IAEtG,IAAI,CAACA,MAAMC,MAAM,CAACN,MAAM,EAAE,OAAO,EAAE;IACnC,MAAMO,MAAMX,UAAUY,UAAU,CAAC,QAAQC,GAAG,CAACJ,MAAMK,KAAK;IACxD,8CAA8C;IAC9C,MAAMC,QAAQJ,IAAIC,UAAU,CAAC;IAC7B,MAAMV,YAAY,MAAMH,cACtBC,WACAS,MAAMC,MAAM,CAACM,GAAG,CAAC,CAACC,SAChBA,OAAO9B,IAAI,KAAK,SAAS4B,MAAMF,GAAG,CAACI,OAAOC,EAAE,IAAIP,IAAIC,UAAU,CAAC,YAAYC,GAAG,CAACI,OAAOC,EAAE;IAG5F,MAAMC,OAAO,MAAMC,QAAQC,GAAG,CAC5BZ,MAAMC,MAAM,CAACM,GAAG,CAAC,OAAOC,QAAQK;YAgCHpD;YA/BdgC;QAAb,MAAMqB,OAAOrB,EAAAA,mBAAAA,SAAS,CAACoB,MAAM,qBAAhBpB,iBAAkBsB,MAAM,IAAItB,SAAS,CAACoB,MAAM,CAACC,IAAI,KAAiC;QAC/F,IAAIN,OAAO9B,IAAI,KAAK,QAAQ;YAC1B,MAAME,QAAQkC,OAAOvD,sBAAsBuD,IAAI,CAAC,QAAQ,IAAI;YAC5D,MAAME,OACJF,QAAQlC,QAAQ,MAAMf,mBAAmBqC,IAAIC,UAAU,CAAC,aAAavB,SAAS;YAChF,OAAO;gBACLqC,UAAUT,OAAOC,EAAE;gBACnBS,QAAQ;gBACRC,WAAW;gBACXC,QAAQZ,OAAOC,EAAE;gBACjBY,SAASP,OAAO7C,cAAc6C,MAAMd,MAAM7B,cAAc,IAAI;gBAC5DD,MAAM4C;gBACN,sDAAsD;gBACtDQ,SAASR,SAAS;gBAClBjC,MAAMiC,OAAOpD,mBAAmBoD,QAAQ;gBACxClC;gBACA2C,WAAW;gBACXC,oBAAoBV,QAAQ,OAAOA,IAAI,CAAC,qBAAqB,KAAK,WAAWA,IAAI,CAAC,qBAAqB,GAAG;gBAC1GW,iBAAiBT,OAAOA,KAAKP,EAAE,GAAG;YACpC;QACF;QACA,OAAO;YACLQ,UAAUT,OAAOC,EAAE;YACnBS,QAAQ;YACRC,WAAWX,OAAOC,EAAE;YACpBW,QAAQ;YACRC,SAASP;YACT5C,MAAM;YACNoD,SAASR,OAAOlD,cAAckD,IAAI,CAAC,YAAY,EAA0Bd,MAAM0B,MAAM,IAAI;YACzF7C,MAAMiC,OAAOtD,mBAAmBsD,MAAMd,MAAM7B,cAAc,EAAEwD,IAAI,KAAK;YACrE/C,OAAOkC,OAAOvD,sBAAsBuD,IAAI,CAAC,QAAQ,IAAI;YACrDS,WAAWT,OAAOrC,QAAOhB,8BAAAA,iBAAiBqD,MAAMd,MAAM7B,cAAc,EAAEoD,SAAS,YAAtD9D,8BAA0D,MAAM;YACzF+D,oBAAoB;YACpBC,iBAAiB;QACnB;IACF;IAEF,MAAMG,aAAa;WAAI,IAAIC,IAAInB,KAAKH,GAAG,CAAC,CAACuB,QAAUA,MAAMP,SAAS,EAAEQ,MAAM,CAACC;KAAU;IACrF,MAAMC,YAAY,IAAIC;IACtB,IAAIN,WAAWjC,MAAM,EAAE;QACrB,MAAMwC,QAAQ,MAAM7C,cAClBC,WACAqC,WAAWrB,GAAG,CAAC,CAACE,KAAOP,IAAIC,UAAU,CAAC,aAAaC,GAAG,CAACK;QAEzD0B,MAAMC,OAAO,CAAC,CAACC,UAAUxB;YACvB,IAAIwB,SAAStB,MAAM,EAAEkB,UAAUK,GAAG,CAACV,UAAU,CAACf,MAAM,EAAEwB,SAASvB,IAAI;QACrE;IACF;IACA,OAAOJ,KAAKH,GAAG,CAAC;YAAC,EAAEgB,SAAS,EAAgB,OAAXgB;;;YAETN;eAF0B,aAC7CM;YACHC,SAASjB,aAAaU,iBAAAA,UAAUQ,GAAG,CAAClB,sBAAdU,iBAA4B,OAAQ;;;AAE9D;AAEA,2CAA2C,GAC3C,SAASS,SAASH,SAAkC;IAClD,OAAO;QACLtB,UAAUsB,UAAUtB,QAAQ;QAC5BC,QAAQqB,UAAUrB,MAAM;QACxBC,WAAWoB,UAAUpB,SAAS;QAC9BC,QAAQmB,UAAUnB,MAAM;IAC1B;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASuB,wBACdJ,SAAkC,EAClCK,KAAuD;IAEvD,IAAI,CAACL,UAAUlB,OAAO,EAAE;QACtB,OAAOkB,UAAUrB,MAAM,KAAK,SACxB;YAAC;gBAAE2B,MAAM;gBAAgBC,QAAQ;YAA4D;SAAE,GAC/F;YAAC;gBAAED,MAAM;gBAAmBC,QAAQ;YAA4C;SAAE;IACxF;IACA,MAAMC,SAAgC,EAAE;IACxC,IAAIR,UAAUrB,MAAM,KAAK,QAAQ;QAC/B,sEAAsE;QACtE,sEAAsE;QACtE,IAAIqB,UAAUf,kBAAkB,EAAE;YAChCuB,OAAOnD,IAAI,CAAC;gBACViD,MAAM;gBACNC,QAAQ;YACV;QACF,OAAO,IAAIP,UAAUd,eAAe,EAAE;YACpCsB,OAAOnD,IAAI,CAAC;gBACViD,MAAM;gBACNC,QAAQ;YACV;QACF;QACA,IAAInF,cAAc4E,UAAUrE,IAAI,MAAM,eAAe;YACnD6E,OAAOnD,IAAI,CAAC;gBACViD,MAAM;gBACNC,QAAQ;YACV;QACF;IACF;IACA,IAAI,CAACP,UAAUjB,OAAO,EAAE;QACtByB,OAAOnD,IAAI,CAAC;YACViD,MAAM;YACNC,QAAQ,CAAC,sBAAsB,EAAEF,MAAMI,QAAQ,IAAI,uBAAuB,OAAO,CAAC;QACpF;IACF;IACA,IAAI,CAACT,UAAU3D,KAAK,EAAE;QACpBmE,OAAOnD,IAAI,CAAC;YAAEiD,MAAM;YAAYC,QAAQ;QAAqC;IAC/E;IACA,IAAIF,MAAMK,iBAAiB,EAAE;QAC3BF,OAAOnD,IAAI,CAAC;YACViD,MAAM;YACNC,QAAQ;QACV;IACF;IACA,OAAOC;AACT;AAEA,gFAAgF,GAChF,MAAMG,uBAA6CC,OAAOC,WAAW,CACnEpF,2BAA2BuC,GAAG,CAAC,CAAC7B,OAAS;QAACA;QAAM;YAAE2E,KAAK;YAAWC,MAAM;QAAE;KAAE;AAG9E,MAAMC,WAAW,CAACR,SAChBA,OAAOxC,GAAG,CAAC,CAAC,EAAEsC,IAAI,EAAEC,MAAM,EAAE,GAAM,CAAA;YAAED;YAAMC;QAAO,CAAA;AAUnD;;;CAGC,GACD,SAASU,UAAUC,QAA8B,EAAEC,IAA6B;QAC7DD;IAAjB,MAAME,YAAWF,oBAAAA,SAASG,OAAO,qBAAhBH,kBAAkBI,OAAO;IAC1C,OAAOF,WAAW,aAAKA;QAAUD,MAAMA,SAAS;SAAS;AAC3D;AAEA,oFAAoF,GACpF,OAAO,SAASI,sBACdL,QAA8B,EAC9Bb,KAAuD;IAEvD,MAAM,EAAEL,SAAS,EAAE,GAAGkB;IACtB,MAAMjD,SAAS,aACVkC,SAASH;QACZ1D,MAAM0D,UAAU1D,IAAI;QACpBD,OAAO2D,UAAU3D,KAAK;;IAExB,MAAMmF,QAAQpB,wBAAwBJ,WAAWK;IACjD,IAAImB,MAAMpE,MAAM,EAAE;QAChB,OAAO,aACFa;YACHwD,MAAM;YACNC,SAAS;YACTC,QAAQ;YACRnB,QAAQgB;YACRI,UAAU;gBAAEC,cAAc;gBAAOC,cAAc,EAAE;YAAC;YAClDR,SAAS;;IAEb;IACA,MAAMS,MAAM,CAACF,cAAsBC;YAExB9B;eADTzE,sBAAsB;YACpBc,KAAK,GAAE2D,mBAAAA,UAAU3D,KAAK,YAAf2D,mBAAmB;YAC1BlB,SAASkB,UAAUlB,OAAO;YAC1BlD,gBAAgBsF,SAAStF,cAAc;YACvCqE,SAASD,UAAUC,OAAO;YAC1B+B,UAAUd,SAASc,QAAQ;YAC3BH;YACAC;YACAT,SAASH,SAASG,OAAO;QAC3B;;IACF,MAAMY,OAAOF,IAAI,IAAI,CAAC;IACtB,MAAMG,QAAQ;QAAET,MAAMQ,KAAKR,IAAI;QAAEC,SAASO,KAAKP,OAAO,CAACA,OAAO;QAAEJ,SAASL,UAAUC,UAAUe,KAAKd,IAAI;IAAE;IACxG,IAAIc,KAAKE,OAAO,EAAE;QAChB,OAAO,aACFlE,QACAiE;YACHP,QAAQ;YACRnB,QAAQ,EAAE;YACVoB,UAAU;gBAAEC,cAAc;gBAAOC,cAAc,EAAE;YAAC;;IAEtD;IACA,yEAAyE;IACzE,MAAMM,YAAYL,IAAI,oBAAoBpB;IAC1C,IAAI,CAACyB,UAAUD,OAAO,EAAE;QACtB,OAAO,aACFlE,QACAiE;YACHP,QAAQ;YACRnB,QAAQQ,SAASoB,UAAU5B,MAAM;YACjCoB,UAAU;gBAAEC,cAAc;gBAAOC,cAAc,EAAE;YAAC;;IAEtD;IACA,MAAMA,eAA0CG,KAAKR,IAAI,GACrD;WAAIQ,KAAKI,mBAAmB;KAAC,GAC7BJ,KAAKzB,MAAM,CAAC8B,IAAI,CAAC,CAACC,QAAUA,MAAMjC,IAAI,KAAK,qBACzC;QAAC;KAAsB,GACvB,EAAE;IACR,OAAO,aACFrC,QACAiE;QACHP,QAAQ;QACRnB,QAAQ,EAAE;QACVoB,UAAU;YACRC,cAAcI,KAAKzB,MAAM,CAAC8B,IAAI,CAAC,CAACC,QAAUA,MAAMjC,IAAI,KAAK;YACzDwB;QACF;;AAEJ;AAiBA,uFAAuF,GACvF,OAAO,SAASU,yBACdtB,QAA8B,EAC9Bb,KAAuD,EACvDoC,QAKC;QAQQvB;IANT,MAAMY,eAAetG,yBAAyBiH,SAASX,YAAY,EAAEW,SAAS3B,GAAG,EAAE2B,SAASC,KAAK;IACjG,MAAMlB,QAAQpB,wBAAwBc,SAASlB,SAAS,EAAEK;IAC1D,IAAImB,MAAMpE,MAAM,EAAE;QAChB,OAAO;YAAE+E,SAAS;YAAO3B,QAAQgB;YAAOC,MAAM;YAAOpF,OAAO6E,SAASlB,SAAS,CAAC3D,KAAK;YAAEyF;YAAcX,MAAM;QAAK;IACjH;IACA,MAAMwB,SAASpH,sBAAsB;QACnCc,KAAK,GAAE6E,4BAAAA,SAASlB,SAAS,CAAC3D,KAAK,YAAxB6E,4BAA4B;QACnCpC,SAASoC,SAASlB,SAAS,CAAClB,OAAO;QACnClD,gBAAgBsF,SAAStF,cAAc;QACvCqE,SAASiB,SAASlB,SAAS,CAACC,OAAO;QACnC+B,UAAUd,SAASc,QAAQ;QAC3BH,cAAcY,SAASZ,YAAY;QACnCC;QACAT,SAASH,SAASG,OAAO;IAC3B;IACA,OAAO;QACLc,SAASQ,OAAOR,OAAO;QACvB3B,QAAQQ,SAAS2B,OAAOnC,MAAM;QAC9BiB,MAAMkB,OAAOlB,IAAI;QACjBpF,OAAOsG,OAAOtG,KAAK;QACnByF;QACAX,MAAMwB,OAAOxB,IAAI;IACnB;AACF"}
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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"}
@@ -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;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { SenderReadiness } from '@aglyn/shared-util-email'\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */\nexport type OutreachMailboxAvailabilityGate =\n | { gate: 'google'; missing: string[] }\n | { gate: 'state' }\n | { gate: 'redirect' }\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * When not configured, which gates refused, so an operator reading the\n * response knows what to set: the Google client (`google`, with the names\n * of the missing variables), the state signing secret (`state`), or the\n * console origin to register (`redirect`). Absent when configured.\n */\n missing?: OutreachMailboxAvailabilityGate[]\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n /**\n * Whether the warm-up ramp applies (AGL-3228). `false` clears it, so an\n * established mailbox sends at its cap from today; `true` starts one now\n * when none is running.\n */\n warmUp?: boolean\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/**\n * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address\n * the test goes to; left off, the account's own (AGL-3228).\n */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * or `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed'\n}\n\n/**\n * `POST outreach/mailboxes/readiness` — `{ mailboxId, fresh? }`. How\n * receivers judge mail from the address the mailbox sends as (AGL-3328):\n * `readiness` is null for a consumer Gmail address, whose records are\n * Google's own and have nothing for its owner to publish.\n */\nexport interface OutreachMailboxReadinessResponse {\n ok: true\n /** The address whose domain was read. */\n sendAs: string\n readiness: SenderReadiness | null\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n | { kind: 'error'; reason: OutreachConnectReturnError }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return { kind: 'error', reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error' }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAmKD;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAS9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;IACnC;IACA,OAAON,OAAOO,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMV,SAAS,IAAIC,gBAAgBU,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMV,OAAOF,OAAOa,GAAG,CAAChB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOa,GAAG,CAAC,mBAAXb,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOa,GAAG,CAAC,oBAAXb,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOa,GAAG,CAAC;QAC1B,OAAO;YAAEX,MAAM;YAASI,QAAQA,UAAUE,cAAcM,QAAQ,CAACR,UAAUA,SAAS;QAAe;IACrG;IACA,OAAO;AACT"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { SenderReadiness } from '@aglyn/shared-util-email'\nimport type { OutreachMailbox, OutreachSendWindow } from '../model/outreach.types'\n\n/**\n * THE MAILBOX ROUTES' CONTRACT (AGL-2978): what the Mailboxes panel sends,\n * what the routes answer, and the fragment a connect comes back through.\n * Client-safe — types, constants and two string functions.\n */\n\n/** Every reason a mailbox route refuses with, for a caller to branch on. */\nexport type OutreachApiRefusalReason =\n | 'unauthenticated'\n | 'email-unverified'\n | 'org-required'\n | 'not-a-member'\n | 'not-org-wide'\n | 'permission'\n | 'entitlement'\n | 'not-configured'\n | 'method-not-allowed'\n | 'invalid-request'\n | 'mailbox-not-found'\n | 'not-your-mailbox'\n | 'mailbox-limit'\n | 'state-invalid'\n | 'state-expired'\n | 'state-user-mismatch'\n | 'state-org-mismatch'\n | 'state-replayed'\n | 'state-superseded'\n | 'code-rejected'\n | 'refresh-token-missing'\n | 'scopes-missing'\n | 'identity-unverified'\n | 'account-mismatch'\n | 'reconnect-required'\n | 'invalid-settings'\n | 'rate-limited'\n /** The Google account has no Gmail service: off for it, or not yet provisioned. */\n | 'mail-service-unavailable'\n /** Google refused the account itself, such as an administrator barring Gmail API access. */\n | 'google-refused'\n | 'google-unavailable'\n\nexport interface OutreachApiRefusal {\n error: string\n reason: OutreachApiRefusalReason\n}\n\n/** A reason `GET outreach/mailboxes/availability` answers `configured: false`. */\nexport type OutreachMailboxAvailabilityGate =\n | { gate: 'google'; missing: string[] }\n | { gate: 'state' }\n | { gate: 'redirect' }\n\n/** `GET outreach/mailboxes/availability` */\nexport interface OutreachMailboxAvailability {\n configured: boolean\n /**\n * When not configured, which gates refused, so an operator reading the\n * response knows what to set: the Google client (`google`, with the names\n * of the missing variables), the state signing secret (`state`), or the\n * console origin to register (`redirect`). Absent when configured.\n */\n missing?: OutreachMailboxAvailabilityGate[]\n /**\n * Whether the viewer is an organization owner or admin, who may change,\n * pause and disconnect any member's mailbox. The routes decide this on\n * every call; the panel reads it to offer only what will be allowed.\n */\n canManageAll: boolean\n}\n\n/** `POST outreach/mailboxes/connect` */\nexport interface OutreachConnectRequest {\n orgId: string\n}\nexport interface OutreachConnectResponse {\n /** Google's consent address; the browser goes there. */\n url: string\n}\n\n/** `POST outreach/mailboxes/connect/complete` */\nexport interface OutreachConnectCompleteRequest {\n orgId: string\n code: string\n state: string\n /** The browser's IANA timezone, for a new mailbox's sending window. */\n timezone?: string\n}\nexport interface OutreachConnectCompleteResponse {\n ok: true\n mailbox: OutreachMailbox\n /** False when an existing mailbox for the account was reconnected. */\n created: boolean\n /** Pending addresses of the member's that Gmail's send-as list confirmed. */\n confirmedAliases: string[]\n}\n\n/** `POST outreach/mailboxes/settings` — every field but the ids is optional. */\nexport interface OutreachMailboxSettingsRequest {\n orgId: string\n mailboxId: string\n sendAs?: string\n displayName?: string\n dailyCap?: number\n window?: OutreachSendWindow\n timezone?: string\n /**\n * Whether the warm-up ramp applies (AGL-3228). `false` clears it, so an\n * established mailbox sends at its cap from today; `true` starts one now\n * when none is running.\n */\n warmUp?: boolean\n}\n\n/** `POST outreach/mailboxes/status` */\nexport interface OutreachMailboxStatusRequest {\n orgId: string\n mailboxId: string\n paused: boolean\n}\n\nexport interface OutreachMailboxResponse {\n ok: true\n mailbox: OutreachMailbox\n}\n\n/**\n * `POST outreach/mailboxes/test` — `{ mailboxId, to? }`. `to` is the address\n * the test goes to; left off, the account's own (AGL-3228).\n */\nexport interface OutreachMailboxTestResponse {\n ok: true\n /** The address the test went to. */\n sentTo: string\n gmailMessageId: string\n sentAtMs: number\n}\n\n/** `POST outreach/mailboxes/disconnect` */\nexport interface OutreachMailboxDisconnectResponse {\n ok: true\n /**\n * What became of the grant at Google: `revoked`, `already-invalid`,\n * `kept-for-other-mailbox` when another mailbox still uses the account,\n * or `failed` when Google could not be told — the stored grant is deleted\n * either way.\n */\n revocation: 'revoked' | 'already-invalid' | 'kept-for-other-mailbox' | 'failed'\n}\n\n/**\n * `POST outreach/mailboxes/readiness` — `{ mailboxId, fresh? }`. How\n * receivers judge mail from the address the mailbox sends as (AGL-3328):\n * `readiness` is null for a consumer Gmail address, whose records are\n * Google's own and have nothing for its owner to publish.\n */\nexport interface OutreachMailboxReadinessResponse {\n ok: true\n /** The address whose domain was read. */\n sendAs: string\n readiness: SenderReadiness | null\n}\n\n/**\n * The fragment key a connect returns through (AGL-2978).\n *\n * Google redirects to the callback route with the authorization code in the\n * query; the callback moves it into the FRAGMENT of the Mailboxes page, which\n * no server receives — not in a request line, not in a `Referer` — and the\n * page takes it out of the address bar before it finishes the connect with\n * the member's own session. The cross-domain session handoff uses the\n * fragment for the same reason.\n */\nexport const OUTREACH_CONNECT_FRAGMENT_KEY = 'outreachConnect'\n\n/** Why a connect came back without a code. */\nexport type OutreachConnectReturnError = 'access_denied' | 'expired' | 'google_error'\n\nexport type OutreachConnectReturn =\n | { kind: 'code'; code: string; state: string }\n | { kind: 'error'; reason: OutreachConnectReturnError }\n\n/** The fragment for a connect's return, without the leading `#`. */\nexport function buildConnectReturnFragment(value: OutreachConnectReturn): string {\n const params = new URLSearchParams()\n if (value.kind === 'code') {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'code')\n params.set('code', value.code)\n params.set('state', value.state)\n } else {\n params.set(OUTREACH_CONNECT_FRAGMENT_KEY, 'error')\n params.set('reason', value.reason)\n }\n return params.toString()\n}\n\nconst RETURN_ERRORS: readonly OutreachConnectReturnError[] = ['access_denied', 'expired', 'google_error']\n\n/** A connect's return read off a location hash, or `null` when it carries none. */\nexport function parseConnectReturnFragment(hash: string | null | undefined): OutreachConnectReturn | null {\n const params = new URLSearchParams(String(hash ?? '').replace(/^#/, ''))\n const kind = params.get(OUTREACH_CONNECT_FRAGMENT_KEY)\n if (kind === 'code') {\n const code = params.get('code') ?? ''\n const state = params.get('state') ?? ''\n return code && state ? { kind: 'code', code, state } : null\n }\n if (kind === 'error') {\n const reason = params.get('reason') as OutreachConnectReturnError | null\n return { kind: 'error', reason: reason && RETURN_ERRORS.includes(reason) ? reason : 'google_error' }\n }\n return null\n}\n"],"names":["OUTREACH_CONNECT_FRAGMENT_KEY","buildConnectReturnFragment","value","params","URLSearchParams","kind","set","code","state","reason","toString","RETURN_ERRORS","parseConnectReturnFragment","hash","String","replace","get","includes"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAuKD;;;;;;;;;CASC,GACD,OAAO,MAAMA,gCAAgC,kBAAiB;AAS9D,kEAAkE,GAClE,OAAO,SAASC,2BAA2BC,KAA4B;IACrE,MAAMC,SAAS,IAAIC;IACnB,IAAIF,MAAMG,IAAI,KAAK,QAAQ;QACzBF,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,QAAQJ,MAAMK,IAAI;QAC7BJ,OAAOG,GAAG,CAAC,SAASJ,MAAMM,KAAK;IACjC,OAAO;QACLL,OAAOG,GAAG,CAACN,+BAA+B;QAC1CG,OAAOG,GAAG,CAAC,UAAUJ,MAAMO,MAAM;IACnC;IACA,OAAON,OAAOO,QAAQ;AACxB;AAEA,MAAMC,gBAAuD;IAAC;IAAiB;IAAW;CAAe;AAEzG,iFAAiF,GACjF,OAAO,SAASC,2BAA2BC,IAA+B;IACxE,MAAMV,SAAS,IAAIC,gBAAgBU,OAAOD,eAAAA,OAAQ,IAAIE,OAAO,CAAC,MAAM;IACpE,MAAMV,OAAOF,OAAOa,GAAG,CAAChB;IACxB,IAAIK,SAAS,QAAQ;YACNF,aACCA;QADd,MAAMI,QAAOJ,cAAAA,OAAOa,GAAG,CAAC,mBAAXb,cAAsB;QACnC,MAAMK,SAAQL,eAAAA,OAAOa,GAAG,CAAC,oBAAXb,eAAuB;QACrC,OAAOI,QAAQC,QAAQ;YAAEH,MAAM;YAAQE;YAAMC;QAAM,IAAI;IACzD;IACA,IAAIH,SAAS,SAAS;QACpB,MAAMI,SAASN,OAAOa,GAAG,CAAC;QAC1B,OAAO;YAAEX,MAAM;YAASI,QAAQA,UAAUE,cAAcM,QAAQ,CAACR,UAAUA,SAAS;QAAe;IACrG;IACA,OAAO;AACT"}
@@ -49,8 +49,11 @@ import { type OutreachGoogleConfigResult } from './outreach-config';
49
49
  * expired, already used or superseded; a code Google refuses; a grant with no
50
50
  * refresh token; a grant missing `gmail.send` or `gmail.readonly` (the consent
51
51
  * screen lets a person untick them); an ID token that is not about this
52
- * exchange or whose address Google has not verified; a Gmail profile whose
53
- * address is not the ID token's; and a member at their mailbox limit.
52
+ * exchange or whose address Google has not verified; an account with no Gmail
53
+ * service (a Workspace user whose Gmail is off, or not yet provisioned) or one
54
+ * Google bars from the Gmail API; a Gmail profile whose address is not the ID
55
+ * token's; and a member at their mailbox limit. Each is a 4xx: only a Google
56
+ * outage, or a request Google calls malformed, answers 502.
54
57
  *
55
58
  * A grant refused after the code exchange is dropped, not revoked. Google's
56
59
  * revocation ends the whole grant this client holds for the account, and the