@aglyn/plugins-outreach 1.0.0-beta.151 → 1.0.0-beta.153

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 (64) hide show
  1. package/README.md +5 -1
  2. package/package.json +12 -12
  3. package/src/lib/components/compliance-section.js +1 -1
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.js +142 -32
  6. package/src/lib/components/enroll-dialog.js.map +1 -1
  7. package/src/lib/components/enrollments-table.js +37 -8
  8. package/src/lib/components/enrollments-table.js.map +1 -1
  9. package/src/lib/components/mailbox-card.d.ts +3 -0
  10. package/src/lib/components/mailbox-card.js +44 -8
  11. package/src/lib/components/mailbox-card.js.map +1 -1
  12. package/src/lib/components/use-outreach-crm.d.ts +20 -1
  13. package/src/lib/components/use-outreach-crm.js +78 -4
  14. package/src/lib/components/use-outreach-crm.js.map +1 -1
  15. package/src/lib/components/use-outreach-mailbox-api.d.ts +2 -1
  16. package/src/lib/components/use-outreach-mailbox-api.js +5 -3
  17. package/src/lib/components/use-outreach-mailbox-api.js.map +1 -1
  18. package/src/lib/declarations.console-server.js +14 -0
  19. package/src/lib/declarations.console-server.js.map +1 -1
  20. package/src/lib/engine/compose.d.ts +7 -2
  21. package/src/lib/engine/compose.js +11 -5
  22. package/src/lib/engine/compose.js.map +1 -1
  23. package/src/lib/engine/enrollment-state.d.ts +6 -1
  24. package/src/lib/engine/enrollment-state.js +3 -1
  25. package/src/lib/engine/enrollment-state.js.map +1 -1
  26. package/src/lib/engine/opt-out-intent.js +1 -1
  27. package/src/lib/engine/opt-out-intent.js.map +1 -1
  28. package/src/lib/enrollment/enroll-people.d.ts +52 -7
  29. package/src/lib/enrollment/enroll-people.js +109 -12
  30. package/src/lib/enrollment/enroll-people.js.map +1 -1
  31. package/src/lib/enrollment/gate-lookups.d.ts +12 -3
  32. package/src/lib/enrollment/gate-lookups.js +17 -14
  33. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  34. package/src/lib/mailboxes/mailbox-api.d.ts +27 -2
  35. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  36. package/src/lib/mailboxes/mailbox-routes.js +52 -8
  37. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  38. package/src/lib/mailboxes/oauth-redirect.d.ts +15 -4
  39. package/src/lib/mailboxes/oauth-redirect.js +15 -6
  40. package/src/lib/mailboxes/oauth-redirect.js.map +1 -1
  41. package/src/lib/model/outreach-api.d.ts +39 -8
  42. package/src/lib/model/outreach-api.js.map +1 -1
  43. package/src/lib/model/outreach.types.d.ts +29 -4
  44. package/src/lib/model/outreach.types.js +9 -0
  45. package/src/lib/model/outreach.types.js.map +1 -1
  46. package/src/lib/model/stored-records.js +7 -0
  47. package/src/lib/model/stored-records.js.map +1 -1
  48. package/src/lib/routes/enroll-routes.js +112 -39
  49. package/src/lib/routes/enroll-routes.js.map +1 -1
  50. package/src/lib/routes/preview-routes.js +18 -5
  51. package/src/lib/routes/preview-routes.js.map +1 -1
  52. package/src/lib/runtime/lead-records.d.ts +26 -0
  53. package/src/lib/runtime/lead-records.js +84 -0
  54. package/src/lib/runtime/lead-records.js.map +1 -0
  55. package/src/lib/runtime/send-job.js +30 -12
  56. package/src/lib/runtime/send-job.js.map +1 -1
  57. package/src/lib/runtime/sync-job.js +11 -3
  58. package/src/lib/runtime/sync-job.js.map +1 -1
  59. package/src/lib/runtime/timeline.d.ts +6 -4
  60. package/src/lib/runtime/timeline.js +3 -7
  61. package/src/lib/runtime/timeline.js.map +1 -1
  62. package/src/lib/storage/outreach-records.d.ts +21 -2
  63. package/src/lib/storage/outreach-records.js +31 -2
  64. package/src/lib/storage/outreach-records.js.map +1 -1
@@ -25,8 +25,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
25
25
  * ## The footer is not optional
26
26
  *
27
27
  * Every email ends with the organization's legal name, its postal address,
28
- * a sentence saying the email is a business solicitation, and the way out —
29
- * reply "no". Those are what CAN-SPAM requires of a commercial email, and
28
+ * a sentence saying the email is a sales email, and the way out — reply
29
+ * "no". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:
30
+ * identifiable as a solicitation, no fixed phrase), and
30
31
  * the composer appends them itself, after the merge, as literal text: no
31
32
  * template can leave them off, and no merge field can rewrite them. With no
32
33
  * postal address or no legal name, it refuses to compose at all.
@@ -54,9 +55,14 @@ import { resolveCrmMergeFields } from "@aglyn/aglyn/app-utils/crm-email-template
54
55
  import { crmThreadSubject } from "@aglyn/aglyn/app-utils/crm-inbound";
55
56
  import { normalizeOutreachPersonalLine } from "./gates.js";
56
57
  import { isInThreadEmailStep, OUTREACH_PERSONAL_LINE_FIELD, outreachSubjectHasReplyPrefix } from "./sequence-validation.js";
57
- /** The way out every email offers, word for word. */ export const OUTREACH_OPT_OUT_LINE = 'Not relevant? Reply "no" and I won\'t email again.';
58
- /** The sentence that says what the email is. */ export function outreachSolicitationStatement(senderName) {
59
- return `This is a business solicitation from ${senderName}.`;
58
+ /** The way out every email offers, word for word. */ export const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply "no" and I won\'t email again.';
59
+ /**
60
+ * The sentence that says what the email is. "Sales email", not "business
61
+ * solicitation": an inbox-placement test on 2026-09-19 named the latter
62
+ * phrase as a spam trigger, and the law asks that the email be identifiable
63
+ * as a solicitation, not for any particular words (AGL-3228).
64
+ */ export function outreachSolicitationStatement(senderName) {
65
+ return `This is a sales email from ${senderName}.`;
60
66
  }
61
67
  const oneLine = (value)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
62
68
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a business solicitation, and the way out —\n * reply \"no\". Those are what CAN-SPAM requires of a commercial email, and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not relevant? Reply \"no\" and I won\\'t email again.'\n\n/** The sentence that says what the email is. */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a business solicitation from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '' }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n email.text = `${body}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/compose.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WHAT EXACTLY IS SENT (AGL-2979).\n *\n * The composer turns a step, an enrollment and the organization's settings\n * into the message the transport sends: the recipient, the subject, the\n * plain text, and the headers that thread it and let the recipient leave.\n * The transport adds the From line and the MIME; nothing here knows either.\n *\n * ## The footer is not optional\n *\n * Every email ends with the organization's legal name, its postal address,\n * a sentence saying the email is a sales email, and the way out — reply\n * \"no\". Those are what CAN-SPAM requires of a commercial email (16 CFR 316:\n * identifiable as a solicitation, no fixed phrase), and\n * the composer appends them itself, after the merge, as literal text: no\n * template can leave them off, and no merge field can rewrite them. With no\n * postal address or no legal name, it refuses to compose at all.\n *\n * ## Threading\n *\n * An email after the first is a reply in the thread unless its step says\n * otherwise: `Re:` and the thread's own subject, `In-Reply-To` the last\n * message sent into the thread, `References` every one of them, and the\n * provider thread to send into. A `Re:` on an email that answers nothing is\n * a deception, so a reply step with no thread to answer is refused rather\n * than sent with a borrowed one, and a subject that starts with `Re:` or\n * `Fwd:` on an email that starts a thread is refused too.\n *\n * ## Merge fields\n *\n * The CRM's own resolver fills them, from the documents the runtime already\n * read, and the enrollment's personal line joins them as\n * `{{enrollment.personalLine}}` through the resolver's `extra` fields.\n * Fields that render empty are returned beside the message, for the\n * runtime to decide whether \"Hi ,\" is worth sending.\n *==========================================*/\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_EMAIL_SUBJECT_MAX } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmMergeContext,\n resolveCrmMergeFields,\n} from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { crmThreadSubject } from '@aglyn/aglyn/app-utils/crm-inbound'\nimport type {\n OutreachEnrollment,\n OutreachOrgSettings,\n OutreachSequence,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport {\n isInThreadEmailStep,\n OUTREACH_PERSONAL_LINE_FIELD,\n outreachSubjectHasReplyPrefix,\n} from './sequence-validation'\n\n/** The way out every email offers, word for word. */\nexport const OUTREACH_OPT_OUT_LINE = 'Not interested? Reply \"no\" and I won\\'t email again.'\n\n/**\n * The sentence that says what the email is. \"Sales email\", not \"business\n * solicitation\": an inbox-placement test on 2026-09-19 named the latter\n * phrase as a spam trigger, and the law asks that the email be identifiable\n * as a solicitation, not for any particular words (AGL-3228).\n */\nexport function outreachSolicitationStatement(senderName: string): string {\n return `This is a sales email from ${senderName}.`\n}\n\n/**\n * The message the transport sends. Header values are single lines already;\n * the text uses `\\n` line breaks, which the transport writes as CRLF.\n */\nexport interface ComposedOutreachEmail {\n /** The recipient's address, normalized. */\n to: string\n subject: string\n /** The plain-text body, footer included. */\n text: string\n /** The provider thread a reply step is sent into. */\n threadId?: string\n /** The `Message-ID` this email answers, angle brackets included. */\n inReplyTo?: string\n /** Every `Message-ID` in the thread so far, oldest first, space-separated. */\n references?: string\n /** The one-click unsubscribe URL, for `List-Unsubscribe` with `List-Unsubscribe-Post`. */\n listUnsubscribeUrl?: string\n /** A `mailto:` URI for `List-Unsubscribe`. */\n listUnsubscribeMailto?: string\n}\n\nexport type OutreachComposeErrorCode =\n | 'missing_postal_address'\n | 'missing_legal_name'\n | 'not_an_email_step'\n | 'invalid_recipient'\n | 'missing_subject'\n | 'subject_reply_prefix'\n | 'missing_body'\n | 'missing_thread'\n | 'invalid_unsubscribe_url'\n | 'invalid_unsubscribe_mailto'\n\nexport interface OutreachComposeError {\n code: OutreachComposeErrorCode\n message: string\n}\n\nexport interface ComposeOutreachEmailInput {\n sequence: Pick<OutreachSequence, 'steps'>\n enrollment: Pick<\n OutreachEnrollment,\n 'email' | 'stepIndex' | 'personalLine' | 'threadSubject' | 'messageIds' | 'gmailThreadId'\n >\n orgSettings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined\n /**\n * What the CRM resolver reads: the contact, the sending site's group id,\n * the sender (the mailbox's display name and address), the site. Its\n * `extra` may carry fields of the caller's own; the personal line is added\n * here.\n */\n merge: CrmMergeContext | null | undefined\n /** The CRM template's body, read by the caller, when the step names a template. */\n templateBody?: string | null\n /** The signed one-click unsubscribe URL, minted by the runtime. */\n listUnsubscribeUrl?: string | null\n /** An address, or a `mailto:` URI, that unsubscribes whoever writes to it. */\n listUnsubscribeMailto?: string | null\n}\n\nexport interface OutreachComposeResult {\n email: ComposedOutreachEmail | null\n error: OutreachComposeError | null\n /** Merge fields that rendered empty, unique, in order of appearance. */\n unresolvedFields: string[]\n}\n\nconst oneLine = (value: unknown): string =>\n typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n\n/**\n * The footer every email ends with, or the reason it cannot be written.\n *\n * The postal address may be stored over several lines; the footer prints it\n * on one, the parts separated by commas.\n */\nexport function composeOutreachFooter(\n settings: Pick<OutreachOrgSettings, 'legalName' | 'brandName' | 'postalAddress'> | null | undefined,\n): { footer: string | null; error: OutreachComposeError | null } {\n const postalAddress = String(settings?.postalAddress ?? '')\n .split(/\\r\\n|\\r|\\n/)\n .map((line) => oneLine(line).replace(/,+$/, ''))\n .filter(Boolean)\n .join(', ')\n if (!postalAddress) {\n return {\n footer: null,\n error: {\n code: 'missing_postal_address',\n message:\n \"This email can't be sent without your organization's postal address. Add it in Sequences settings.\",\n },\n }\n }\n const legalName = oneLine(settings?.legalName)\n if (!legalName) {\n return {\n footer: null,\n error: {\n code: 'missing_legal_name',\n message:\n \"This email can't be sent without your organization's legal name. Add it in Sequences settings.\",\n },\n }\n }\n const senderName = oneLine(settings?.brandName) || legalName\n return {\n footer: `${legalName} · ${postalAddress}\\n${outreachSolicitationStatement(senderName)} ${OUTREACH_OPT_OUT_LINE}`,\n error: null,\n }\n}\n\n/** A `Message-ID` in its header form — `<id@host>` — or `null` for one that is not. */\nexport function outreachMessageIdHeader(value: unknown): string | null {\n const bare = String(value ?? '').trim().replace(/^<+/, '').replace(/>+$/, '')\n if (!bare || /[\\s<>]/.test(bare)) return null\n return `<${bare}>`\n}\n\nfunction unsubscribeUrl(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' ? url.href : null\n } catch {\n return null\n }\n}\n\nfunction unsubscribeMailto(value: unknown): string | null | undefined {\n const raw = String(value ?? '').trim()\n if (!raw) return undefined\n if (/[\\r\\n]/.test(raw)) return null\n if (!/^mailto:/i.test(raw)) {\n const address = normalizeContactEmail(raw)\n return address ? `mailto:${address}?subject=unsubscribe` : null\n }\n let target = raw.slice('mailto:'.length).split('?')[0]\n try {\n target = decodeURIComponent(target)\n } catch {\n return null\n }\n return normalizeContactEmail(target) ? raw : null\n}\n\nconst refuse = (\n code: OutreachComposeErrorCode,\n message: string,\n): OutreachComposeResult => ({ email: null, error: { code, message }, unresolvedFields: [] })\n\n/** The email one step sends to one person — see the module note. */\nexport function composeOutreachEmail(input: ComposeOutreachEmailInput): OutreachComposeResult {\n const { footer, error } = composeOutreachFooter(input.orgSettings)\n if (error) return { email: null, error, unresolvedFields: [] }\n\n const steps = input.sequence?.steps ?? []\n const stepIndex = input.enrollment?.stepIndex\n const step = steps[stepIndex]\n if (step?.kind !== 'email') {\n return refuse('not_an_email_step', 'The step this enrollment is on does not send an email.')\n }\n const to = normalizeContactEmail(input.enrollment.email)\n if (!to) {\n return refuse('invalid_recipient', \"This enrollment's address isn't a valid email address.\")\n }\n\n const context: CrmMergeContext = {\n ...(input.merge ?? {}),\n extra: {\n ...(input.merge?.extra ?? {}),\n [OUTREACH_PERSONAL_LINE_FIELD]: normalizeOutreachPersonalLine(input.enrollment.personalLine),\n },\n }\n const unresolved: string[] = []\n const render = (text: string): string => {\n const result = resolveCrmMergeFields(text, context)\n for (const key of result.unresolved) if (!unresolved.includes(key)) unresolved.push(key)\n return result.text\n }\n\n const email: ComposedOutreachEmail = { to, subject: '', text: '' }\n if (isInThreadEmailStep(steps, stepIndex)) {\n const threadSubject = crmThreadSubject(input.enrollment.threadSubject)\n const messageIds = (input.enrollment.messageIds ?? [])\n .map(outreachMessageIdHeader)\n .filter((id): id is string => id !== null)\n const threadId = String(input.enrollment.gmailThreadId ?? '').trim()\n if (!threadSubject || !messageIds.length || !threadId) {\n return refuse(\n 'missing_thread',\n 'This email replies in the thread, and no earlier email in the thread was sent.',\n )\n }\n email.subject = `Re: ${threadSubject}`.slice(0, CRM_EMAIL_SUBJECT_MAX)\n email.threadId = threadId\n email.inReplyTo = messageIds[messageIds.length - 1]\n email.references = messageIds.join(' ')\n } else {\n const subject = oneLine(render(String(step.subject ?? '')))\n if (!subject) return refuse('missing_subject', 'This email has no subject.')\n if (outreachSubjectHasReplyPrefix(subject)) {\n return refuse(\n 'subject_reply_prefix',\n 'This email starts a thread, so its subject may not begin with \"Re:\" or \"Fwd:\".',\n )\n }\n email.subject = subject.slice(0, CRM_EMAIL_SUBJECT_MAX)\n }\n\n const source = step.templateId ? String(input.templateBody ?? '') : String(step.body ?? '')\n const body = render(source)\n .replace(/\\r\\n?/g, '\\n')\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/, ''))\n .join('\\n')\n .replace(/^\\n+|\\n+$/g, '')\n if (!body.trim()) return refuse('missing_body', 'This email has no body.')\n email.text = `${body}\\n\\n${footer}`\n\n const url = unsubscribeUrl(input.listUnsubscribeUrl)\n if (url === null) {\n return refuse('invalid_unsubscribe_url', 'The unsubscribe link must be an https URL.')\n }\n if (url) email.listUnsubscribeUrl = url\n const mailto = unsubscribeMailto(input.listUnsubscribeMailto)\n if (mailto === null) {\n return refuse('invalid_unsubscribe_mailto', 'The unsubscribe address is not an email address.')\n }\n if (mailto) email.listUnsubscribeMailto = mailto\n\n return { email, error: null, unresolvedFields: unresolved }\n}\n"],"names":["normalizeContactEmail","CRM_EMAIL_SUBJECT_MAX","resolveCrmMergeFields","crmThreadSubject","normalizeOutreachPersonalLine","isInThreadEmailStep","OUTREACH_PERSONAL_LINE_FIELD","outreachSubjectHasReplyPrefix","OUTREACH_OPT_OUT_LINE","outreachSolicitationStatement","senderName","oneLine","value","replace","trim","composeOutreachFooter","settings","postalAddress","String","split","map","line","filter","Boolean","join","footer","error","code","message","legalName","brandName","outreachMessageIdHeader","bare","test","unsubscribeUrl","raw","undefined","url","URL","protocol","href","unsubscribeMailto","address","target","slice","length","decodeURIComponent","refuse","email","unresolvedFields","composeOutreachEmail","input","step","orgSettings","steps","sequence","stepIndex","enrollment","kind","to","context","merge","extra","personalLine","unresolved","render","text","result","key","includes","push","subject","threadSubject","messageIds","id","threadId","gmailThreadId","inReplyTo","references","source","templateId","templateBody","body","listUnsubscribeUrl","mailto","listUnsubscribeMailto"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type OutreachAttestations, type OutreachEnrollment, type OutreachEnrollmentStatus, type OutreachMailbox, type OutreachSequence, type OutreachStopReason } from '../model/outreach.types';
17
+ import { type OutreachAttestations, type OutreachEnrollment, type OutreachEnrollmentStatus, type OutreachEnrollmentTarget, type OutreachMailbox, type OutreachSequence, type OutreachStopReason } from '../model/outreach.types';
18
18
  import { type OutreachRandom } from './schedule';
19
19
  /** Where each status may go next. */
20
20
  export declare const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>>;
@@ -88,7 +88,12 @@ export interface OutreachEnrollmentDraft {
88
88
  id: string;
89
89
  sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings'>;
90
90
  mailbox: ScheduleMailbox;
91
+ /** The record the person is (AGL-3234); a contact when absent. */
92
+ target?: OutreachEnrollmentTarget;
93
+ /** The contact's id; `''` for a lead. */
91
94
  contactId: string;
95
+ /** The lead's person key, for an enrollment made on a lead. */
96
+ leadId?: string | null;
92
97
  /** The contact's name as the sending site knows it; `''` for none. */
93
98
  contactName?: string | null;
94
99
  /** The address, as the gates normalized it. */
@@ -188,11 +188,13 @@ const detailOf = (value)=>{
188
188
  * door that enrolls, so an enrollment made from a contact and one made from
189
189
  * a list are the same shape. Call it after the gates allowed the person.
190
190
  */ export function buildOutreachEnrollment(draft) {
191
- var _draft_contactName, _draft_email, _draft_attestations;
191
+ var _draft_target, _draft_leadId, _draft_contactName, _draft_email, _draft_attestations;
192
192
  return {
193
193
  id: draft.id,
194
194
  sequenceId: draft.sequence.id,
195
+ target: (_draft_target = draft.target) != null ? _draft_target : 'contact',
195
196
  contactId: draft.contactId,
197
+ leadId: (_draft_leadId = draft.leadId) != null ? _draft_leadId : null,
196
198
  contactName: String((_draft_contactName = draft.contactName) != null ? _draft_contactName : '').replace(/\s+/g, ' ').trim().slice(0, 200),
197
199
  email: String((_draft_email = draft.email) != null ? _draft_email : '').trim().toLowerCase(),
198
200
  hostId: draft.sequence.hostId,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | { type: 'pause'; atMs: number; byUid: string | null; detail?: string | null }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? 'manual'\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings'>\n mailbox: ScheduleMailbox\n contactId: string\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n contactId: draft.contactId,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","contactId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;4CAsB4C,GAE5C,SACEA,0BAA0B,EAC1BC,+BAA+B,QAQ1B,6BAAyB;AAChC,SAASC,6BAA6B,QAAQ,aAAS;AACvD,SAASC,uBAAuB,EAAuBC,mBAAmB,QAAQ,gBAAY;AAC9F,SAASC,mBAAmB,QAAQ,2BAAuB;AAE3D,mCAAmC,GACnC,OAAO,MAAMC,kCAET;IACFC,QAAQ;QAAC;QAAU;QAAY;QAAW;QAAW;QAAa;QAAW;KAAS;IACtFC,QAAQ;QAAC;QAAU;QAAW;QAAW;QAAa;KAAU;IAChEC,UAAU;QAAC;QAAW;QAAW;KAAY;IAC7CC,SAAS;QAAC;QAAW;QAAW;KAAY;IAC5CC,QAAQ;QAAC;QAAW;QAAW;KAAY;IAC3CC,SAAS;QAAC;KAAY;IACtBC,SAAS,EAAE;IACXC,WAAW,EAAE;AACf,EAAC;AAED,OAAO,SAASC,gCACdC,IAA8B,EAC9BC,EAA4B;QAErBX;IAAP,OAAOA,EAAAA,wCAAAA,+BAA+B,CAACU,KAAK,qBAArCV,sCAAuCY,QAAQ,CAACD,SAAQ;AACjE;AA2CA,MAAME,eAAyD;IAC7DZ,QAAQ;IACRC,QAAQ;IACRC,UAAU;IACVG,SAAS;IACTC,SAAS;IACTC,WAAW;IACXJ,SAAS;IACTC,QAAQ;AACV;AAEA,MAAMS,eAAkF;IACtFC,OAAO;IACPC,QAAQ;IACRC,MAAM;IACNC,OAAO;IACPC,SAAS;IACTC,QAAQ;IACRC,aAAa;AACf;AAEA,MAAMC,WAAW,CAACC;IAChB,MAAMC,OAAO,OAAOD,UAAU,WAAWA,MAAME,OAAO,CAAC,QAAQ,KAAKC,IAAI,KAAK;IAC7E,OAAOF,OAAOA,KAAKG,KAAK,CAAC,GAAG,OAAO;AACrC;AAEA,wEAAwE,GACxE,OAAO,SAASC,6BACdC,UAA8D,EAC9DC,KAA8B;QA6BrBA;IA3BT,MAAMpB,OAAOmB,WAAWE,MAAM;IAC9B,MAAMpB,KAAKG,YAAY,CAACgB,MAAME,IAAI,CAAC;IACnC,IAAI,CAACrB,IAAI,OAAO;QAAEsB,OAAO;QAAMC,OAAO;IAAuD;IAC7F,IAAIxB,SAASC,IAAI,OAAO;QAAEsB,OAAO;QAAMC,OAAO;IAAK;IACnD,IAAI,CAACzB,gCAAgCC,MAAMC,KAAK;QAC9C,OAAO;YACLsB,OAAO;YACPC,OAAO,CAAC,gBAAgB,EAAErB,YAAY,CAACH,KAAK,CAAC,oCAAoC,EAAEG,YAAY,CAACF,GAAG,CAAC,CAAC,CAAC;QACxG;IACF;IACA,IAAImB,MAAME,IAAI,KAAK,UAAU;QAC3B,OAAO;YACLC,OAAO;gBACLF,QAAQ;gBACRI,YAAY;gBACZC,YAAY;gBACZC,aAAa;gBACbC,cAAc;gBACdC,aAAaV,WAAWU,WAAW;YACrC;YACAL,OAAO;QACT;IACF;IACA,MAAMM,SACJV,MAAME,IAAI,KAAK,UACX,WACAF,MAAME,IAAI,KAAK,UACZF,gBAAAA,MAAMU,MAAM,YAAZV,gBAAgB,WACjBA,MAAME,IAAI,KAAK,UACb,UACAF,MAAME,IAAI,KAAK,YACbF,MAAMU,MAAM,GACZV,MAAME,IAAI,KAAK,WACb,gBACA;IACd,MAAMS,UAAU9C,+BAA+B,CAACgB,GAAmD;IACnG,IAAI,EAAC8B,2BAAAA,QAAS7B,QAAQ,CAAC4B,UAAS;QAC9B,OAAO;YAAEP,OAAO;YAAMC,OAAO,CAAC,CAAC,EAAEM,OAAO,+BAA+B,EAAE3B,YAAY,CAACF,GAAG,CAAC,CAAC,CAAC;QAAC;IAC/F;IACA,MAAM+B,QAAQ,WAAWZ,SAAS,OAAOA,MAAMY,KAAK,KAAK,YAAYZ,MAAMY,KAAK,GAAGZ,MAAMY,KAAK,GAAG;IACjG,OAAO;QACLT,OAAO;YACLF,QAAQpB;YACRwB,YAAYK;YACZJ,YAAYd,SAASQ,MAAMa,MAAM;YACjCN,aAAaP,MAAMc,IAAI;YACvBN,cAAcI;YACd,wEAAwE;YACxE,wDAAwD;YACxDH,aAAa5B,OAAO,WAAWkB,WAAWU,WAAW,GAAG;QAC1D;QACAL,OAAO;IACT;AACF;AAKA,sFAAsF,GACtF,OAAO,SAASW,qBAAqBC,KAKpC;QACeA,uBAMoBA;IANlC,MAAMC,SAAQD,wBAAAA,MAAME,QAAQ,CAACC,KAAK,qBAApBH,qBAAsB,CAAC,EAAE;IACvC,IAAI,CAACC,OAAO,OAAO;IACnB,OAAOjD,oBAAoB;QACzBoD,QAAQJ,MAAMK,YAAY;QAC1BC,mBAAmBL,MAAMK,iBAAiB;QAC1CC,UAAUP,MAAMQ,OAAO,CAACC,QAAQ;QAChCC,QAAQ3D,yBAAwBiD,2BAAAA,MAAME,QAAQ,CAACS,QAAQ,qBAAvBX,yBAAyBU,MAAM,EAAEV,MAAMQ,OAAO,CAACE,MAAM;QACrFE,QAAQZ,MAAMY,MAAM;IACtB;AACF;AAEA,mGAAmG,GACnG,OAAO,SAASC,yBACdC,KAAyB,EACzBC,GAAW,EACXjB,IAAY;IAEZ,MAAMkB,eAAqC,CAAC;IAC5C,KAAK,MAAMC,QAAQH,gBAAAA,QAAS,EAAE,CAAE;QAC9B,IAAI,AAAClE,2BAAkDkB,QAAQ,CAACmD,OAAO;YACrED,YAAY,CAACC,KAAgC,GAAG;gBAAEF;gBAAKjB;YAAK;QAC9D;IACF;IACA,OAAOkB;AACT;AAoBA;;;;CAIC,GACD,OAAO,SAASE,wBAAwBC,KAA8B;QAK9CA,oBACNA,cAiBMA;IAtBtB,OAAO;QACLC,IAAID,MAAMC,EAAE;QACZC,YAAYF,MAAMjB,QAAQ,CAACkB,EAAE;QAC7BE,WAAWH,MAAMG,SAAS;QAC1BC,aAAaC,QAAOL,qBAAAA,MAAMI,WAAW,YAAjBJ,qBAAqB,IAAIxC,OAAO,CAAC,QAAQ,KAAKC,IAAI,GAAGC,KAAK,CAAC,GAAG;QAClF4C,OAAOD,QAAOL,eAAAA,MAAMM,KAAK,YAAXN,eAAe,IAAIvC,IAAI,GAAG8C,WAAW;QACnDC,QAAQR,MAAMjB,QAAQ,CAACyB,MAAM;QAC7BC,WAAWT,MAAMjB,QAAQ,CAAC0B,SAAS;QACnCC,WAAW;QACXpC,aAAaM,qBAAqB;YAChCG,UAAUiB,MAAMjB,QAAQ;YACxBM,SAASW,MAAMX,OAAO;YACtBH,cAAcc,MAAMW,KAAK;YACzBlB,QAAQO,MAAMP,MAAM;QACtB;QACA3B,QAAQ;QACRI,YAAY;QACZC,YAAY;QACZC,aAAa;QACbC,cAAc;QACduC,cAAcjF,8BAA8BqE,MAAMY,YAAY;QAC9DC,MAAMb,MAAMa,IAAI,KAAK;QACrBhB,cAAc,cAAMG,sBAAAA,MAAMH,YAAY,YAAlBG,sBAAsB,CAAC;QAC3Cc,eAAed,MAAMc,aAAa;QAClCC,eAAe;QACfC,gBAAgB,EAAE;QAClBC,eAAe;QACfC,YAAY,EAAE;QACdC,cAAc;QACdC,aAAapB,MAAMW,KAAK;QACxBU,aAAarB,MAAMW,KAAK;IAC1B;AACF;AAiDA;;;;CAIC,GACD,OAAO,SAASW,2BACdzC,KAAkC;QAMpBE;QAoCoBA;IAxClC,MAAM,EAAEnB,UAAU,EAAEmB,QAAQ,EAAE,GAAGF;IACjC,IAAIjB,WAAWE,MAAM,KAAK,UAAU;QAClC,OAAO;YAAEE,OAAO;YAAMC,OAAO;QAA4C;IAC3E;IACA,MAAMe,SAAQD,kBAAAA,SAASC,KAAK,YAAdD,kBAAkB,EAAE;IAClC,MAAMwC,OAAOvC,KAAK,CAACpB,WAAW8C,SAAS,CAAC;IACxC,IAAI,CAACa,MAAM,OAAO;QAAEvD,OAAO;QAAMC,OAAO;IAA+C;IACvF,MAAMD,QAAqC;QACzCF,QAAQ;QACR4C,WAAW9C,WAAW8C,SAAS,GAAG;QAClCpC,aAAa;QACb6C,cAActC,MAAM2C,aAAa;IACnC;IACA,IAAID,KAAKzB,IAAI,KAAK,SAAS;yBAOFlC;QANvB,MAAM6D,OAAO5C,MAAM4C,IAAI;QACvB,MAAMC,YAAYrB,eAAOoB,wBAAAA,KAAMC,SAAS,mBAAI,IAAIjE,IAAI;QACpD,MAAMkE,WAAWtB,gBAAOoB,wBAAAA,KAAME,QAAQ,oBAAI,IAAIlE,IAAI;QAClD,IAAI,CAACiE,aAAa,CAACC,UAAU;YAC3B,OAAO;gBAAE3D,OAAO;gBAAMC,OAAO;YAAuD;QACtF;QACA,MAAM2D,YAAY;gBAAKhE,6BAAAA,WAAWoD,cAAc,YAAzBpD,6BAA6B,EAAE;SAAE;QACxD,IAAI,CAACgE,UAAUjF,QAAQ,CAACgF,WAAWC,UAAUC,IAAI,CAACF;QAClD3D,MAAM+C,aAAa,GAAGY;QACtB3D,MAAMgD,cAAc,GAAGY;QACvB,IAAI9F,oBAAoBkD,OAAOpB,WAAW8C,SAAS,KAAK9C,WAAWqD,aAAa,EAAE;gBACxDrD;YAAxBI,MAAMkD,UAAU,GAAG;oBAAKtD,yBAAAA,WAAWsD,UAAU,YAArBtD,yBAAyB,EAAE;gBAAG8D;aAAU;QAClE,OAAO;gBACwBD;YAA7BzD,MAAMiD,aAAa,GAAGZ,QAAOoB,gBAAAA,KAAKK,OAAO,YAAZL,gBAAgB,IAAIhE,IAAI;YACrDO,MAAMkD,UAAU,GAAG;gBAACQ;aAAU;QAChC;IACF;IACA,MAAMK,OAAO/C,KAAK,CAAChB,MAAM0C,SAAS,CAAC;IACnC,IAAI,CAACqB,MAAM;QACT/D,MAAMF,MAAM,GAAG;QACf,OAAO;YAAEE;YAAOC,OAAO;QAAK;IAC9B;IACAD,MAAMM,WAAW,GAAGzC,oBAAoB;QACtCoD,QAAQJ,MAAM2C,aAAa;QAC3BrC,mBAAmB4C,KAAK5C,iBAAiB;QACzCC,UAAUP,MAAMQ,OAAO,CAACC,QAAQ;QAChCC,QAAQ3D,yBAAwBmD,qBAAAA,SAASS,QAAQ,qBAAjBT,mBAAmBQ,MAAM,EAAEV,MAAMQ,OAAO,CAACE,MAAM;QAC/EE,QAAQZ,MAAMY,MAAM;IACtB;IACA,OAAO;QACLzB;QACAC,OACED,MAAMM,WAAW,KAAK,OAClB,8FACA;IACR;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/enrollment-state.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 * ONE PERSON'S RUN THROUGH A SEQUENCE (AGL-2979).\n *\n * ## The state machine\n *\n * active ──► paused ──► active\n * │ │\n * ├──────────┴──► replied · bounced · opted_out · stopped\n * └─► finished · failed\n *\n * An enrollment sends only while `active`. Everything that ends the sending\n * records why (`stopReason`, `stopDetail`, when, and who). Ending the\n * sending does not end what can still be learned from the thread: a reply,\n * a bounce or an opt-out that arrives after the last step, after a member\n * stopped it or after a send failed is still recorded, and a person who\n * replied and then asked to be left alone moves on to `opted_out` — an\n * opt-out is the one fact every later sequence has to be able to find.\n * `bounced` and `opted_out` are final.\n *\n * Every function returns a PATCH — the fields to write — and never the\n * whole document, so a writer applies it with its own SDK and its own\n * timestamps.\n *==========================================*/\n\nimport {\n OUTREACH_ATTESTATION_KINDS,\n OUTREACH_STOP_REASONS_BY_STATUS,\n type OutreachAttestationKind,\n type OutreachAttestations,\n type OutreachEnrollment,\n type OutreachEnrollmentStatus,\n type OutreachEnrollmentTarget,\n type OutreachMailbox,\n type OutreachSequence,\n type OutreachStopReason,\n} from '../model/outreach.types'\nimport { normalizeOutreachPersonalLine } from './gates'\nimport { effectiveOutreachWindow, type OutreachRandom, scheduleOutreachDue } from './schedule'\nimport { isInThreadEmailStep } from './sequence-validation'\n\n/** Where each status may go next. */\nexport const OUTREACH_ENROLLMENT_TRANSITIONS: Readonly<\n Record<OutreachEnrollmentStatus, readonly OutreachEnrollmentStatus[]>\n> = {\n active: ['paused', 'finished', 'replied', 'bounced', 'opted_out', 'stopped', 'failed'],\n paused: ['active', 'replied', 'bounced', 'opted_out', 'stopped'],\n finished: ['replied', 'bounced', 'opted_out'],\n stopped: ['replied', 'bounced', 'opted_out'],\n failed: ['replied', 'bounced', 'opted_out'],\n replied: ['opted_out'],\n bounced: [],\n opted_out: [],\n}\n\nexport function canTransitionOutreachEnrollment(\n from: OutreachEnrollmentStatus,\n to: OutreachEnrollmentStatus,\n): boolean {\n return OUTREACH_ENROLLMENT_TRANSITIONS[from]?.includes(to) === true\n}\n\n/** Something that happened to an enrollment. `atMs` is when it happened. */\nexport type OutreachEnrollmentEvent =\n | { type: 'pause'; atMs: number; byUid: string | null; detail?: string | null }\n | { type: 'resume'; atMs: number; byUid: string | null }\n | {\n type: 'stop'\n atMs: number\n byUid: string | null\n reason?: Extract<OutreachStopReason, 'manual' | 'gate' | 'sequence_archived'>\n detail?: string | null\n }\n | { type: 'reply'; atMs: number; detail?: string | null }\n | {\n type: 'opt_out'\n atMs: number\n reason: Extract<OutreachStopReason, 'opt_out_reply' | 'unsubscribe' | 'do_not_contact'>\n detail?: string | null\n }\n | { type: 'bounce'; atMs: number; detail?: string | null }\n | { type: 'send_failed'; atMs: number; detail?: string | null }\n\n/** The status fields an event writes. */\nexport interface OutreachEnrollmentStatusPatch {\n status: OutreachEnrollmentStatus\n stopReason: OutreachStopReason | null\n stopDetail: string | null\n stoppedAtMs: number | null\n stoppedByUid: string | null\n nextDueAtMs: number | null\n}\n\n/**\n * `patch` is what to write, or `null` when there is nothing to write — the\n * enrollment is already there, or the event is not allowed, in which case\n * `error` says why.\n */\nexport interface OutreachEnrollmentEventResult {\n patch: OutreachEnrollmentStatusPatch | null\n error: string | null\n}\n\nconst STATUS_WORDS: Record<OutreachEnrollmentStatus, string> = {\n active: 'is active',\n paused: 'is paused',\n finished: 'finished',\n replied: 'got a reply',\n bounced: 'bounced',\n opted_out: 'opted out',\n stopped: 'was stopped',\n failed: 'failed',\n}\n\nconst EVENT_TARGET: Record<OutreachEnrollmentEvent['type'], OutreachEnrollmentStatus> = {\n pause: 'paused',\n resume: 'active',\n stop: 'stopped',\n reply: 'replied',\n opt_out: 'opted_out',\n bounce: 'bounced',\n send_failed: 'failed',\n}\n\nconst detailOf = (value: unknown): string | null => {\n const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n return text ? text.slice(0, 500) : null\n}\n\n/** What an event does to an enrollment's status — see the module note. */\nexport function applyOutreachEnrollmentEvent(\n enrollment: Pick<OutreachEnrollment, 'status' | 'nextDueAtMs'>,\n event: OutreachEnrollmentEvent,\n): OutreachEnrollmentEventResult {\n const from = enrollment.status\n const to = EVENT_TARGET[event.type]\n if (!to) return { patch: null, error: 'That is not something that happens to an enrollment.' }\n if (from === to) return { patch: null, error: null }\n if (!canTransitionOutreachEnrollment(from, to)) {\n return {\n patch: null,\n error: `This enrollment ${STATUS_WORDS[from]}, so it can't be marked as one that ${STATUS_WORDS[to]}.`,\n }\n }\n if (event.type === 'resume') {\n return {\n patch: {\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n nextDueAtMs: enrollment.nextDueAtMs,\n },\n error: null,\n }\n }\n const reason: OutreachStopReason =\n event.type === 'pause'\n ? 'manual'\n : event.type === 'stop'\n ? (event.reason ?? 'manual')\n : event.type === 'reply'\n ? 'reply'\n : event.type === 'opt_out'\n ? event.reason\n : event.type === 'bounce'\n ? 'hard_bounce'\n : 'send_failed'\n const allowed = OUTREACH_STOP_REASONS_BY_STATUS[to as keyof typeof OUTREACH_STOP_REASONS_BY_STATUS]\n if (!allowed?.includes(reason)) {\n return { patch: null, error: `\"${reason}\" isn't a reason an enrollment ${STATUS_WORDS[to]}.` }\n }\n const byUid = 'byUid' in event && typeof event.byUid === 'string' && event.byUid ? event.byUid : null\n return {\n patch: {\n status: to,\n stopReason: reason,\n stopDetail: detailOf(event.detail),\n stoppedAtMs: event.atMs,\n stoppedByUid: byUid,\n // A paused enrollment keeps its place, so resuming it picks up where it\n // stopped; every other status has nothing left waiting.\n nextDueAtMs: to === 'paused' ? enrollment.nextDueAtMs : null,\n },\n error: null,\n }\n}\n\ntype ScheduleMailbox = Pick<OutreachMailbox, 'timezone' | 'window'>\ntype ScheduleSequence = Pick<OutreachSequence, 'steps' | 'settings'>\n\n/** When a new enrollment's first step comes due, or `null` when it cannot be placed. */\nexport function planOutreachFirstDue(input: {\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n enrolledAtMs: number\n random: OutreachRandom\n}): number | null {\n const first = input.sequence.steps?.[0]\n if (!first) return null\n return scheduleOutreachDue({\n fromMs: input.enrolledAtMs,\n delayBusinessDays: first.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(input.sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n}\n\n/** Stamps the attestations a rep ticked with who ticked them and when; unknown kinds are dropped. */\nexport function outreachAttestationsFrom(\n kinds: readonly unknown[],\n uid: string,\n atMs: number,\n): OutreachAttestations {\n const attestations: OutreachAttestations = {}\n for (const kind of kinds ?? []) {\n if ((OUTREACH_ATTESTATION_KINDS as readonly unknown[]).includes(kind)) {\n attestations[kind as OutreachAttestationKind] = { uid, atMs }\n }\n }\n return attestations\n}\n\nexport interface OutreachEnrollmentDraft {\n id: string\n sequence: Pick<OutreachSequence, 'id' | 'hostId' | 'mailboxId' | 'steps' | 'settings'>\n mailbox: ScheduleMailbox\n /** The record the person is (AGL-3234); a contact when absent. */\n target?: OutreachEnrollmentTarget\n /** The contact's id; `''` for a lead. */\n contactId: string\n /** The lead's person key, for an enrollment made on a lead. */\n leadId?: string | null\n /** The contact's name as the sending site knows it; `''` for none. */\n contactName?: string | null\n /** The address, as the gates normalized it. */\n email: string\n /** The gates' `cold`. */\n cold: boolean\n personalLine: string\n attestations: OutreachAttestations\n enrolledByUid: string\n nowMs: number\n random: OutreachRandom\n}\n\n/**\n * A new enrollment document, first step scheduled — one builder for every\n * door that enrolls, so an enrollment made from a contact and one made from\n * a list are the same shape. Call it after the gates allowed the person.\n */\nexport function buildOutreachEnrollment(draft: OutreachEnrollmentDraft): OutreachEnrollment {\n return {\n id: draft.id,\n sequenceId: draft.sequence.id,\n target: draft.target ?? 'contact',\n contactId: draft.contactId,\n leadId: draft.leadId ?? null,\n contactName: String(draft.contactName ?? '').replace(/\\s+/g, ' ').trim().slice(0, 200),\n email: String(draft.email ?? '').trim().toLowerCase(),\n hostId: draft.sequence.hostId,\n mailboxId: draft.sequence.mailboxId,\n stepIndex: 0,\n nextDueAtMs: planOutreachFirstDue({\n sequence: draft.sequence,\n mailbox: draft.mailbox,\n enrolledAtMs: draft.nowMs,\n random: draft.random,\n }),\n status: 'active',\n stopReason: null,\n stopDetail: null,\n stoppedAtMs: null,\n stoppedByUid: null,\n personalLine: normalizeOutreachPersonalLine(draft.personalLine),\n cold: draft.cold === true,\n attestations: { ...(draft.attestations ?? {}) },\n enrolledByUid: draft.enrolledByUid,\n gmailThreadId: null,\n gmailThreadIds: [],\n threadSubject: null,\n messageIds: [],\n lastSentAtMs: null,\n createdAtMs: draft.nowMs,\n updatedAtMs: draft.nowMs,\n }\n}\n\n/** What the provider reported for an email step that was sent. */\nexport interface OutreachSentEmail {\n /** The `Message-ID` header the message went out with, angle brackets included. */\n messageId: string\n /** The provider's thread id. */\n threadId: string\n /** The subject as sent. */\n subject: string\n}\n\nexport interface OutreachStepCompletionInput {\n enrollment: Pick<\n OutreachEnrollment,\n 'status' | 'stepIndex' | 'gmailThreadId' | 'gmailThreadIds' | 'messageIds' | 'threadSubject'\n >\n sequence: ScheduleSequence\n mailbox: ScheduleMailbox\n /** When the step ran. */\n completedAtMs: number\n /** For an email step, what was sent; not read for a task. */\n sent?: OutreachSentEmail | null\n random: OutreachRandom\n}\n\n/** The fields a completed step writes. */\nexport interface OutreachStepCompletionPatch {\n status: 'active' | 'finished'\n stepIndex: number\n nextDueAtMs: number | null\n lastSentAtMs: number\n gmailThreadId?: string\n gmailThreadIds?: string[]\n threadSubject?: string\n messageIds?: string[]\n}\n\nexport interface OutreachStepCompletionResult {\n patch: OutreachStepCompletionPatch | null\n /**\n * Why there is no patch, or — beside one — that the next step could not\n * be placed on the calendar (a window that never opens, a zone `Intl` does\n * not know), which leaves the enrollment with nothing due until a member\n * fixes the window and resumes it.\n */\n error: string | null\n}\n\n/**\n * What running the current step writes: the send recorded on the thread\n * fields, and the next step scheduled — or the enrollment `finished` when\n * that was the last.\n */\nexport function planOutreachStepCompletion(\n input: OutreachStepCompletionInput,\n): OutreachStepCompletionResult {\n const { enrollment, sequence } = input\n if (enrollment.status !== 'active') {\n return { patch: null, error: 'Only an active enrollment runs its steps.' }\n }\n const steps = sequence.steps ?? []\n const step = steps[enrollment.stepIndex]\n if (!step) return { patch: null, error: 'This enrollment has no step at its position.' }\n const patch: OutreachStepCompletionPatch = {\n status: 'active',\n stepIndex: enrollment.stepIndex + 1,\n nextDueAtMs: null,\n lastSentAtMs: input.completedAtMs,\n }\n if (step.kind === 'email') {\n const sent = input.sent\n const messageId = String(sent?.messageId ?? '').trim()\n const threadId = String(sent?.threadId ?? '').trim()\n if (!messageId || !threadId) {\n return { patch: null, error: 'An email step needs the sent message and thread ids.' }\n }\n const threadIds = [...(enrollment.gmailThreadIds ?? [])]\n if (!threadIds.includes(threadId)) threadIds.push(threadId)\n patch.gmailThreadId = threadId\n patch.gmailThreadIds = threadIds\n if (isInThreadEmailStep(steps, enrollment.stepIndex) && enrollment.threadSubject) {\n patch.messageIds = [...(enrollment.messageIds ?? []), messageId]\n } else {\n patch.threadSubject = String(sent.subject ?? '').trim()\n patch.messageIds = [messageId]\n }\n }\n const next = steps[patch.stepIndex]\n if (!next) {\n patch.status = 'finished'\n return { patch, error: null }\n }\n patch.nextDueAtMs = scheduleOutreachDue({\n fromMs: input.completedAtMs,\n delayBusinessDays: next.delayBusinessDays,\n timeZone: input.mailbox.timezone,\n window: effectiveOutreachWindow(sequence.settings?.window, input.mailbox.window),\n random: input.random,\n })\n return {\n patch,\n error:\n patch.nextDueAtMs === null\n ? \"The next step couldn't be scheduled: check the sending window and the mailbox's timezone.\"\n : null,\n }\n}\n"],"names":["OUTREACH_ATTESTATION_KINDS","OUTREACH_STOP_REASONS_BY_STATUS","normalizeOutreachPersonalLine","effectiveOutreachWindow","scheduleOutreachDue","isInThreadEmailStep","OUTREACH_ENROLLMENT_TRANSITIONS","active","paused","finished","stopped","failed","replied","bounced","opted_out","canTransitionOutreachEnrollment","from","to","includes","STATUS_WORDS","EVENT_TARGET","pause","resume","stop","reply","opt_out","bounce","send_failed","detailOf","value","text","replace","trim","slice","applyOutreachEnrollmentEvent","enrollment","event","status","type","patch","error","stopReason","stopDetail","stoppedAtMs","stoppedByUid","nextDueAtMs","reason","allowed","byUid","detail","atMs","planOutreachFirstDue","input","first","sequence","steps","fromMs","enrolledAtMs","delayBusinessDays","timeZone","mailbox","timezone","window","settings","random","outreachAttestationsFrom","kinds","uid","attestations","kind","buildOutreachEnrollment","draft","id","sequenceId","target","contactId","leadId","contactName","String","email","toLowerCase","hostId","mailboxId","stepIndex","nowMs","personalLine","cold","enrolledByUid","gmailThreadId","gmailThreadIds","threadSubject","messageIds","lastSentAtMs","createdAtMs","updatedAtMs","planOutreachStepCompletion","step","completedAtMs","sent","messageId","threadId","threadIds","push","subject","next"],"mappings":"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"}
@@ -49,7 +49,7 @@ const NONE = {
49
49
  complaint: false,
50
50
  matched: null
51
51
  };
52
- /** A line of the footer every Outreach email carries, however a client re-quoted it. */ const FOOTER_LINE = /reply\s+["“”'‘’]?no["“”'‘’]?\s+and\s+i\s+won['’]?t\s+email|business\s+solicitation\s+from/i;
52
+ /** A line of the footer every Outreach email carries, however a client re-quoted it. */ const FOOTER_LINE = /reply\s+["“”'‘’]?no["“”'‘’]?\s+and\s+i\s+won['’]?t\s+email|(?:business\s+solicitation|sales\s+email)\s+from/i;
53
53
  /**
54
54
  * The words a reply adds to its thread: the text above the quoted history,
55
55
  * with a signature and any quoted footer line removed.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/opt-out-intent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A REPLY THAT SAYS STOP (AGL-2979).\n *\n * Every Outreach email tells its reader to reply \"no\" to be left alone, so\n * a reply has to be read for that. Any reply already stops its sequence;\n * reading one as an OPT-OUT also puts the address on the organization's\n * do-not-contact list, so no later sequence reaches it either. Both mistakes\n * cost something: a missed opt-out lets another sequence write to somebody\n * who said no, and a false one silently loses a prospect who said \"no rush\".\n *\n * ## Only the reply's own words\n *\n * A reply quotes the email it answers, and that email's footer says \"Reply\n * 'no'\". So the quoted history is cut first — the CRM capture reader's\n * `stripQuotedHistory` — along with a signature and any footer line a mail\n * client quoted without marking it.\n *\n * ## What counts\n *\n * - A short reply that says nothing but no: \"No.\", \"No thanks\", \"Nope\",\n * \"Pass\", \"Stop\" — with a greeting and a sign-off allowed around it.\n * - A request anywhere in the reply: unsubscribe, remove me, take me off\n * the list, stop emailing, don't contact me, opt out, no more emails —\n * unless it is negated (\"please don't remove me\").\n * - \"Not interested\", unless it defers (\"not interested yet\", \"not\n * interested right now, try me next quarter\") or asks (\"not interested?\").\n * - A reply that calls the email spam, which is also a COMPLAINT.\n *\n * \"No rush\", \"no problem\" and \"no worries\" are none of these: a \"no\" only\n * counts when it is the whole answer.\n *==========================================*/\n\nimport { stripQuotedHistory } from '@aglyn/aglyn/app-utils/crm-inbound'\n\nexport interface OutreachOptOutIntent {\n /** The reply asks not to be emailed again. */\n optOut: boolean\n /** The reply calls the email spam — an opt-out that also counts against the mailbox. */\n complaint: boolean\n /** The words that decided it, for the timeline and the log; `null` when nothing matched. */\n matched: string | null\n}\n\nconst NONE: OutreachOptOutIntent = { optOut: false, complaint: false, matched: null }\n\n/** A line of the footer every Outreach email carries, however a client re-quoted it. */\nconst FOOTER_LINE = /reply\\s+[\"“”'‘’]?no[\"“”'‘’]?\\s+and\\s+i\\s+won['’]?t\\s+email|business\\s+solicitation\\s+from/i\n\n/**\n * The words a reply adds to its thread: the text above the quoted history,\n * with a signature and any quoted footer line removed.\n */\nexport function outreachFreshReplyText(text: unknown): string {\n const body = stripQuotedHistory(\n String(text ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[\\u00a0\\u202f]/g, ' '),\n )\n const lines = body.split('\\n')\n const signature = lines.findIndex((line) => /^--\\s*$/.test(line))\n return (signature >= 0 ? lines.slice(0, signature) : lines)\n .filter((line) => !FOOTER_LINE.test(line))\n .join('\\n')\n .trim()\n}\n\n/** Curly quotes straightened, case folded, whitespace settled. */\nfunction fold(text: string): string {\n return text\n .replace(/[‘’]/g, \"'\")\n .replace(/[“”]/g, '\"')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nconst GREETING_LINE = /^(hi|hello|hey|dear|greetings|good (morning|afternoon|evening))\\b[^\\n]{0,40}$/i\nconst GREETING_LEAD = /^(hi|hello|hey|dear|greetings)(\\s+[a-z'’-]+)?\\s*[,!.:–—-]\\s*/i\nconst SIGN_OFF =\n /^(thanks|thank you|thx|many thanks|cheers|regards|best|best regards|kind regards|warm regards|sincerely|all the best|sent from my\\b.*|get outlook\\b.*)[\\s,.!]*$/i\nconst NAME = \"[A-Z][a-z'’-]+( [A-Z][a-z'’-]+)?\"\n/** Thanks, then a signed name: \"No thanks, Jordan\" or \"No thanks — Jordan\". */\nconst THANKS_THEN_NAME = new RegExp(`\\\\b(thanks|thank you|thx|cheers)[\\\\s,–—-]+${NAME}[.!]?\\\\s*$`, 'i')\nconst NAME_LINE = new RegExp(`^${NAME}[.!]?$`)\n\n/**\n * What is left of a reply once its greeting, its sign-off and a name signed\n * after \"thanks\" are gone. A name is only taken off after a thank-you,\n * because \"No, WordPress.\" is an answer about a platform, not a signature.\n */\nfunction answerOnly(fresh: string): string {\n const lines = fresh\n .split('\\n')\n .map((line) => line.trim())\n .filter(Boolean)\n if (lines.length > 1 && GREETING_LINE.test(lines[0])) lines.shift()\n const signOff = lines.findIndex((line, index) => index > 0 && SIGN_OFF.test(line))\n const kept = signOff >= 0 ? lines.slice(0, signOff) : lines\n if (\n kept.length > 1 &&\n NAME_LINE.test(kept[kept.length - 1]) &&\n /\\b(thanks|thank you|thx|cheers)[\\s,.!]*$/i.test(kept[kept.length - 2])\n ) {\n kept.pop()\n }\n return kept\n .join(' ')\n .replace(GREETING_LEAD, '')\n .replace(THANKS_THEN_NAME, '$1')\n}\n\n/** The only words a reply may hold to be read as a bare \"no\". */\nconst BARE_NO_WORDS = new Set([\n 'no',\n 'nope',\n 'nah',\n 'pass',\n \"i'll\",\n 'ill',\n 'i',\n 'will',\n 'stop',\n 'unsubscribe',\n 'remove',\n 'me',\n 'us',\n 'please',\n 'thanks',\n 'thank',\n 'you',\n 'thx',\n 'sorry',\n 'but',\n 'not',\n 'for',\n 'interested',\n])\nconst BARE_NO_TRIGGERS = new Set(['no', 'nope', 'nah', 'pass', 'stop', 'unsubscribe', 'remove'])\n\nfunction bareNo(answer: string): string | null {\n const words = fold(answer)\n .replace(/[^a-z' ]+/g, ' ')\n .split(' ')\n .filter(Boolean)\n if (!words.length || words.length > 6) return null\n if (!words.every((word) => BARE_NO_WORDS.has(word))) return null\n const said = words.join(' ')\n if (\n words.some((word) => BARE_NO_TRIGGERS.has(word)) ||\n /\\bnot interested\\b|\\bnot for (me|us)\\b/.test(said)\n ) {\n return said\n }\n return null\n}\n\n/** Negations that turn a request around when they come just before it. */\nconst NEGATED = /\\b(don't|dont|do not|didn't|didnt|did not|never|not|no need to|please don't|wouldn't|won't)\\s+(\\w+\\s+){0,2}$/\n\nconst REQUESTS: readonly RegExp[] = [\n /\\bunsubscrib(e|ed|ing)\\b/,\n /\\bopt(ed|ing)?[ -]?(me )?out\\b/,\n /\\bremove (me|us|my (email|e-mail|address|name|details|info))\\b/,\n /\\bremove\\b[^.!?\\n]{0,30}\\bfrom (your|the|this|all) (list|lists|mailing list|emails?|database|system|sequence)\\b/,\n /\\btake (me|us|my (email|e-mail|address|name)) off\\b/,\n /\\btake\\b[^.!?\\n]{0,30}\\boff (your|the|this|all) (list|lists|mailing list|emails?)\\b/,\n // Aimed at us: \"stop emailing me\", \"stop sending these\", \"stop contacting.\" —\n // not \"we had to stop sending newsletters ourselves\".\n /\\b(stop|quit) (emailing|e-mailing|mailing|contacting|messaging|writing|sending|reaching out)(?=\\s*([.!,;]|$)|\\s+(me|us|my|our|this|these|those|them|to (me|us)|emails?|e-mails?|messages?|mail)\\b)/,\n /\\bplease stop\\b/,\n /^stop\\b(?! (by|in|over|at|the|thinking|worrying))/,\n /\\b(do not|don't|dont|never) (email|e-mail|contact|message|write to|reach out to) (me|us)\\b/,\n /\\bno (more|further) (emails?|e-mails?|messages|mail|contact)\\b/,\n /\\b(don't|do not) want (to (hear|receive)|any more|these)\\b[^.!?\\n]{0,20}\\b(emails?|messages|from you)\\b/,\n]\n\nconst COMPLAINTS: readonly RegExp[] = [\n /\\b(this|that|it|you|your emails?|these emails?|this email)\\s*(is|are|'s|'re)\\s+(just\\s+)?spam\\b/,\n /\\b(stop|quit|don't|do not) spam(ming)?\\b/,\n /\\bspamming (me|us|my|our)\\b/,\n /\\b(i|we)('ve| have| just| will| am| are)?\\s*(report(ed|ing)?|mark(ed|ing)?|flag(ged|ging)?)\\b[^.!?\\n]{0,30}\\b(as|for) spam\\b/,\n]\n\n/** Words after \"not interested\" that put it off rather than rule it out. */\nconst DEFERRAL =\n /^[\\s,]*(yet|right now|for now|now|at the moment|at this (time|moment|point|stage)|today|this (week|month|quarter|year)|until|in the (near )?future|but\\b|however\\b|though\\b)/\n\nfunction notInterested(text: string): string | null {\n const pattern = /\\bnot (really |at all )?interested\\b/g\n for (let match = pattern.exec(text); match; match = pattern.exec(text)) {\n const before = text.slice(0, match.index)\n const after = text.slice(match.index + match[0].length)\n const sentenceEnd = after.search(/[.!?\\n]/)\n const rest = sentenceEnd >= 0 ? after.slice(0, sentenceEnd + 1) : after\n if (rest.trimEnd().endsWith('?')) continue\n if (DEFERRAL.test(after)) continue\n if (/\\b(if|unless|in case|are you|were you|you're|you are)\\s+(\\w+\\s+){0,2}$/.test(before)) continue\n return match[0]\n }\n return null\n}\n\n/**\n * Whether a SUBJECT says nothing but stop — \"Unsubscribe\", \"Remove me\",\n * \"STOP\" — as a message sent from an unsubscribe link, or typed fresh, does.\n * Only a bare answer counts: a subject is a sender's headline, and \"Stop\n * losing leads\" is one.\n */\nexport function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent {\n const said = bareNo(String(subject ?? ''))\n return said ? { optOut: true, complaint: false, matched: said } : NONE\n}\n\n/**\n * Whether a reply's text asks to be left alone — see the module note. Pass\n * the reply's text as received; the quoted history is removed here.\n */\nexport function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent {\n const fresh = outreachFreshReplyText(text)\n if (!fresh) return NONE\n const folded = fold(fresh)\n for (const pattern of COMPLAINTS) {\n const match = pattern.exec(folded)\n if (match) return { optOut: true, complaint: true, matched: match[0] }\n }\n const bare = bareNo(answerOnly(fresh))\n if (bare) return { optOut: true, complaint: false, matched: bare }\n for (const pattern of REQUESTS) {\n const match = pattern.exec(folded)\n if (match && !NEGATED.test(folded.slice(0, match.index))) {\n return { optOut: true, complaint: false, matched: match[0] }\n }\n }\n const declined = notInterested(folded)\n if (declined) return { optOut: true, complaint: false, matched: declined }\n return NONE\n}\n"],"names":["stripQuotedHistory","NONE","optOut","complaint","matched","FOOTER_LINE","outreachFreshReplyText","text","body","String","replace","lines","split","signature","findIndex","line","test","slice","filter","join","trim","fold","toLowerCase","GREETING_LINE","GREETING_LEAD","SIGN_OFF","NAME","THANKS_THEN_NAME","RegExp","NAME_LINE","answerOnly","fresh","map","Boolean","length","shift","signOff","index","kept","pop","BARE_NO_WORDS","Set","BARE_NO_TRIGGERS","bareNo","answer","words","every","word","has","said","some","NEGATED","REQUESTS","COMPLAINTS","DEFERRAL","notInterested","pattern","match","exec","before","after","sentenceEnd","search","rest","trimEnd","endsWith","detectOutreachOptOutSubject","subject","detectOutreachOptOutIntent","folded","bare","declined"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/engine/opt-out-intent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * A REPLY THAT SAYS STOP (AGL-2979).\n *\n * Every Outreach email tells its reader to reply \"no\" to be left alone, so\n * a reply has to be read for that. Any reply already stops its sequence;\n * reading one as an OPT-OUT also puts the address on the organization's\n * do-not-contact list, so no later sequence reaches it either. Both mistakes\n * cost something: a missed opt-out lets another sequence write to somebody\n * who said no, and a false one silently loses a prospect who said \"no rush\".\n *\n * ## Only the reply's own words\n *\n * A reply quotes the email it answers, and that email's footer says \"Reply\n * 'no'\". So the quoted history is cut first — the CRM capture reader's\n * `stripQuotedHistory` — along with a signature and any footer line a mail\n * client quoted without marking it.\n *\n * ## What counts\n *\n * - A short reply that says nothing but no: \"No.\", \"No thanks\", \"Nope\",\n * \"Pass\", \"Stop\" — with a greeting and a sign-off allowed around it.\n * - A request anywhere in the reply: unsubscribe, remove me, take me off\n * the list, stop emailing, don't contact me, opt out, no more emails —\n * unless it is negated (\"please don't remove me\").\n * - \"Not interested\", unless it defers (\"not interested yet\", \"not\n * interested right now, try me next quarter\") or asks (\"not interested?\").\n * - A reply that calls the email spam, which is also a COMPLAINT.\n *\n * \"No rush\", \"no problem\" and \"no worries\" are none of these: a \"no\" only\n * counts when it is the whole answer.\n *==========================================*/\n\nimport { stripQuotedHistory } from '@aglyn/aglyn/app-utils/crm-inbound'\n\nexport interface OutreachOptOutIntent {\n /** The reply asks not to be emailed again. */\n optOut: boolean\n /** The reply calls the email spam — an opt-out that also counts against the mailbox. */\n complaint: boolean\n /** The words that decided it, for the timeline and the log; `null` when nothing matched. */\n matched: string | null\n}\n\nconst NONE: OutreachOptOutIntent = { optOut: false, complaint: false, matched: null }\n\n/** A line of the footer every Outreach email carries, however a client re-quoted it. */\nconst FOOTER_LINE =\n /reply\\s+[\"“”'‘’]?no[\"“”'‘’]?\\s+and\\s+i\\s+won['’]?t\\s+email|(?:business\\s+solicitation|sales\\s+email)\\s+from/i\n\n/**\n * The words a reply adds to its thread: the text above the quoted history,\n * with a signature and any quoted footer line removed.\n */\nexport function outreachFreshReplyText(text: unknown): string {\n const body = stripQuotedHistory(\n String(text ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .replace(/[\\u00a0\\u202f]/g, ' '),\n )\n const lines = body.split('\\n')\n const signature = lines.findIndex((line) => /^--\\s*$/.test(line))\n return (signature >= 0 ? lines.slice(0, signature) : lines)\n .filter((line) => !FOOTER_LINE.test(line))\n .join('\\n')\n .trim()\n}\n\n/** Curly quotes straightened, case folded, whitespace settled. */\nfunction fold(text: string): string {\n return text\n .replace(/[‘’]/g, \"'\")\n .replace(/[“”]/g, '\"')\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nconst GREETING_LINE = /^(hi|hello|hey|dear|greetings|good (morning|afternoon|evening))\\b[^\\n]{0,40}$/i\nconst GREETING_LEAD = /^(hi|hello|hey|dear|greetings)(\\s+[a-z'’-]+)?\\s*[,!.:–—-]\\s*/i\nconst SIGN_OFF =\n /^(thanks|thank you|thx|many thanks|cheers|regards|best|best regards|kind regards|warm regards|sincerely|all the best|sent from my\\b.*|get outlook\\b.*)[\\s,.!]*$/i\nconst NAME = \"[A-Z][a-z'’-]+( [A-Z][a-z'’-]+)?\"\n/** Thanks, then a signed name: \"No thanks, Jordan\" or \"No thanks — Jordan\". */\nconst THANKS_THEN_NAME = new RegExp(`\\\\b(thanks|thank you|thx|cheers)[\\\\s,–—-]+${NAME}[.!]?\\\\s*$`, 'i')\nconst NAME_LINE = new RegExp(`^${NAME}[.!]?$`)\n\n/**\n * What is left of a reply once its greeting, its sign-off and a name signed\n * after \"thanks\" are gone. A name is only taken off after a thank-you,\n * because \"No, WordPress.\" is an answer about a platform, not a signature.\n */\nfunction answerOnly(fresh: string): string {\n const lines = fresh\n .split('\\n')\n .map((line) => line.trim())\n .filter(Boolean)\n if (lines.length > 1 && GREETING_LINE.test(lines[0])) lines.shift()\n const signOff = lines.findIndex((line, index) => index > 0 && SIGN_OFF.test(line))\n const kept = signOff >= 0 ? lines.slice(0, signOff) : lines\n if (\n kept.length > 1 &&\n NAME_LINE.test(kept[kept.length - 1]) &&\n /\\b(thanks|thank you|thx|cheers)[\\s,.!]*$/i.test(kept[kept.length - 2])\n ) {\n kept.pop()\n }\n return kept\n .join(' ')\n .replace(GREETING_LEAD, '')\n .replace(THANKS_THEN_NAME, '$1')\n}\n\n/** The only words a reply may hold to be read as a bare \"no\". */\nconst BARE_NO_WORDS = new Set([\n 'no',\n 'nope',\n 'nah',\n 'pass',\n \"i'll\",\n 'ill',\n 'i',\n 'will',\n 'stop',\n 'unsubscribe',\n 'remove',\n 'me',\n 'us',\n 'please',\n 'thanks',\n 'thank',\n 'you',\n 'thx',\n 'sorry',\n 'but',\n 'not',\n 'for',\n 'interested',\n])\nconst BARE_NO_TRIGGERS = new Set(['no', 'nope', 'nah', 'pass', 'stop', 'unsubscribe', 'remove'])\n\nfunction bareNo(answer: string): string | null {\n const words = fold(answer)\n .replace(/[^a-z' ]+/g, ' ')\n .split(' ')\n .filter(Boolean)\n if (!words.length || words.length > 6) return null\n if (!words.every((word) => BARE_NO_WORDS.has(word))) return null\n const said = words.join(' ')\n if (\n words.some((word) => BARE_NO_TRIGGERS.has(word)) ||\n /\\bnot interested\\b|\\bnot for (me|us)\\b/.test(said)\n ) {\n return said\n }\n return null\n}\n\n/** Negations that turn a request around when they come just before it. */\nconst NEGATED = /\\b(don't|dont|do not|didn't|didnt|did not|never|not|no need to|please don't|wouldn't|won't)\\s+(\\w+\\s+){0,2}$/\n\nconst REQUESTS: readonly RegExp[] = [\n /\\bunsubscrib(e|ed|ing)\\b/,\n /\\bopt(ed|ing)?[ -]?(me )?out\\b/,\n /\\bremove (me|us|my (email|e-mail|address|name|details|info))\\b/,\n /\\bremove\\b[^.!?\\n]{0,30}\\bfrom (your|the|this|all) (list|lists|mailing list|emails?|database|system|sequence)\\b/,\n /\\btake (me|us|my (email|e-mail|address|name)) off\\b/,\n /\\btake\\b[^.!?\\n]{0,30}\\boff (your|the|this|all) (list|lists|mailing list|emails?)\\b/,\n // Aimed at us: \"stop emailing me\", \"stop sending these\", \"stop contacting.\" —\n // not \"we had to stop sending newsletters ourselves\".\n /\\b(stop|quit) (emailing|e-mailing|mailing|contacting|messaging|writing|sending|reaching out)(?=\\s*([.!,;]|$)|\\s+(me|us|my|our|this|these|those|them|to (me|us)|emails?|e-mails?|messages?|mail)\\b)/,\n /\\bplease stop\\b/,\n /^stop\\b(?! (by|in|over|at|the|thinking|worrying))/,\n /\\b(do not|don't|dont|never) (email|e-mail|contact|message|write to|reach out to) (me|us)\\b/,\n /\\bno (more|further) (emails?|e-mails?|messages|mail|contact)\\b/,\n /\\b(don't|do not) want (to (hear|receive)|any more|these)\\b[^.!?\\n]{0,20}\\b(emails?|messages|from you)\\b/,\n]\n\nconst COMPLAINTS: readonly RegExp[] = [\n /\\b(this|that|it|you|your emails?|these emails?|this email)\\s*(is|are|'s|'re)\\s+(just\\s+)?spam\\b/,\n /\\b(stop|quit|don't|do not) spam(ming)?\\b/,\n /\\bspamming (me|us|my|our)\\b/,\n /\\b(i|we)('ve| have| just| will| am| are)?\\s*(report(ed|ing)?|mark(ed|ing)?|flag(ged|ging)?)\\b[^.!?\\n]{0,30}\\b(as|for) spam\\b/,\n]\n\n/** Words after \"not interested\" that put it off rather than rule it out. */\nconst DEFERRAL =\n /^[\\s,]*(yet|right now|for now|now|at the moment|at this (time|moment|point|stage)|today|this (week|month|quarter|year)|until|in the (near )?future|but\\b|however\\b|though\\b)/\n\nfunction notInterested(text: string): string | null {\n const pattern = /\\bnot (really |at all )?interested\\b/g\n for (let match = pattern.exec(text); match; match = pattern.exec(text)) {\n const before = text.slice(0, match.index)\n const after = text.slice(match.index + match[0].length)\n const sentenceEnd = after.search(/[.!?\\n]/)\n const rest = sentenceEnd >= 0 ? after.slice(0, sentenceEnd + 1) : after\n if (rest.trimEnd().endsWith('?')) continue\n if (DEFERRAL.test(after)) continue\n if (/\\b(if|unless|in case|are you|were you|you're|you are)\\s+(\\w+\\s+){0,2}$/.test(before)) continue\n return match[0]\n }\n return null\n}\n\n/**\n * Whether a SUBJECT says nothing but stop — \"Unsubscribe\", \"Remove me\",\n * \"STOP\" — as a message sent from an unsubscribe link, or typed fresh, does.\n * Only a bare answer counts: a subject is a sender's headline, and \"Stop\n * losing leads\" is one.\n */\nexport function detectOutreachOptOutSubject(subject: unknown): OutreachOptOutIntent {\n const said = bareNo(String(subject ?? ''))\n return said ? { optOut: true, complaint: false, matched: said } : NONE\n}\n\n/**\n * Whether a reply's text asks to be left alone — see the module note. Pass\n * the reply's text as received; the quoted history is removed here.\n */\nexport function detectOutreachOptOutIntent(text: unknown): OutreachOptOutIntent {\n const fresh = outreachFreshReplyText(text)\n if (!fresh) return NONE\n const folded = fold(fresh)\n for (const pattern of COMPLAINTS) {\n const match = pattern.exec(folded)\n if (match) return { optOut: true, complaint: true, matched: match[0] }\n }\n const bare = bareNo(answerOnly(fresh))\n if (bare) return { optOut: true, complaint: false, matched: bare }\n for (const pattern of REQUESTS) {\n const match = pattern.exec(folded)\n if (match && !NEGATED.test(folded.slice(0, match.index))) {\n return { optOut: true, complaint: false, matched: match[0] }\n }\n }\n const declined = notInterested(folded)\n if (declined) return { optOut: true, complaint: false, matched: declined }\n return NONE\n}\n"],"names":["stripQuotedHistory","NONE","optOut","complaint","matched","FOOTER_LINE","outreachFreshReplyText","text","body","String","replace","lines","split","signature","findIndex","line","test","slice","filter","join","trim","fold","toLowerCase","GREETING_LINE","GREETING_LEAD","SIGN_OFF","NAME","THANKS_THEN_NAME","RegExp","NAME_LINE","answerOnly","fresh","map","Boolean","length","shift","signOff","index","kept","pop","BARE_NO_WORDS","Set","BARE_NO_TRIGGERS","bareNo","answer","words","every","word","has","said","some","NEGATED","REQUESTS","COMPLAINTS","DEFERRAL","notInterested","pattern","match","exec","before","after","sentenceEnd","search","rest","trimEnd","endsWith","detectOutreachOptOutSubject","subject","detectOutreachOptOutIntent","folded","bare","declined"],"mappings":"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"}
@@ -16,14 +16,44 @@
16
16
  */
17
17
  import { type OutreachGateLookups } from '../engine/gates';
18
18
  import { type OutreachAttestations, type OutreachSequenceSettings } from '../model/outreach.types';
19
- import type { OutreachEnrollBlock, OutreachEnrollPreviewPerson } from '../model/outreach-api';
19
+ import type { OutreachEnrollBlock, OutreachEnrollPreviewPerson, OutreachPersonRef } from '../model/outreach-api';
20
20
  type Firestore = FirebaseFirestore.Firestore;
21
- /** One person as the preview and the enroll read them. */
21
+ /**
22
+ * One person as the preview and the enroll read them: a contact, or a lead
23
+ * the sequence's site holds (AGL-3234).
24
+ *
25
+ * ## A lead is judged as the contact it would be
26
+ *
27
+ * The gates, the merge fields and the country rule all read a CONTACT
28
+ * document — its facet's sources, stage, address, company and title. A lead
29
+ * carries the same facts in its own shape, so rather than teach every gate
30
+ * two records, a lead is handed to them as {@link leadAsContact}: a
31
+ * contact-shaped view whose one facet, under the sequence's group, holds
32
+ * what the lead holds. An imported lead has no inbound source and is cold;
33
+ * one that wrote in through a form is not; a lead is never a customer; its
34
+ * address decides its country; and `{{contact.firstName}}` or
35
+ * `{{contact.company}}` in a step reads the lead's name and company text,
36
+ * so a sequence written for contacts sends to leads unchanged — which is
37
+ * also what lets an enrollment made on a lead keep sending after the lead
38
+ * converts and the view becomes the real contact.
39
+ */
22
40
  export interface OutreachEnrollCandidate {
41
+ /** The record's own id: the contact's, or the lead's person key. */
42
+ personId: string;
43
+ target: 'contact' | 'lead';
44
+ /** The contact's id; `''` for a lead. */
23
45
  contactId: string;
24
- /** The contact document, or `null` when it no longer exists. */
46
+ /** The lead's person key; `null` for a contact. */
47
+ leadId: string | null;
48
+ /**
49
+ * The contact document (`orgs/{orgId}/contacts/{id}`), or for a lead the
50
+ * contact-shaped view of it — see the module note. `null` when the record
51
+ * no longer exists.
52
+ */
25
53
  contact: Record<string, unknown> | null;
26
- /** Whether the sequence's site may see the contact. */
54
+ /** The lead document as stored, for a lead; `null` for a contact. */
55
+ lead: Record<string, unknown> | null;
56
+ /** Whether the sequence's site may see the record. A lead is the site's own. */
27
57
  visible: boolean;
28
58
  /** The name the sequence's site knows them by; `''` for none. */
29
59
  name: string;
@@ -31,16 +61,31 @@ export interface OutreachEnrollCandidate {
31
61
  email: string | null;
32
62
  /** The company the contact is filed under at that site, when there is one. */
33
63
  company: Record<string, unknown> | null;
64
+ /** A lead already converted: the contact it became. Enroll that instead. */
65
+ convertedContactId: string | null;
66
+ /** A lead whose address the workspace already holds as a contact. Enroll that instead. */
67
+ heldAsContactId: string | null;
34
68
  }
35
69
  /**
36
- * The contacts, as the sequence's site knows them, and the company each is
37
- * filed under there — two batched reads for everyone.
70
+ * A lead as the gates and the merge fields read a contact — see the
71
+ * module note. The facet is the sequence's group's, which is the one every
72
+ * reader asks for. The lead's `sources` — `form:{id}`, `booking`, `import`,
73
+ * `manual`, `api`, `signup` — become the facet's source flags by kind, so
74
+ * the inbound rule reads them as it reads a contact's.
75
+ */
76
+ export declare function leadAsContact(lead: Record<string, unknown>, contactGroupId: string): Record<string, unknown>;
77
+ /**
78
+ * The people, as the sequence's site knows them, and the company each
79
+ * contact is filed under there — batched reads for everyone: the contacts
80
+ * and the leads by id, the companies once, and for each lead one address
81
+ * lookup, because a lead the workspace already holds as a contact is that
82
+ * contact's to enroll.
38
83
  */
39
84
  export declare function readOutreachEnrollCandidates(firestore: Firestore, input: {
40
85
  orgId: string;
41
86
  hostId: string;
42
87
  contactGroupId: string;
43
- contactIds: readonly string[];
88
+ people: readonly OutreachPersonRef[];
44
89
  }): Promise<OutreachEnrollCandidate[]>;
45
90
  /** What stands in the way before the gates are asked anything. */
46
91
  export declare function outreachCandidateBlocks(candidate: OutreachEnrollCandidate, facts: {